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:
Aditya Tripathi
2026-08-11 10:55:50 +00:00
parent a8beb14a53
commit 872f8aafb8
31 changed files with 1782 additions and 70 deletions
+25 -1
View File
@@ -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');
}
}
+52
View File
@@ -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) {
+9
View File
@@ -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);
}
+28
View File
@@ -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) {
+7
View File
@@ -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}");
}
+18
View File
@@ -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(),
+305
View File
@@ -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 dIvoire',
'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;
}
+39
View File
@@ -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 &amp; 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 &rarr;
</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 &amp; 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\(&#039;([^&]+)&#039;\)/', $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');
});
+42
View File
@@ -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!'],
]);
});