mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
feat(traffic): add live refresh, geo maps, and app overview widget
- Add live 24h polling toggle to server/application analytics views - Add geo visualization (world map, country flags/names) for traffic - Add dashboard nudge for servers eligible but not yet analytics-enabled - Add lazy-loaded TrafficOverview widget to application General page - Default-enable traffic analytics on new eligible server settings - Rotate Caddy access logs via lumberjack roll options - Add Traefik logrotate sidecar for copytruncate access-log rotation
This commit is contained in:
@@ -27,12 +27,36 @@ class TrafficAnalytics extends Component
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $topCountries = [];
|
||||
|
||||
/**
|
||||
* Servers that could run traffic analytics but have it off — drives the dashboard nudge.
|
||||
*
|
||||
* @var array<int, array{uuid: string, name: string}>
|
||||
*/
|
||||
public array $eligibleDisabledServers = [];
|
||||
|
||||
// Stable key over the eligible-disabled set so a localStorage dismissal sticks until
|
||||
// a new eligible server appears (which changes the key and re-shows the nudge).
|
||||
public string $nudgeKey = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->servers = Server::ownedByCurrentTeamCached()
|
||||
$allServers = Server::ownedByCurrentTeamCached();
|
||||
|
||||
$this->servers = $allServers
|
||||
->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
|
||||
->values();
|
||||
|
||||
$eligibleDisabled = $allServers
|
||||
->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled()
|
||||
&& ! $server->isSwarm()
|
||||
&& ! $server->isBuildServer())
|
||||
->values();
|
||||
|
||||
$this->eligibleDisabledServers = $eligibleDisabled
|
||||
->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name])
|
||||
->all();
|
||||
$this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12);
|
||||
|
||||
if ($this->servers->isNotEmpty()) {
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ class Analytics extends Component
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
// Realtime refresh. On by default for the 24h range; the 60s cadence matches the
|
||||
// SentinelTrafficClient cache TTL and Sentinel's per-minute rollups, so polling
|
||||
// faster returns identical data. Auto-paused (control disabled) for 7d/30d.
|
||||
public bool $live = true;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public array $topPaths = [];
|
||||
@@ -43,6 +48,22 @@ class Analytics extends Component
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function toggleLive(): void
|
||||
{
|
||||
if ($this->range !== '24h') {
|
||||
return;
|
||||
}
|
||||
$this->live = ! $this->live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime polling is only armed when the user has it on and the range is 24h.
|
||||
*/
|
||||
public function isLivePollable(): bool
|
||||
{
|
||||
return $this->live && $this->range === '24h';
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Compact last-24h traffic KPI card for the application General page. Lazy-loaded so
|
||||
* the General page isn't blocked by Sentinel's docker-exec round-trip.
|
||||
*/
|
||||
#[Lazy]
|
||||
class TrafficOverview extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public bool $eligible = false;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Runs in the deferred lazy-load request, so the Sentinel fetch never blocks
|
||||
// the initial General-page render.
|
||||
$server = $this->application->destination?->server;
|
||||
$this->serverUuid = $server?->uuid;
|
||||
$this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
|
||||
$this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false;
|
||||
|
||||
if ($this->enabled && $server) {
|
||||
try {
|
||||
$client = app(SentinelTrafficClient::class, ['server' => $server]);
|
||||
$this->overview = $client->appOverview($this->application->uuid, '24h')->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->overview = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function placeholder(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div class="h-24 w-full animate-pulse rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.02]"></div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.traffic-overview');
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Server\ConfigureTrafficAnalytics;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Analytics extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public string $chartId = 'server-analytics';
|
||||
@@ -17,6 +21,11 @@ class Analytics extends Component
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
// Realtime refresh. On by default for the 24h range; the 60s cadence matches the
|
||||
// SentinelTrafficClient cache TTL and Sentinel's per-minute rollups, so polling
|
||||
// faster returns identical data. Auto-paused (control disabled) for 7d/30d.
|
||||
public bool $live = true;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public array $topPaths = [];
|
||||
@@ -53,6 +62,49 @@ class Analytics extends Component
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function toggleLive(): void
|
||||
{
|
||||
if ($this->range !== '24h') {
|
||||
return;
|
||||
}
|
||||
$this->live = ! $this->live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this server can run traffic analytics at all (Swarm/Build cannot).
|
||||
*/
|
||||
public function isEligibleForTrafficAnalytics(): bool
|
||||
{
|
||||
return ! $this->server->isSwarm() && ! $this->server->isBuildServer();
|
||||
}
|
||||
|
||||
public function enableTrafficAnalytics(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if (! $this->isEligibleForTrafficAnalytics()) {
|
||||
$this->dispatch('error', 'Traffic analytics is not supported on Swarm/Build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
ConfigureTrafficAnalytics::run($this->server, true);
|
||||
$this->server->refresh();
|
||||
$this->enabled = true;
|
||||
$this->dispatch('success', 'Traffic analytics enabled. Restarting proxy and Sentinel.');
|
||||
$this->loadData();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime polling is only armed when the user has it on and the range is 24h.
|
||||
*/
|
||||
public function isLivePollable(): bool
|
||||
{
|
||||
return $this->live && $this->range === '24h';
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
|
||||
@@ -139,6 +139,15 @@ class ServerSetting extends Model
|
||||
{
|
||||
static::creating(function ($setting) {
|
||||
try {
|
||||
// Enable traffic analytics by default for eligible servers, unless the
|
||||
// caller explicitly set a value. Swarm and build servers are ineligible,
|
||||
// mirroring the toggle guard so the flag never contradicts capability.
|
||||
// Runs before sentinel generation, which may throw and be swallowed below.
|
||||
if (! $setting->isDirty('is_traffic_analytics_enabled')) {
|
||||
$isSwarm = $setting->is_swarm_manager || $setting->is_swarm_worker;
|
||||
$isBuild = (bool) $setting->is_build_server;
|
||||
$setting->is_traffic_analytics_enabled = ! $isSwarm && ! $isBuild;
|
||||
}
|
||||
if (str($setting->sentinel_token)->isEmpty()) {
|
||||
$setting->generateSentinelToken(save: false, ignoreEvent: true);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,34 @@ class SentinelTrafficClient
|
||||
return TrafficOverviewData::fromSentinel($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UI range key (24h/7d/30d) into ISO-8601 Zulu from/to bounds.
|
||||
*
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
public static function rangeWindow(string $range): array
|
||||
{
|
||||
$to = now();
|
||||
$from = match ($range) {
|
||||
'7d' => now()->subDays(7),
|
||||
'30d' => now()->subDays(30),
|
||||
default => now()->subDay(),
|
||||
};
|
||||
|
||||
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim shared fetch for a single application's overview over a UI range, so the
|
||||
* General-page widget and the full analytics tab don't duplicate window + client calls.
|
||||
*/
|
||||
public function appOverview(string $appKey, string $range = '24h'): TrafficOverviewData
|
||||
{
|
||||
[$from, $to] = self::rangeWindow($range);
|
||||
|
||||
return $this->overview($appKey, $from, $to);
|
||||
}
|
||||
|
||||
public function paths(?string $appKey, string $from, string $to, int $limit = 50): Collection
|
||||
{
|
||||
if ($appKey !== null) {
|
||||
|
||||
@@ -495,6 +495,13 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
|
||||
}
|
||||
if ($is_traffic_analytics_enabled) {
|
||||
$labels->push("caddy_{$loop}.log.output=file /traffic/access.log");
|
||||
// Explicit lumberjack roll options so the access log doesn't grow unbounded
|
||||
// (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted
|
||||
// keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }.
|
||||
// Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change).
|
||||
$labels->push("caddy_{$loop}.log.output.roll_size=20MiB");
|
||||
$labels->push("caddy_{$loop}.log.output.roll_keep=5");
|
||||
$labels->push("caddy_{$loop}.log.output.roll_keep_for=168h");
|
||||
$labels->push("caddy_{$loop}.log.format=json");
|
||||
$labels->push("caddy_{$loop}.log_append=coolify_app_id {$uuid}");
|
||||
}
|
||||
|
||||
@@ -378,6 +378,24 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
|
||||
$config['services']['traefik']['command'][] = $custom_command;
|
||||
}
|
||||
}
|
||||
|
||||
// Traefik has no native access-log rotation. Add a minimal logrotate sidecar that
|
||||
// rotates /traefik/access.log in copytruncate mode so the file keeps the same inode
|
||||
// and Sentinel keeps its file handle (the tailer handles len < pos by seeking to 0).
|
||||
// Only for the non-swarm, non-dev production path (dev uses a different access-log path).
|
||||
if ($server->isTrafficAnalyticsEnabled() && ! $server->isSwarm() && ! isDev()) {
|
||||
$config['services']['traefik-logrotate'] = [
|
||||
'image' => 'alpine:3.20',
|
||||
'restart' => RESTART_MODE,
|
||||
'volumes' => [
|
||||
"{$proxy_path}:/traefik",
|
||||
],
|
||||
'labels' => [
|
||||
'coolify.managed=true',
|
||||
],
|
||||
'entrypoint' => 'sh -c \'apk add --no-cache logrotate >/dev/null 2>&1; printf "/traefik/access.log {\n copytruncate\n size 20M\n rotate 5\n compress\n missingok\n notifempty\n}\n" > /etc/logrotate.d/traefik-access; while true; do logrotate -s /traefik/.logrotate.state /etc/logrotate.d/traefik-access; sleep 3600; done\'',
|
||||
];
|
||||
}
|
||||
} elseif ($proxy_type === 'CADDY') {
|
||||
$config = [
|
||||
'networks' => $array_of_networks->toArray(),
|
||||
|
||||
@@ -4769,3 +4769,308 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
|
||||
|
||||
return str($value)->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO 3166-1 alpha-2 country code into its regional-indicator flag emoji.
|
||||
*
|
||||
* The input is case-insensitive (e.g. "us" and "US" both yield the United States flag).
|
||||
* For null, empty, or otherwise invalid input (not exactly two ASCII letters) a neutral
|
||||
* globe emoji is returned to represent an "Unknown" origin.
|
||||
*/
|
||||
function countryFlagEmoji(?string $a2): string
|
||||
{
|
||||
$unknown = '🌐';
|
||||
|
||||
if (! is_string($a2)) {
|
||||
return $unknown;
|
||||
}
|
||||
|
||||
$code = strtoupper(trim($a2));
|
||||
|
||||
if (preg_match('/^[A-Z]{2}$/', $code) !== 1) {
|
||||
return $unknown;
|
||||
}
|
||||
|
||||
$flag = '';
|
||||
foreach (str_split($code) as $letter) {
|
||||
$flag .= mb_chr(0x1F1E6 + (ord($letter) - ord('A')), 'UTF-8');
|
||||
}
|
||||
|
||||
return $flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ISO 3166-1 alpha-2 country code to its English country name.
|
||||
*
|
||||
* Uses a bundled ISO 3166-1 lookup so the result is deterministic and does not
|
||||
* depend on the intl extension being installed. Returns "Unknown" for null,
|
||||
* empty, invalid, or unassigned codes.
|
||||
*/
|
||||
function countryName(?string $a2): string
|
||||
{
|
||||
$unknown = 'Unknown';
|
||||
|
||||
if (! is_string($a2)) {
|
||||
return $unknown;
|
||||
}
|
||||
|
||||
$code = strtoupper(trim($a2));
|
||||
|
||||
if (preg_match('/^[A-Z]{2}$/', $code) !== 1) {
|
||||
return $unknown;
|
||||
}
|
||||
|
||||
static $names = [
|
||||
'AD' => 'Andorra',
|
||||
'AE' => 'United Arab Emirates',
|
||||
'AF' => 'Afghanistan',
|
||||
'AG' => 'Antigua & Barbuda',
|
||||
'AI' => 'Anguilla',
|
||||
'AL' => 'Albania',
|
||||
'AM' => 'Armenia',
|
||||
'AO' => 'Angola',
|
||||
'AQ' => 'Antarctica',
|
||||
'AR' => 'Argentina',
|
||||
'AS' => 'American Samoa',
|
||||
'AT' => 'Austria',
|
||||
'AU' => 'Australia',
|
||||
'AW' => 'Aruba',
|
||||
'AX' => 'Åland Islands',
|
||||
'AZ' => 'Azerbaijan',
|
||||
'BA' => 'Bosnia & Herzegovina',
|
||||
'BB' => 'Barbados',
|
||||
'BD' => 'Bangladesh',
|
||||
'BE' => 'Belgium',
|
||||
'BF' => 'Burkina Faso',
|
||||
'BG' => 'Bulgaria',
|
||||
'BH' => 'Bahrain',
|
||||
'BI' => 'Burundi',
|
||||
'BJ' => 'Benin',
|
||||
'BL' => 'St. Barthélemy',
|
||||
'BM' => 'Bermuda',
|
||||
'BN' => 'Brunei',
|
||||
'BO' => 'Bolivia',
|
||||
'BQ' => 'Caribbean Netherlands',
|
||||
'BR' => 'Brazil',
|
||||
'BS' => 'Bahamas',
|
||||
'BT' => 'Bhutan',
|
||||
'BV' => 'Bouvet Island',
|
||||
'BW' => 'Botswana',
|
||||
'BY' => 'Belarus',
|
||||
'BZ' => 'Belize',
|
||||
'CA' => 'Canada',
|
||||
'CC' => 'Cocos (Keeling) Islands',
|
||||
'CD' => 'Congo - Kinshasa',
|
||||
'CF' => 'Central African Republic',
|
||||
'CG' => 'Congo - Brazzaville',
|
||||
'CH' => 'Switzerland',
|
||||
'CI' => 'Côte d’Ivoire',
|
||||
'CK' => 'Cook Islands',
|
||||
'CL' => 'Chile',
|
||||
'CM' => 'Cameroon',
|
||||
'CN' => 'China',
|
||||
'CO' => 'Colombia',
|
||||
'CR' => 'Costa Rica',
|
||||
'CU' => 'Cuba',
|
||||
'CV' => 'Cape Verde',
|
||||
'CW' => 'Curaçao',
|
||||
'CX' => 'Christmas Island',
|
||||
'CY' => 'Cyprus',
|
||||
'CZ' => 'Czechia',
|
||||
'DE' => 'Germany',
|
||||
'DJ' => 'Djibouti',
|
||||
'DK' => 'Denmark',
|
||||
'DM' => 'Dominica',
|
||||
'DO' => 'Dominican Republic',
|
||||
'DZ' => 'Algeria',
|
||||
'EC' => 'Ecuador',
|
||||
'EE' => 'Estonia',
|
||||
'EG' => 'Egypt',
|
||||
'EH' => 'Western Sahara',
|
||||
'ER' => 'Eritrea',
|
||||
'ES' => 'Spain',
|
||||
'ET' => 'Ethiopia',
|
||||
'FI' => 'Finland',
|
||||
'FJ' => 'Fiji',
|
||||
'FK' => 'Falkland Islands',
|
||||
'FM' => 'Micronesia',
|
||||
'FO' => 'Faroe Islands',
|
||||
'FR' => 'France',
|
||||
'GA' => 'Gabon',
|
||||
'GB' => 'United Kingdom',
|
||||
'GD' => 'Grenada',
|
||||
'GE' => 'Georgia',
|
||||
'GF' => 'French Guiana',
|
||||
'GG' => 'Guernsey',
|
||||
'GH' => 'Ghana',
|
||||
'GI' => 'Gibraltar',
|
||||
'GL' => 'Greenland',
|
||||
'GM' => 'Gambia',
|
||||
'GN' => 'Guinea',
|
||||
'GP' => 'Guadeloupe',
|
||||
'GQ' => 'Equatorial Guinea',
|
||||
'GR' => 'Greece',
|
||||
'GS' => 'South Georgia & South Sandwich Islands',
|
||||
'GT' => 'Guatemala',
|
||||
'GU' => 'Guam',
|
||||
'GW' => 'Guinea-Bissau',
|
||||
'GY' => 'Guyana',
|
||||
'HK' => 'Hong Kong SAR China',
|
||||
'HM' => 'Heard & McDonald Islands',
|
||||
'HN' => 'Honduras',
|
||||
'HR' => 'Croatia',
|
||||
'HT' => 'Haiti',
|
||||
'HU' => 'Hungary',
|
||||
'ID' => 'Indonesia',
|
||||
'IE' => 'Ireland',
|
||||
'IL' => 'Israel',
|
||||
'IM' => 'Isle of Man',
|
||||
'IN' => 'India',
|
||||
'IO' => 'British Indian Ocean Territory',
|
||||
'IQ' => 'Iraq',
|
||||
'IR' => 'Iran',
|
||||
'IS' => 'Iceland',
|
||||
'IT' => 'Italy',
|
||||
'JE' => 'Jersey',
|
||||
'JM' => 'Jamaica',
|
||||
'JO' => 'Jordan',
|
||||
'JP' => 'Japan',
|
||||
'KE' => 'Kenya',
|
||||
'KG' => 'Kyrgyzstan',
|
||||
'KH' => 'Cambodia',
|
||||
'KI' => 'Kiribati',
|
||||
'KM' => 'Comoros',
|
||||
'KN' => 'St. Kitts & Nevis',
|
||||
'KP' => 'North Korea',
|
||||
'KR' => 'South Korea',
|
||||
'KW' => 'Kuwait',
|
||||
'KY' => 'Cayman Islands',
|
||||
'KZ' => 'Kazakhstan',
|
||||
'LA' => 'Laos',
|
||||
'LB' => 'Lebanon',
|
||||
'LC' => 'St. Lucia',
|
||||
'LI' => 'Liechtenstein',
|
||||
'LK' => 'Sri Lanka',
|
||||
'LR' => 'Liberia',
|
||||
'LS' => 'Lesotho',
|
||||
'LT' => 'Lithuania',
|
||||
'LU' => 'Luxembourg',
|
||||
'LV' => 'Latvia',
|
||||
'LY' => 'Libya',
|
||||
'MA' => 'Morocco',
|
||||
'MC' => 'Monaco',
|
||||
'MD' => 'Moldova',
|
||||
'ME' => 'Montenegro',
|
||||
'MF' => 'St. Martin',
|
||||
'MG' => 'Madagascar',
|
||||
'MH' => 'Marshall Islands',
|
||||
'MK' => 'North Macedonia',
|
||||
'ML' => 'Mali',
|
||||
'MM' => 'Myanmar (Burma)',
|
||||
'MN' => 'Mongolia',
|
||||
'MO' => 'Macao SAR China',
|
||||
'MP' => 'Northern Mariana Islands',
|
||||
'MQ' => 'Martinique',
|
||||
'MR' => 'Mauritania',
|
||||
'MS' => 'Montserrat',
|
||||
'MT' => 'Malta',
|
||||
'MU' => 'Mauritius',
|
||||
'MV' => 'Maldives',
|
||||
'MW' => 'Malawi',
|
||||
'MX' => 'Mexico',
|
||||
'MY' => 'Malaysia',
|
||||
'MZ' => 'Mozambique',
|
||||
'NA' => 'Namibia',
|
||||
'NC' => 'New Caledonia',
|
||||
'NE' => 'Niger',
|
||||
'NF' => 'Norfolk Island',
|
||||
'NG' => 'Nigeria',
|
||||
'NI' => 'Nicaragua',
|
||||
'NL' => 'Netherlands',
|
||||
'NO' => 'Norway',
|
||||
'NP' => 'Nepal',
|
||||
'NR' => 'Nauru',
|
||||
'NU' => 'Niue',
|
||||
'NZ' => 'New Zealand',
|
||||
'OM' => 'Oman',
|
||||
'PA' => 'Panama',
|
||||
'PE' => 'Peru',
|
||||
'PF' => 'French Polynesia',
|
||||
'PG' => 'Papua New Guinea',
|
||||
'PH' => 'Philippines',
|
||||
'PK' => 'Pakistan',
|
||||
'PL' => 'Poland',
|
||||
'PM' => 'St. Pierre & Miquelon',
|
||||
'PN' => 'Pitcairn Islands',
|
||||
'PR' => 'Puerto Rico',
|
||||
'PS' => 'Palestinian Territories',
|
||||
'PT' => 'Portugal',
|
||||
'PW' => 'Palau',
|
||||
'PY' => 'Paraguay',
|
||||
'QA' => 'Qatar',
|
||||
'RE' => 'Réunion',
|
||||
'RO' => 'Romania',
|
||||
'RS' => 'Serbia',
|
||||
'RU' => 'Russia',
|
||||
'RW' => 'Rwanda',
|
||||
'SA' => 'Saudi Arabia',
|
||||
'SB' => 'Solomon Islands',
|
||||
'SC' => 'Seychelles',
|
||||
'SD' => 'Sudan',
|
||||
'SE' => 'Sweden',
|
||||
'SG' => 'Singapore',
|
||||
'SH' => 'St. Helena',
|
||||
'SI' => 'Slovenia',
|
||||
'SJ' => 'Svalbard & Jan Mayen',
|
||||
'SK' => 'Slovakia',
|
||||
'SL' => 'Sierra Leone',
|
||||
'SM' => 'San Marino',
|
||||
'SN' => 'Senegal',
|
||||
'SO' => 'Somalia',
|
||||
'SR' => 'Suriname',
|
||||
'SS' => 'South Sudan',
|
||||
'ST' => 'São Tomé & Príncipe',
|
||||
'SV' => 'El Salvador',
|
||||
'SX' => 'Sint Maarten',
|
||||
'SY' => 'Syria',
|
||||
'SZ' => 'Eswatini',
|
||||
'TC' => 'Turks & Caicos Islands',
|
||||
'TD' => 'Chad',
|
||||
'TF' => 'French Southern Territories',
|
||||
'TG' => 'Togo',
|
||||
'TH' => 'Thailand',
|
||||
'TJ' => 'Tajikistan',
|
||||
'TK' => 'Tokelau',
|
||||
'TL' => 'Timor-Leste',
|
||||
'TM' => 'Turkmenistan',
|
||||
'TN' => 'Tunisia',
|
||||
'TO' => 'Tonga',
|
||||
'TR' => 'Türkiye',
|
||||
'TT' => 'Trinidad & Tobago',
|
||||
'TV' => 'Tuvalu',
|
||||
'TW' => 'Taiwan',
|
||||
'TZ' => 'Tanzania',
|
||||
'UA' => 'Ukraine',
|
||||
'UG' => 'Uganda',
|
||||
'UM' => 'U.S. Outlying Islands',
|
||||
'US' => 'United States',
|
||||
'UY' => 'Uruguay',
|
||||
'UZ' => 'Uzbekistan',
|
||||
'VA' => 'Vatican City',
|
||||
'VC' => 'St. Vincent & Grenadines',
|
||||
'VE' => 'Venezuela',
|
||||
'VG' => 'British Virgin Islands',
|
||||
'VI' => 'U.S. Virgin Islands',
|
||||
'VN' => 'Vietnam',
|
||||
'VU' => 'Vanuatu',
|
||||
'WF' => 'Wallis & Futuna',
|
||||
'WS' => 'Samoa',
|
||||
'YE' => 'Yemen',
|
||||
'YT' => 'Mayotte',
|
||||
'ZA' => 'South Africa',
|
||||
'ZM' => 'Zambia',
|
||||
'ZW' => 'Zimbabwe',
|
||||
];
|
||||
|
||||
return $names[$code] ?? $unknown;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,45 @@
|
||||
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/*
|
||||
Traffic-analytics chart tokens (light defaults; dark overrides below).
|
||||
One source of truth shared by ApexCharts donuts, the geo choropleth SVG,
|
||||
and proportional bars — JS reads them at runtime via getComputedStyle.
|
||||
Palette validated with the dataviz skill; contrast ratios are recorded in
|
||||
the PR description. See resources/views/livewire/traffic/_geo.blade.php.
|
||||
*/
|
||||
:root {
|
||||
/* Categorical HTTP status palette (labelled 2xx/3xx/4xx/5xx in every legend). */
|
||||
--chart-status-2xx: #15803d;
|
||||
--chart-status-3xx: #2563eb;
|
||||
--chart-status-4xx: #d97706;
|
||||
--chart-status-5xx: #dc2626;
|
||||
|
||||
/* Sequential 5-step geo ramp (low -> high traffic) + neutral empty. */
|
||||
--chart-geo-1: #3b82f6;
|
||||
--chart-geo-2: #2563eb;
|
||||
--chart-geo-3: #1d4ed8;
|
||||
--chart-geo-4: #1e40af;
|
||||
--chart-geo-5: #172554;
|
||||
--chart-geo-empty: #e5e7eb;
|
||||
--chart-geo-stroke: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chart-status-2xx: #22c55e;
|
||||
--chart-status-3xx: #3b82f6;
|
||||
--chart-status-4xx: #f59e0b;
|
||||
--chart-status-5xx: #ef4444;
|
||||
|
||||
--chart-geo-1: #2563eb;
|
||||
--chart-geo-2: #3b82f6;
|
||||
--chart-geo-3: #60a5fa;
|
||||
--chart-geo-4: #93c5fd;
|
||||
--chart-geo-5: #bfdbfe;
|
||||
--chart-geo-empty: #262626;
|
||||
--chart-geo-stroke: #101010;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
|
||||
@@ -34,6 +34,39 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! empty($eligibleDisabledServers))
|
||||
<div x-data="{ dismissed: localStorage.getItem('traffic-nudge-{{ $nudgeKey }}') === '1' }" x-show="!dismissed" x-cloak
|
||||
class="mb-3 flex items-start gap-3 rounded-xl border border-neutral-200 bg-white px-4 py-3 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[12px] font-semibold text-black dark:text-fg">
|
||||
{{ count($eligibleDisabledServers) === 1 ? '1 server can start collecting traffic analytics' : count($eligibleDisabledServers).' servers can start collecting traffic analytics' }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Enabling regenerates the proxy config and restarts the proxy + Sentinel (a brief blip).
|
||||
Works with Traefik & Caddy.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if (count($eligibleDisabledServers) === 1)
|
||||
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $eligibleDisabledServers[0]['uuid']]) }}" {{ wireNavigate() }}>
|
||||
Enable on {{ \Illuminate\Support\Str::limit($eligibleDisabledServers[0]['name'], 16) }}
|
||||
</a>
|
||||
@else
|
||||
<a class="button" href="{{ route('server.index') }}" {{ wireNavigate() }}>
|
||||
View servers
|
||||
</a>
|
||||
@endif
|
||||
<button type="button" title="Dismiss"
|
||||
@click="dismissed = true; localStorage.setItem('traffic-nudge-{{ $nudgeKey }}', '1')"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l12 12M6 18L18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($servers->isEmpty())
|
||||
<x-empty size="sm" title="Traffic analytics is not enabled"
|
||||
description="Enable Sentinel traffic analytics on a server to see a team-wide summary here."
|
||||
@@ -86,7 +119,7 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="mt-4 grid min-w-0 grid-cols-1 gap-4">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
|
||||
@@ -126,21 +159,7 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@forelse ($topCountries as $row)
|
||||
<div wire:key="dashboard-traffic-country-{{ $row['value'] }}"
|
||||
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]">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[12px] text-black dark:text-fg">{{ $row['value'] }}</span>
|
||||
<span
|
||||
class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ number_format($row['requests']) }} req</span>
|
||||
<span
|
||||
class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ formatBytes($row['bytesOut']) }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<x-empty size="sm" title="No country data"
|
||||
description="No country data was recorded for the selected range."
|
||||
icon-name="network" />
|
||||
@endforelse
|
||||
@include('livewire.traffic._geo', ['countries' => $topCountries, 'attribution' => null])
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -4,7 +4,6 @@ $tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:b
|
||||
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
|
||||
|
||||
$dimensionLabels = [
|
||||
'country' => 'Countries',
|
||||
'referer' => 'Referrers',
|
||||
'browser' => 'Browsers',
|
||||
'os' => 'Operating systems',
|
||||
@@ -15,7 +14,7 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
<div class="flex flex-col gap-6">
|
||||
@if (! $enabled)
|
||||
<x-application.settings-section id="analytics-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel.">
|
||||
helper="Inspect traffic statistics reported by Sentinel.">
|
||||
@if ($analyticsServerUuid)
|
||||
<x-slot:actions>
|
||||
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $analyticsServerUuid]) }}"
|
||||
@@ -31,28 +30,35 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
</x-application.settings-section>
|
||||
@elseif (! $overview)
|
||||
<x-application.settings-section id="analytics-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel.">
|
||||
helper="Inspect traffic statistics reported by Sentinel.">
|
||||
<x-empty size="sm" title="No analytics data yet"
|
||||
description="We could not load traffic analytics for the selected range. Try a different range or check back shortly."
|
||||
icon-name="network" />
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
@if ($this->isLivePollable())
|
||||
<div wire:poll.60s="loadData" class="hidden"></div>
|
||||
@endif
|
||||
|
||||
<x-application.settings-section id="analytics-range-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel.">
|
||||
helper="Inspect traffic statistics reported by Sentinel.">
|
||||
<x-slot:actions>
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<button type="button" wire:click="setRange('24h')"
|
||||
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
|
||||
24 hours
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('7d')"
|
||||
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
7 days
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('30d')"
|
||||
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
30 days
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
@include('livewire.traffic._live-toggle')
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<button type="button" wire:click="setRange('24h')"
|
||||
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
|
||||
24 hours
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('7d')"
|
||||
@class([$tabButtonBase, $range === '7d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
7 days
|
||||
</button>
|
||||
<button type="button" wire:click="setRange('30d')"
|
||||
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
30 days
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -89,9 +95,13 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
(() => {
|
||||
checkTheme();
|
||||
|
||||
const statusColorsLight = ['#0ca30c', '#2a78d6', '#fab219', '#d03b3b'];
|
||||
const statusColorsDark = ['#0ca30c', '#3987e5', '#fab219', '#d03b3b'];
|
||||
const statusColors = () => theme === 'light' ? statusColorsLight : statusColorsDark;
|
||||
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const statusColors = () => [
|
||||
cssVar('--chart-status-2xx'),
|
||||
cssVar('--chart-status-3xx'),
|
||||
cssVar('--chart-status-4xx'),
|
||||
cssVar('--chart-status-5xx'),
|
||||
];
|
||||
|
||||
const statusChart = new ApexCharts(document.getElementById('{!! $chartId !!}-status'), {
|
||||
chart: {
|
||||
@@ -166,6 +176,14 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
@endforelse
|
||||
</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>
|
||||
|
||||
@foreach ($dimensionLabels as $dimension => $label)
|
||||
<x-application.settings-section id="analytics-{{ $dimension }}-section" title="{{ $label }}"
|
||||
helper="Top {{ strtolower($label) }} by request count for the selected range." flush>
|
||||
@@ -180,12 +198,6 @@ $analyticsServerUuid = $application->destination?->server?->uuid;
|
||||
<x-empty size="sm" title="No data" description="No {{ strtolower($label) }} data for the selected range."
|
||||
icon-name="network" />
|
||||
@endforelse
|
||||
|
||||
@if ($dimension === 'country' && $attribution)
|
||||
<p class="border-t border-neutral-200 px-4 py-2 text-[11px] text-neutral-400 dark:border-white/[0.07] dark:text-fg-faint">
|
||||
{{ $attribution }}
|
||||
</p>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
|
||||
</x-application.settings-section>
|
||||
|
||||
<livewire:project.application.traffic-overview :application="$application"
|
||||
:key="'application-traffic-overview-'.$application->id" />
|
||||
|
||||
<x-application.settings-section id="access-section" title="Access" helper="Manage how this application is reached publicly and from the Docker network.">
|
||||
<section id="public-access-section" @class([
|
||||
'border-b border-neutral-200 pb-5 dark:border-white/[0.07]' => $buildPack !== 'dockercompose',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
@php
|
||||
$analyticsRoute = route('project.application.analytics', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if (! $enabled)
|
||||
@if ($eligible && $serverUuid)
|
||||
<section class="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-[13px] font-semibold text-black dark:text-fg">Traffic analytics</h3>
|
||||
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
Enable Sentinel traffic analytics on this server to see requests, visitors, and
|
||||
geography for this application. Restarts the proxy + Sentinel.
|
||||
</p>
|
||||
</div>
|
||||
<a class="button shrink-0" href="{{ route('server.sentinel', ['server_uuid' => $serverUuid]) }}" {{ wireNavigate() }}>
|
||||
Server settings
|
||||
<x-external-link />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@else
|
||||
<section class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
|
||||
<div>
|
||||
<h3 class="text-[12px]! leading-4! font-semibold! text-black dark:text-fg">Traffic (last 24h)</h3>
|
||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">Request activity for this application</p>
|
||||
</div>
|
||||
<a class="text-[12px] font-medium text-coollabs hover:underline dark:text-fg" href="{{ $analyticsRoute }}" {{ wireNavigate() }}>
|
||||
View full analytics →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (! $this->hasData())
|
||||
<p class="px-4 py-4 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No traffic recorded in the last 24h yet.
|
||||
</p>
|
||||
@else
|
||||
<div class="grid grid-cols-2 gap-px bg-neutral-200 sm:grid-cols-4 dark:bg-white/[0.07]">
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['requests'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Unique visitors</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['uniqueVisitors'] ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ $this->errorRate() }}%</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 bg-white px-4 py-3 dark:bg-base">
|
||||
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">p95 latency</span>
|
||||
<span class="text-lg font-semibold text-black dark:text-fg">{{ number_format($overview['latencyP95'] ?? 0, 1) }} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@@ -4,7 +4,6 @@ $tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:b
|
||||
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
|
||||
|
||||
$dimensionLabels = [
|
||||
'country' => 'Countries',
|
||||
'referer' => 'Referrers',
|
||||
'browser' => 'Browsers',
|
||||
'os' => 'Operating systems',
|
||||
@@ -25,7 +24,7 @@ $dimensionLabels = [
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
@if (! $enabled)
|
||||
<x-application.settings-section id="analytics-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
|
||||
helper="Inspect traffic statistics reported by Sentinel across all applications on this server.">
|
||||
<x-slot:actions>
|
||||
<a class="button" href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
@@ -33,22 +32,53 @@ $dimensionLabels = [
|
||||
<x-external-link />
|
||||
</a>
|
||||
</x-slot:actions>
|
||||
<x-empty size="sm" title="Traffic analytics is not enabled"
|
||||
description="Enable Sentinel traffic analytics for this server to start collecting request analytics."
|
||||
icon-name="network" />
|
||||
|
||||
<div class="flex flex-col gap-4 px-4 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-[13px] font-semibold text-black dark:text-fg">
|
||||
Turn on traffic analytics for this server
|
||||
</h3>
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
See request volume, status codes, top paths, and visitor geography for every
|
||||
application on this server. Here's exactly what enabling does:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@include('livewire.traffic._enable-benefits')
|
||||
|
||||
@if ($this->isEligibleForTrafficAnalytics())
|
||||
<div>
|
||||
<button type="button" wire:click="enableTrafficAnalytics" class="button"
|
||||
wire:loading.attr="disabled" wire:target="enableTrafficAnalytics">
|
||||
<span wire:loading.remove wire:target="enableTrafficAnalytics">Enable traffic analytics</span>
|
||||
<span wire:loading wire:target="enableTrafficAnalytics">Enabling…</span>
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-[12px] font-medium text-amber-600 dark:text-amber-400">
|
||||
Traffic analytics is not available on Swarm or Build-pack servers.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@elseif (! $overview)
|
||||
<x-application.settings-section id="analytics-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
|
||||
helper="Inspect traffic statistics reported by Sentinel across all applications on this server.">
|
||||
<x-empty size="sm" title="No analytics data yet"
|
||||
description="We could not load traffic analytics for the selected range. Try a different range or check back shortly."
|
||||
icon-name="network" />
|
||||
</x-application.settings-section>
|
||||
@else
|
||||
@if ($this->isLivePollable())
|
||||
<div wire:poll.60s="loadData" class="hidden"></div>
|
||||
@endif
|
||||
|
||||
<x-application.settings-section id="analytics-range-section" title="Analytics"
|
||||
helper="Inspect Cloudflare-style traffic statistics reported by Sentinel across all applications on this server.">
|
||||
helper="Inspect traffic statistics reported by Sentinel across all applications on this server.">
|
||||
<x-slot:actions>
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<div class="flex items-center gap-2">
|
||||
@include('livewire.traffic._live-toggle')
|
||||
<div class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
|
||||
<button type="button" wire:click="setRange('24h')"
|
||||
@class([$tabButtonBase, $range === '24h' ? $tabButtonActive : $tabButtonInactive])>
|
||||
24 hours
|
||||
@@ -61,6 +91,7 @@ $dimensionLabels = [
|
||||
@class([$tabButtonBase, $range === '30d' ? $tabButtonActive : $tabButtonInactive])>
|
||||
30 days
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -97,9 +128,13 @@ $dimensionLabels = [
|
||||
(() => {
|
||||
checkTheme();
|
||||
|
||||
const statusColorsLight = ['#0ca30c', '#2a78d6', '#fab219', '#d03b3b'];
|
||||
const statusColorsDark = ['#0ca30c', '#3987e5', '#fab219', '#d03b3b'];
|
||||
const statusColors = () => theme === 'light' ? statusColorsLight : statusColorsDark;
|
||||
const cssVar = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const statusColors = () => [
|
||||
cssVar('--chart-status-2xx'),
|
||||
cssVar('--chart-status-3xx'),
|
||||
cssVar('--chart-status-4xx'),
|
||||
cssVar('--chart-status-5xx'),
|
||||
];
|
||||
|
||||
const statusChart = new ApexCharts(document.getElementById('{!! $chartId !!}-status'), {
|
||||
chart: {
|
||||
@@ -189,6 +224,14 @@ $dimensionLabels = [
|
||||
@endforelse
|
||||
</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>
|
||||
|
||||
@foreach ($dimensionLabels as $dimension => $label)
|
||||
<x-application.settings-section id="analytics-{{ $dimension }}-section" title="{{ $label }}"
|
||||
helper="Top {{ strtolower($label) }} by request count for the selected range." flush>
|
||||
@@ -203,12 +246,6 @@ $dimensionLabels = [
|
||||
<x-empty size="sm" title="No data" description="No {{ strtolower($label) }} data for the selected range."
|
||||
icon-name="network" />
|
||||
@endforelse
|
||||
|
||||
@if ($dimension === 'country' && $attribution)
|
||||
<p class="border-t border-neutral-200 px-4 py-2 text-[11px] text-neutral-400 dark:border-white/[0.07] dark:text-fg-faint">
|
||||
{{ $attribution }}
|
||||
</p>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{{--
|
||||
Shared explainer for what enabling traffic analytics actually does. Kept in one
|
||||
place so the server analytics nudge, the dashboard nudge, and the application
|
||||
General-page nudge stay consistent about the side effects (proxy + Sentinel restart).
|
||||
--}}
|
||||
<ul class="flex flex-col gap-1.5 text-[12px] text-neutral-600 dark:text-fg-dim">
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-neutral-400 dark:bg-fg-faint"></span>
|
||||
<span>Regenerates the proxy config and <span class="font-medium text-black dark:text-fg">restarts the proxy</span> (a brief blip for in-flight connections).</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-neutral-400 dark:bg-fg-faint"></span>
|
||||
<span><span class="font-medium text-black dark:text-fg">Restarts Sentinel</span> and mounts a <span class="font-medium text-black dark:text-fg">read-only</span> access-log volume.</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-neutral-400 dark:bg-fg-faint"></span>
|
||||
<span>Adds <span class="font-medium text-black dark:text-fg">visitor geography</span> — which countries your traffic comes from.</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-neutral-400 dark:bg-fg-faint"></span>
|
||||
<span>Works with <span class="font-medium text-black dark:text-fg">Traefik & Caddy</span>; not available on Swarm or Build-pack servers.</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,109 @@
|
||||
{{--
|
||||
Shared geo visualization for traffic analytics: an inline public-domain world
|
||||
choropleth (see _world-map.blade.php) plus a ranked country list. Driven by the
|
||||
`country` breakdown collection; each row is ['value' => ISO-A2, 'requests', 'bytesOut'].
|
||||
Colors come from the --chart-geo-* tokens (light + dark) so the map, bars, and
|
||||
legend share one source. Consumed by the server + application analytics views and
|
||||
the dashboard summary.
|
||||
|
||||
@param iterable $countries country-breakdown rows
|
||||
@param ?string $attribution optional Sentinel attribution note
|
||||
--}}
|
||||
@php
|
||||
$rows = collect($countries ?? [])
|
||||
->map(fn ($r) => [
|
||||
'value' => strtoupper((string) data_get($r, 'value', '')),
|
||||
'requests' => (int) data_get($r, 'requests', 0),
|
||||
'bytesOut' => (int) data_get($r, 'bytesOut', 0),
|
||||
])
|
||||
->filter(fn ($r) => $r['requests'] > 0);
|
||||
|
||||
// A row is "known" only when its code is a real, resolvable ISO-A2; everything
|
||||
// else (absent or invalid codes) collapses into one Unknown row.
|
||||
[$known, $unknown] = $rows->partition(
|
||||
fn ($r) => preg_match('/^[A-Z]{2}$/', $r['value']) && countryName($r['value']) !== 'Unknown'
|
||||
);
|
||||
|
||||
// Quantile buckets (1..5) over known countries for the choropleth fills.
|
||||
$knownSorted = $known->sortBy('requests')->values();
|
||||
$bucketCount = $knownSorted->count();
|
||||
$bucketMap = [];
|
||||
foreach ($knownSorted as $i => $r) {
|
||||
$bucket = $bucketCount <= 1 ? 5 : (int) floor(($i / $bucketCount) * 5) + 1;
|
||||
$bucketMap[$r['value']] = min(5, max(1, $bucket));
|
||||
}
|
||||
|
||||
$countryRows = $known->values();
|
||||
if ($unknown->isNotEmpty()) {
|
||||
$countryRows->push([
|
||||
'value' => '',
|
||||
'requests' => $unknown->sum('requests'),
|
||||
'bytesOut' => $unknown->sum('bytesOut'),
|
||||
]);
|
||||
}
|
||||
$countryRows = $countryRows->sortByDesc('requests')->values();
|
||||
$maxRequests = max(1, (int) $countryRows->max('requests'));
|
||||
|
||||
$hasData = $countryRows->isNotEmpty();
|
||||
// Stable id (one geo section per page): the map subtree is wire:ignore'd, so a random
|
||||
// id would desync from the re-rendered <style> after a live-poll and lose the fills.
|
||||
$mapId = 'traffic-geo-map';
|
||||
@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
|
||||
{{-- Bucket fills are server-rendered as scoped CSS so theme + range changes stay
|
||||
in sync with no JS; the map subtree is wire:ignore'd to skip morph churn on polls. --}}
|
||||
<style>
|
||||
[data-geo-map="{{ $mapId }}"] svg { width: 100%; height: auto; display: block; }
|
||||
[data-geo-map="{{ $mapId }}"] svg path {
|
||||
fill: var(--chart-geo-empty);
|
||||
stroke: var(--chart-geo-stroke);
|
||||
stroke-width: 0.4;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@foreach ($bucketMap as $a2 => $bucket)
|
||||
[data-geo-map="{{ $mapId }}"] svg path#{{ $a2 }} { fill: var(--chart-geo-{{ $bucket }}); }
|
||||
@endforeach
|
||||
</style>
|
||||
|
||||
<div class="border-b border-neutral-200 px-4 py-3 dark:border-white/[0.07]">
|
||||
<div data-geo-map="{{ $mapId }}" wire:ignore
|
||||
class="mx-auto max-w-[720px] overflow-hidden rounded-lg bg-neutral-50 dark:bg-white/[0.02]">
|
||||
@include('livewire.traffic._world-map')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@foreach ($countryRows as $row)
|
||||
@php
|
||||
$isUnknown = $row['value'] === '';
|
||||
$bucket = $isUnknown ? null : ($bucketMap[$row['value']] ?? 1);
|
||||
$width = min(100, round(($row['requests'] / $maxRequests) * 100, 1));
|
||||
$barColor = $isUnknown ? 'var(--chart-geo-empty)' : "var(--chart-geo-{$bucket})";
|
||||
@endphp
|
||||
<div wire:key="geo-country-{{ $mapId }}-{{ $loop->index }}"
|
||||
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]">
|
||||
<span class="shrink-0 text-[14px] leading-none" aria-hidden="true">{{ countryFlagEmoji($isUnknown ? null : $row['value']) }}</span>
|
||||
<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.5 w-24 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full" style="width: {{ $width }}%; background-color: {{ $barColor }};"></div>
|
||||
</div>
|
||||
<span class="shrink-0 text-[12px] text-neutral-500 dark:text-fg-dim">{{ number_format($row['requests']) }} req</span>
|
||||
<span class="hidden shrink-0 text-[12px] text-neutral-500 sm:inline dark:text-fg-dim">{{ formatBytes($row['bytesOut']) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if (! empty($attribution))
|
||||
<p class="border-t border-neutral-200 px-4 py-2 text-[11px] text-neutral-400 dark:border-white/[0.07] dark:text-fg-faint">
|
||||
{{ $attribution }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
{{--
|
||||
Sentry-style play/pause control for realtime analytics refresh. Sits next to the
|
||||
time-range selector. Live is only meaningful at the 24h range (minute-level rollups),
|
||||
so the control is disabled for 7d/30d. The choice is persisted per-browser in
|
||||
localStorage. Expects `$range` in scope and a `live` bool + `toggleLive()` on the host
|
||||
Livewire component. The pulsing dot respects prefers-reduced-motion.
|
||||
--}}
|
||||
@php $liveEnabled = $range === '24h'; @endphp
|
||||
<div x-data="{ live: $wire.entangle('live').live }"
|
||||
x-init="
|
||||
if (localStorage.getItem('traffic-live') === '0') { live = false; }
|
||||
$watch('live', value => localStorage.setItem('traffic-live', value ? '1' : '0'));
|
||||
">
|
||||
<button type="button"
|
||||
@click="live = !live"
|
||||
@disabled(! $liveEnabled)
|
||||
title="{{ $liveEnabled ? 'Toggle realtime refresh (updates every 60s)' : 'Realtime refresh is only available for the 24h range' }}"
|
||||
class="inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ring-1 ring-neutral-200 dark:ring-white/[0.08]"
|
||||
:class="live
|
||||
? 'bg-white text-black shadow-sm dark:bg-white/[0.09] dark:text-fg'
|
||||
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'">
|
||||
<span x-show="live" class="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
|
||||
<span class="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75 motion-safe:animate-ping"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
<svg x-show="!live" class="h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<span x-text="live ? 'Live' : 'Paused'">Live</span>
|
||||
</button>
|
||||
</div>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\TrafficOverview;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeAppOverviewTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
public array $responses = [];
|
||||
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
foreach ($this->responses as $needle => $response) {
|
||||
if (str_contains($url, $needle)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
// #[Lazy] components render a placeholder first; trigger the deferred mount as the
|
||||
// browser would via the x-intersect __lazyLoad call, then continue asserting.
|
||||
function loadLazy(Testable $component): Testable
|
||||
{
|
||||
preg_match('/__lazyLoad\('([^&]+)'\)/', $component->html(), $matches);
|
||||
|
||||
return $component->call('__lazyLoad', $matches[1]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Servers are memoized via once()/cache; clear both so DB-id reuse across tests doesn't bleed a stale enabled server.
|
||||
Cache::flush();
|
||||
Server::flushIdentityMap();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function makeAppOnServer(bool $analyticsEnabled): Application
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'private_key_id' => test()->privateKey->id,
|
||||
]);
|
||||
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
|
||||
$server->settings->save();
|
||||
|
||||
$project = Project::factory()->create(['team_id' => test()->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = StandaloneDocker::where('server_id', $server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
|
||||
|
||||
return Application::factory()->create([
|
||||
'name' => 'Widget App',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
}
|
||||
|
||||
it('shows last-24h KPIs and a link to full analytics when enabled with data', function () {
|
||||
$application = makeAppOnServer(true);
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, function ($app, $params) {
|
||||
$client = new FakeAppOverviewTrafficClient($params['server']);
|
||||
$client->responses = [
|
||||
'/traffic/overview' => json_encode([
|
||||
'requests' => 4200,
|
||||
'bytes_in' => 5000,
|
||||
'bytes_out' => 25000,
|
||||
'status' => ['s2xx' => 4000, 's3xx' => 100, 's4xx' => 80, 's5xx' => 20],
|
||||
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
|
||||
'unique_visitors' => 1234,
|
||||
]),
|
||||
];
|
||||
|
||||
return $client;
|
||||
});
|
||||
|
||||
$component = loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]));
|
||||
$component->assertOk()
|
||||
->assertSet('enabled', true)
|
||||
->assertSee('Traffic (last 24h)')
|
||||
->assertSee('4,200')
|
||||
->assertSee('1,234')
|
||||
->assertSee('View full analytics');
|
||||
});
|
||||
|
||||
it('shows the muted no-data note when enabled but no traffic recorded', function () {
|
||||
$application = makeAppOnServer(true);
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, function ($app, $params) {
|
||||
$client = new FakeAppOverviewTrafficClient($params['server']);
|
||||
$client->responses = ['/traffic/overview' => json_encode(['requests' => 0])];
|
||||
|
||||
return $client;
|
||||
});
|
||||
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSee('No traffic recorded in the last 24h yet');
|
||||
});
|
||||
|
||||
it('shows the enable nudge when analytics is disabled on an eligible server', function () {
|
||||
$application = makeAppOnServer(false);
|
||||
|
||||
loadLazy(Livewire::test(TrafficOverview::class, ['application' => $application]))
|
||||
->assertOk()
|
||||
->assertSet('enabled', false)
|
||||
->assertSee('Traffic analytics')
|
||||
->assertSee('Server settings')
|
||||
->assertDontSee('Traffic (last 24h)');
|
||||
});
|
||||
@@ -11,3 +11,15 @@ it('stamps coolify_app_id and JSON access log on each caddy site when enabled',
|
||||
expect($labels->contains('caddy_0.log.output=file /traffic/access.log'))->toBeTrue();
|
||||
expect($labels->contains('caddy_0.log.format=json'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('emits lumberjack roll directives on each caddy site when enabled', function () {
|
||||
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: true);
|
||||
expect($labels->contains('caddy_0.log.output.roll_size=20MiB'))->toBeTrue();
|
||||
expect($labels->contains('caddy_0.log.output.roll_keep=5'))->toBeTrue();
|
||||
expect($labels->contains('caddy_0.log.output.roll_keep_for=168h'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('omits lumberjack roll directives when disabled', function () {
|
||||
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']), is_traffic_analytics_enabled: false);
|
||||
expect($labels->filter(fn ($l) => str_contains($l, 'roll_'))->isEmpty())->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
// Guards the section-C tokenization: the analytics charts must read their colors
|
||||
// from the shared --chart-* design tokens, not from inlined hex arrays.
|
||||
|
||||
it('defines the chart design tokens in the app stylesheet', function () {
|
||||
$css = file_get_contents(base_path('resources/css/app.css'));
|
||||
|
||||
foreach ([
|
||||
'--chart-status-2xx',
|
||||
'--chart-status-3xx',
|
||||
'--chart-status-4xx',
|
||||
'--chart-status-5xx',
|
||||
'--chart-geo-1',
|
||||
'--chart-geo-2',
|
||||
'--chart-geo-3',
|
||||
'--chart-geo-4',
|
||||
'--chart-geo-5',
|
||||
'--chart-geo-empty',
|
||||
] as $token) {
|
||||
expect($css)->toContain($token);
|
||||
}
|
||||
|
||||
// Dark overrides must exist so the palette is a selected dark theme, not a flip.
|
||||
expect($css)->toContain('.dark {');
|
||||
});
|
||||
|
||||
it('no longer hardcodes status color hex arrays in the analytics views', function () {
|
||||
$views = [
|
||||
base_path('resources/views/livewire/server/analytics.blade.php'),
|
||||
base_path('resources/views/livewire/project/application/analytics.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
$contents = file_get_contents($view);
|
||||
|
||||
expect($contents)->not->toContain('statusColorsLight');
|
||||
expect($contents)->not->toContain('statusColorsDark');
|
||||
expect($contents)->toContain('--chart-status-2xx');
|
||||
}
|
||||
});
|
||||
@@ -172,10 +172,13 @@ it('shows a failure empty-state instead of an all-zero KPI panel when every serv
|
||||
});
|
||||
|
||||
it('shows an empty state when no server in the team has traffic analytics enabled', function () {
|
||||
Server::factory()->create([
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
// New servers default analytics on; this scenario is the all-disabled team.
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
Livewire::test(TrafficAnalytics::class)
|
||||
->assertOk()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Analytics;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeGeoTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
public array $responses = [];
|
||||
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
foreach ($this->responses as $needle => $response) {
|
||||
if (str_contains($url, $needle)) {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function fakeGeoResponses(array $countryRows): array
|
||||
{
|
||||
return [
|
||||
'/traffic/apps' => json_encode([]),
|
||||
'/traffic/overview' => json_encode([
|
||||
'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/paths' => json_encode([]),
|
||||
'/traffic/breakdown/country' => json_encode($countryRows),
|
||||
'/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']),
|
||||
];
|
||||
}
|
||||
|
||||
function bootGeoServer(): Server
|
||||
{
|
||||
Server::flushIdentityMap();
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings->is_traffic_analytics_enabled = true;
|
||||
$server->settings->save();
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('renders resolved country names and the choropleth map when country data is present', function () {
|
||||
$server = bootGeoServer();
|
||||
|
||||
$fake = new FakeGeoTrafficClient($server);
|
||||
$fake->responses = fakeGeoResponses([
|
||||
['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
|
||||
['value' => 'DE', 'requests' => 200, 'bytes_out' => 6000],
|
||||
]);
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $fake);
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('Countries')
|
||||
->assertSee('United States')
|
||||
->assertSee('Germany')
|
||||
->assertSee('World map of request volume by country')
|
||||
->assertSeeHtml('path#US')
|
||||
->assertSee('GeoIP data by MaxMind');
|
||||
});
|
||||
|
||||
it('collapses unresolvable country codes into a single Unknown row', function () {
|
||||
$server = bootGeoServer();
|
||||
|
||||
$fake = new FakeGeoTrafficClient($server);
|
||||
$fake->responses = fakeGeoResponses([
|
||||
['value' => 'US', 'requests' => 600, 'bytes_out' => 15000],
|
||||
['value' => '', 'requests' => 50, 'bytes_out' => 500],
|
||||
['value' => 'ZZ', 'requests' => 25, 'bytes_out' => 250],
|
||||
]);
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $fake);
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('United States')
|
||||
->assertSee('Unknown');
|
||||
});
|
||||
|
||||
it('shows a plain no-data state when no country data has been recorded', function () {
|
||||
$server = bootGeoServer();
|
||||
|
||||
$fake = new FakeGeoTrafficClient($server);
|
||||
$fake->responses = fakeGeoResponses([]);
|
||||
app()->bind(SentinelTrafficClient::class, fn () => $fake);
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('Countries')
|
||||
->assertSee('No country data for the selected range');
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Analytics;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeLiveTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
if (str_contains($url, '/traffic/overview')) {
|
||||
return json_encode([
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function bootLiveServer(): Server
|
||||
{
|
||||
Server::flushIdentityMap();
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->settings->is_traffic_analytics_enabled = true;
|
||||
$server->settings->save();
|
||||
|
||||
app()->bind(SentinelTrafficClient::class, fn () => new FakeLiveTrafficClient($server));
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('polls for realtime data by default at the 24h range', function () {
|
||||
$server = bootLiveServer();
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSet('live', true)
|
||||
->assertSeeHtml('wire:poll.60s')
|
||||
->assertSee('Live');
|
||||
});
|
||||
|
||||
it('stops polling when live is toggled off', function () {
|
||||
$server = bootLiveServer();
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertSeeHtml('wire:poll.60s')
|
||||
->call('toggleLive')
|
||||
->assertSet('live', false)
|
||||
->assertDontSeeHtml('wire:poll.60s');
|
||||
});
|
||||
|
||||
it('disables realtime polling for the 7d and 30d ranges', function () {
|
||||
$server = bootLiveServer();
|
||||
|
||||
$component = Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->call('setRange', '7d')
|
||||
->assertDontSeeHtml('wire:poll.60s');
|
||||
|
||||
expect($component->instance()->isLivePollable())->toBeFalse();
|
||||
|
||||
// toggleLive is a no-op outside the 24h range.
|
||||
$component->call('toggleLive')->assertDontSeeHtml('wire:poll.60s');
|
||||
|
||||
$component->call('setRange', '30d')->assertDontSeeHtml('wire:poll.60s');
|
||||
});
|
||||
|
||||
it('re-arms polling when returning to the 24h range', function () {
|
||||
$server = bootLiveServer();
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->call('setRange', '7d')
|
||||
->assertDontSeeHtml('wire:poll.60s')
|
||||
->call('setRange', '24h')
|
||||
->assertSeeHtml('wire:poll.60s');
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -11,14 +12,44 @@ beforeEach(function () {
|
||||
$this->team = $user->teams()->first();
|
||||
});
|
||||
|
||||
it('defaults traffic analytics to disabled and exposes a server helper', function () {
|
||||
it('defaults traffic analytics to enabled for a normal server and exposes a server helper', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
expect($server->settings->is_traffic_analytics_enabled)->toBeFalse();
|
||||
expect($server->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
expect($server->settings->is_traffic_analytics_enabled)->toBeTrue();
|
||||
expect($server->isTrafficAnalyticsEnabled())->toBeTrue();
|
||||
|
||||
$server->settings->is_traffic_analytics_enabled = true;
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeTrue();
|
||||
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults traffic analytics to disabled for a swarm server', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$setting = ServerSetting::create([
|
||||
'server_id' => $server->id,
|
||||
'is_swarm_manager' => true,
|
||||
]);
|
||||
|
||||
expect($setting->is_traffic_analytics_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('defaults traffic analytics to disabled for a build server', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$setting = ServerSetting::create([
|
||||
'server_id' => $server->id,
|
||||
'is_build_server' => true,
|
||||
]);
|
||||
|
||||
expect($setting->is_traffic_analytics_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('respects an explicit traffic analytics value on creation', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$setting = ServerSetting::create([
|
||||
'server_id' => $server->id,
|
||||
'is_traffic_analytics_enabled' => false,
|
||||
]);
|
||||
|
||||
expect($setting->is_traffic_analytics_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('encrypts the maxmind license key and hides it from array output', function () {
|
||||
|
||||
@@ -14,7 +14,9 @@ beforeEach(function () {
|
||||
|
||||
it('produces no traffic env when disabled', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
expect(StartSentinel::sentinelTrafficEnvironment($server))->toBe([]);
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh()))->toBe([]);
|
||||
});
|
||||
|
||||
it('produces traffic + geoip env when enabled', function () {
|
||||
|
||||
@@ -23,8 +23,11 @@ it('toggles traffic analytics via the sentinel settings component', function ()
|
||||
});
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
// New servers default analytics on; start from the disabled state to exercise enabling.
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
expect($server->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $server])
|
||||
->call('toggleTrafficAnalytics')
|
||||
@@ -38,9 +41,10 @@ it('does not enable traffic analytics on a swarm server', function () {
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings->is_swarm_manager = true;
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
expect($server->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $server])
|
||||
->call('toggleTrafficAnalytics')
|
||||
@@ -54,9 +58,10 @@ it('does not enable traffic analytics on a build server', function () {
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$server->settings->is_build_server = true;
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
expect($server->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
expect($server->fresh()->isTrafficAnalyticsEnabled())->toBeFalse();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $server])
|
||||
->call('toggleTrafficAnalytics')
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// generateDefaultProxyConfiguration() synchronously persists the config to the
|
||||
// server over SSH (SaveProxyConfiguration); fake the process layer so tests
|
||||
// don't attempt a real SSH connection.
|
||||
Process::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not add a traefik-logrotate sidecar when traffic analytics is disabled', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
|
||||
$server->proxy->set('type', 'TRAEFIK');
|
||||
$server->save();
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
$yaml = generateDefaultProxyConfiguration($server->fresh());
|
||||
|
||||
expect($yaml)->not->toContain('traefik-logrotate');
|
||||
|
||||
$config = Yaml::parse($yaml);
|
||||
expect($config['services'])->not->toHaveKey('traefik-logrotate');
|
||||
});
|
||||
|
||||
it('adds a traefik-logrotate sidecar with copytruncate and the proxy mount when enabled', function () {
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
|
||||
$server->proxy->set('type', 'TRAEFIK');
|
||||
$server->save();
|
||||
$server->settings->is_traffic_analytics_enabled = true;
|
||||
$server->settings->save();
|
||||
|
||||
$server = $server->fresh();
|
||||
$yaml = generateDefaultProxyConfiguration($server);
|
||||
|
||||
expect($yaml)->toContain('traefik-logrotate')
|
||||
->toContain('copytruncate');
|
||||
|
||||
$config = Yaml::parse($yaml);
|
||||
$sidecar = $config['services']['traefik-logrotate'];
|
||||
|
||||
expect($sidecar['image'])->toBe('alpine:3.20');
|
||||
expect($sidecar['volumes'])->toContain($server->proxyPath().':/traefik');
|
||||
expect($sidecar['labels'])->toContain('coolify.managed=true');
|
||||
expect($sidecar['entrypoint'])->toContain('copytruncate');
|
||||
expect($sidecar['entrypoint'])->toContain('logrotate');
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ConfigureTrafficAnalytics;
|
||||
use App\Livewire\Dashboard\TrafficAnalytics as DashboardTrafficAnalytics;
|
||||
use App\Livewire\Server\Analytics;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class NudgeEmptyTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
});
|
||||
|
||||
function disabledServer(): Server
|
||||
{
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'private_key_id' => test()->privateKey->id,
|
||||
]);
|
||||
$server->settings->is_traffic_analytics_enabled = false;
|
||||
$server->settings->save();
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('renders the server analytics nudge while traffic analytics is disabled', function () {
|
||||
$server = disabledServer();
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('Turn on traffic analytics for this server')
|
||||
->assertSee('restarts the proxy')
|
||||
->assertSee('Enable traffic analytics');
|
||||
});
|
||||
|
||||
it('enables traffic analytics from the server nudge and hides it', function () {
|
||||
ConfigureTrafficAnalytics::partialMock()->shouldReceive('handle')->once()->andReturnUsing(function ($server, $enable) {
|
||||
$server->settings->is_traffic_analytics_enabled = $enable;
|
||||
$server->settings->save();
|
||||
});
|
||||
|
||||
$server = disabledServer();
|
||||
app()->bind(SentinelTrafficClient::class, fn () => new NudgeEmptyTrafficClient($server));
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertSee('Turn on traffic analytics for this server')
|
||||
->call('enableTrafficAnalytics')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('enabled', true)
|
||||
->assertDontSee('Turn on traffic analytics for this server');
|
||||
});
|
||||
|
||||
it('shows the ineligible note instead of an enable button on a swarm server', function () {
|
||||
$server = disabledServer();
|
||||
$server->settings->is_swarm_manager = true;
|
||||
$server->settings->save();
|
||||
|
||||
Livewire::test(Analytics::class, ['server_uuid' => $server->uuid])
|
||||
->assertOk()
|
||||
->assertSee('not available on Swarm or Build-pack servers')
|
||||
->assertDontSee('Enable traffic analytics');
|
||||
});
|
||||
|
||||
it('shows the dashboard nudge when an eligible server has analytics disabled', function () {
|
||||
disabledServer();
|
||||
|
||||
Livewire::test(DashboardTrafficAnalytics::class)
|
||||
->assertOk()
|
||||
->assertSee('can start collecting traffic analytics');
|
||||
});
|
||||
|
||||
it('does not count swarm or build servers in the dashboard nudge', function () {
|
||||
$swarm = disabledServer();
|
||||
$swarm->settings->is_swarm_manager = true;
|
||||
$swarm->settings->save();
|
||||
|
||||
$build = disabledServer();
|
||||
$build->settings->is_build_server = true;
|
||||
$build->settings->save();
|
||||
|
||||
Livewire::test(DashboardTrafficAnalytics::class)
|
||||
->assertOk()
|
||||
->assertDontSee('can start collecting traffic analytics');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
describe('countryFlagEmoji', function () {
|
||||
it('returns the correct flag for a valid uppercase code', function () {
|
||||
expect(countryFlagEmoji('US'))->toBe('🇺🇸');
|
||||
});
|
||||
|
||||
it('is case-insensitive', function () {
|
||||
expect(countryFlagEmoji('us'))->toBe('🇺🇸');
|
||||
});
|
||||
|
||||
it('returns the globe fallback for invalid input', function (?string $input) {
|
||||
expect(countryFlagEmoji($input))->toBe('🌐');
|
||||
})->with([
|
||||
'null' => [null],
|
||||
'empty' => [''],
|
||||
'three letters' => ['USA'],
|
||||
'non-letters' => ['1!'],
|
||||
'single letter' => ['U'],
|
||||
]);
|
||||
});
|
||||
|
||||
describe('countryName', function () {
|
||||
it('returns the English region name for a valid uppercase code', function () {
|
||||
expect(countryName('US'))->toBe('United States');
|
||||
});
|
||||
|
||||
it('is case-insensitive', function () {
|
||||
expect(countryName('us'))->toBe('United States');
|
||||
});
|
||||
|
||||
it('returns Unknown for invalid input', function (?string $input) {
|
||||
expect(countryName($input))->toBe('Unknown');
|
||||
})->with([
|
||||
'null' => [null],
|
||||
'empty' => [''],
|
||||
'unresolvable ZZ' => ['ZZ'],
|
||||
'unresolvable XX' => ['XX'],
|
||||
'three letters' => ['USA'],
|
||||
'non-letters' => ['1!'],
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user