Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-07 10:57:09 +00:00
33 changed files with 520 additions and 79 deletions
+1
View File
@@ -58,6 +58,7 @@
## Trace infrastructure changes end to end
- For container image changes, inspect Compose services and every relevant Dockerfile build stage.
- Pin a stable release tag instead of using a floating `latest` tag.
- Never give a test build an official image tag (for example `coollabsio/sentinel:<version>`) in the host Docker. Dev instances that use `testing-host` share the host Docker socket and run that tag without a pull. Load such a tag only inside a dev VM, and give the build the same version string, or Coolify's Sentinel check restarts it every minute.
- A successful image pull does not prove that the complete application build no longer uses the old image.
- Do not use `docker compose up --wait` for a stack with one-shot services; wait for the required long-running service's health instead.
+2
View File
@@ -2,6 +2,7 @@
namespace App\Actions\Server;
use App\Enums\TrafficIpMode;
use App\Events\SentinelRestarted;
use App\Models\Server;
use Illuminate\Contracts\Cache\LockTimeoutException;
@@ -58,6 +59,7 @@ class StartSentinel
'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395),
'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false',
'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30),
'TRAFFIC_IP_MODE' => TrafficIpMode::forServer($server)->value,
];
$license = data_get($settings, 'geoip_maxmind_license_key');
if ($settings->is_geoip_enabled && filled($license)) {
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Enums;
use App\Models\Server;
/**
* How Sentinel stores client IPs for the "Top IPs" traffic breakdown (TRAFFIC_IP_MODE).
*/
enum TrafficIpMode: string
{
case Full = 'full';
case Anonymized = 'anonymized';
case Off = 'off';
public static function forServer(Server $server): self
{
return $server->settings?->traffic_ip_mode ?? self::Full;
}
/**
* Effective mode for a view that merges several servers: Off only when every server is off,
* Anonymized when every server that records IPs anonymizes them, otherwise Full.
*
* @param iterable<Server> $servers
*/
public static function forServers(iterable $servers): self
{
$modes = collect($servers)->map(fn (Server $server): self => self::forServer($server));
if ($modes->isEmpty()) {
return self::Full;
}
$recording = $modes->reject(fn (self $mode): bool => $mode === self::Off);
if ($recording->isEmpty()) {
return self::Off;
}
return $recording->every(fn (self $mode): bool => $mode === self::Anonymized)
? self::Anonymized
: self::Full;
}
/**
* Drop the `ip` dimension when Sentinel does not record it.
*
* @param array<int, string> $dimensions
* @return array<int, string>
*/
public function breakdownDimensions(array $dimensions): array
{
if ($this !== self::Off) {
return $dimensions;
}
return array_values(array_filter($dimensions, fn (string $dimension): bool => $dimension !== 'ip'));
}
public function breakdownLabel(): string
{
return $this === self::Anonymized ? 'Top networks' : 'Top IPs';
}
public function breakdownHelper(): string
{
return $this === self::Anonymized
? 'Busiest client networks. IPs are anonymized to /24 (IPv4) and /48 (IPv6).'
: 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).';
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Enums\TrafficIpMode;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\ServerSetting;
@@ -26,6 +27,7 @@ class ServerSentinelController extends Controller
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
];
private function findServerForTeam(int $teamId, string $uuid): ?Server
@@ -55,6 +57,7 @@ class ServerSentinelController extends Controller
'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days,
'is_geoip_enabled' => (bool) $settings->is_geoip_enabled,
'geoip_refresh_days' => (int) $settings->geoip_refresh_days,
'traffic_ip_mode' => TrafficIpMode::forServer($server)->value,
];
if ($this->canReadSensitive()) {
@@ -97,6 +100,7 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'),
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer'),
new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs for the Top IPs breakdown.'),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'),
],
type: 'object',
@@ -152,6 +156,7 @@ class ServerSentinelController extends Controller
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1),
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true),
new OA\Property(property: 'traffic_ip_mode', type: 'string', enum: ['full', 'anonymized', 'off'], description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'),
],
type: 'object',
),
@@ -198,6 +203,7 @@ class ServerSentinelController extends Controller
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer|min:1',
'geoip_maxmind_license_key' => 'nullable|string|max:255',
'traffic_ip_mode' => 'string|in:full,anonymized,off',
]);
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
@@ -816,10 +816,10 @@ class ServersController extends Controller
], 422);
}
if ($serverRole !== null && $serverRole !== ServerRole::BUILD && $server->hasEnabledGithubRunners()) {
if ($serverRole === ServerRole::DEPLOYMENT && $server->hasEnabledGithubRunners()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_role' => ['Disable the GitHub runners before you change the role of this server.']],
'errors' => ['server_role' => ['Disable the GitHub runners before you set this server to deployments only.']],
], 422);
}
+2 -2
View File
@@ -19,7 +19,7 @@ use Illuminate\Support\Sleep;
use RuntimeException;
/**
* Starts one ephemeral runner for a queued execution on the least busy matching build server.
* Starts one ephemeral runner for a queued execution on the least busy matching server.
* When no server has free capacity, the execution stays queued and is retried by
* CleanupGithubRunnerJob or ReconcileGithubRunnersJob. A runner that fails to start is removed and
* started again, possibly on another server, up to MAX_ATTEMPTS times.
@@ -191,7 +191,7 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
->filter(fn (GithubRunnerConfig $config) => $config->server !== null
&& $config->matchesLabels($execution->labels ?? [])
&& ($config->allow_pull_requests || ! $execution->is_pull_request)
&& $config->server->isBuildServer()
&& $config->server->canBuildApplications()
&& ! $config->server->isLocalhost()
&& $config->server->isFunctional())
->map(fn (GithubRunnerConfig $config) => ['config' => $config, 'occupied' => $config->occupiedRunnerCount()])
+2 -2
View File
@@ -17,7 +17,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Compares runner executions with the runner containers on each build server. It cleans up
* Compares runner executions with the runner containers on each runner server. It cleans up
* stopped and orphaned runners, applies the wait, idle, and job timeouts, and restarts
* provisioning for queued executions whose dispatch was lost.
*/
@@ -161,7 +161,7 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
foreach ($executions as $execution) {
if (($execution->queued_at ?? $execution->created_at)->lt(now()->subMinutes($timeout))) {
$execution->finish(GithubRunnerStatus::TimedOut, "No build server had free capacity within {$timeout} minutes.");
$execution->finish(GithubRunnerStatus::TimedOut, "No runner server had free capacity within {$timeout} minutes.");
}
}
+10 -2
View File
@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Enums\TrafficIpMode;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Server;
@@ -79,6 +80,10 @@ class Analytics extends Component
public ?string $attribution = null;
/** Effective TrafficIpMode of the queried servers; decides how the IP breakdown renders. */
#[Locked]
public string $ipMode = 'full';
/**
* Per-bucket status-class time series for the stacked area chart, summed across
* target servers and sorted by bucket.
@@ -342,8 +347,11 @@ class Analytics extends Component
$resource = $this->selectedResource();
$servers = $this->targetServers();
$domainForKey = fn (string $key): ?string => $this->appMeta($key)['domain'];
$ipMode = TrafficIpMode::forServers($servers);
$this->ipMode = $ipMode->value;
$dimensions = $ipMode->breakdownDimensions($this->breakdownDimensions);
$aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions);
$aggregator = new TrafficAnalyticsAggregator($dimensions);
$appRows = [];
$hostTotals = [];
@@ -362,7 +370,7 @@ class Analytics extends Component
// Warm every server-wide endpoint, including the leaderboard's per-key
// overviews, in one dashboard fetch; the per-call methods below then read
// from cache.
$leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
$leaderboardKeys = $client->prefetchServerWide(null, $from, $to, $dimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
if (count($leaderboardKeys) > self::MAX_LEADERBOARD_APPS) {
Log::warning('Traffic analytics leaderboard truncated', [
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared;
use App\Enums\TrafficIpMode;
use App\Livewire\Concerns\BuildsTrafficChartPayload;
use App\Models\Application;
use App\Models\Service;
@@ -52,6 +53,10 @@ abstract class ResourceTrafficAnalytics extends Component
public ?string $attribution = null;
/** TrafficIpMode of the resource's server; decides how the IP breakdown renders. */
#[Locked]
public string $ipMode = 'full';
/**
* Per-bucket status-class time series for the stacked area chart.
*
@@ -131,11 +136,14 @@ abstract class ResourceTrafficAnalytics extends Component
$resource = $this->trafficResource();
[$from, $to] = SentinelTrafficClient::rangeWindow($this->range);
$client = app(SentinelTrafficClient::class, ['server' => $resource->server()]);
$server = $resource->server();
$client = app(SentinelTrafficClient::class, ['server' => $server]);
$ipMode = $server ? TrafficIpMode::forServer($server) : TrafficIpMode::Full;
$this->ipMode = $ipMode->value;
// Sentinel's resource scope merges every key exactly in one docker exec. A Sentinel
// before 1.0.2 falls back to all keys of the resource, merged here (approximate).
$aggregator = new TrafficAnalyticsAggregator($this->breakdownDimensions);
$aggregator = new TrafficAnalyticsAggregator($ipMode->breakdownDimensions($this->breakdownDimensions));
$aggregator->collectResource($client, $resource->uuid(), $from, $to, $this->range, fn (string $appKey) => $resource->domainForKey($appKey));
$result = $aggregator->overview();
+4 -4
View File
@@ -183,8 +183,8 @@ class GithubRunners extends Component
}
try {
if (! $this->server->isBuildServer()) {
throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.');
if (! $this->server->canBuildApplications()) {
throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.');
}
if ($this->config?->is_enabled ?? true) {
@@ -242,8 +242,8 @@ class GithubRunners extends Component
});
$this->dispatch('success', 'GitHub runners disabled. Running jobs continue until they finish.');
} else {
if (! $this->server->isBuildServer()) {
throw new \RuntimeException('GitHub Actions runners can only run on servers with the Build role.');
if (! $this->server->canBuildApplications()) {
throw new \RuntimeException('GitHub Actions runners need a server with the Builds only or Deployments and builds role.');
}
$this->authorize('update', $config->githubApp);
$this->prepareRunnerGroup($config->githubApp);
+2 -2
View File
@@ -476,9 +476,9 @@ class Show extends Component
$newRole = ServerRole::from($this->serverRole);
$currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole();
if ($newRole !== ServerRole::BUILD && $this->server->hasEnabledGithubRunners()) {
if ($newRole === ServerRole::DEPLOYMENT && $this->server->hasEnabledGithubRunners()) {
$this->serverRole = $currentRole->value;
$this->dispatch('error', 'Disable the GitHub runners before you change the role of this server.');
$this->dispatch('error', 'Disable the GitHub runners before you set this server to deployments only.');
return;
}
@@ -4,6 +4,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Enums\ProxyTypes;
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -39,6 +40,9 @@ class TrafficAnalyticsSettings extends Component
#[Validate(['nullable', 'string', 'max:255'])]
public ?string $geoipMaxmindLicenseKey = null;
#[Validate(['required', 'string', 'in:full,anonymized,off'])]
public string $trafficIpMode = 'full';
public function mount(): void
{
$this->authorize('update', $this->server);
@@ -56,6 +60,7 @@ class TrafficAnalyticsSettings extends Component
$this->server->settings->is_geoip_enabled = $this->isGeoipEnabled;
$this->server->settings->geoip_refresh_days = $this->geoipRefreshDays;
$this->server->settings->geoip_maxmind_license_key = $this->geoipMaxmindLicenseKey;
$this->server->settings->traffic_ip_mode = TrafficIpMode::from($this->trafficIpMode);
$this->server->settings->save();
return;
@@ -69,6 +74,7 @@ class TrafficAnalyticsSettings extends Component
$this->isGeoipEnabled = (bool) $this->server->settings->is_geoip_enabled;
$this->geoipRefreshDays = $this->server->settings->geoip_refresh_days;
$this->geoipMaxmindLicenseKey = $this->server->settings->geoip_maxmind_license_key;
$this->trafficIpMode = TrafficIpMode::forServer($this->server)->value;
}
public function toggleTrafficAnalytics(): void
@@ -99,7 +105,7 @@ class TrafficAnalyticsSettings extends Component
$this->authorize('update', $this->server);
$this->syncData(true);
auditLog('ui.server.traffic_analytics.updated', $this->auditContext([
'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key'],
'changed_fields' => ['traffic_topn', 'traffic_sample_threshold', 'traffic_retention_1h_days', 'traffic_retention_1d_days', 'is_geoip_enabled', 'geoip_refresh_days', 'geoip_maxmind_license_key', 'traffic_ip_mode'],
]));
$this->dispatch('success', 'Traffic analytics settings updated. Restarting Sentinel.');
} catch (\Throwable $e) {
+6 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@@ -36,6 +37,7 @@ use OpenApi\Attributes as OA;
'traffic_retention_1d_days' => ['type' => 'integer'],
'is_geoip_enabled' => ['type' => 'boolean'],
'geoip_refresh_days' => ['type' => 'integer'],
'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']],
'is_reachable' => ['type' => 'boolean'],
'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'],
@@ -131,6 +133,7 @@ class ServerSetting extends Model
'is_geoip_enabled',
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
'docker_version',
'docker_version_checked_at',
'compose_version',
@@ -167,6 +170,7 @@ class ServerSetting extends Model
'is_geoip_enabled' => 'boolean',
'geoip_refresh_days' => 'integer',
'geoip_maxmind_license_key' => 'encrypted',
'traffic_ip_mode' => TrafficIpMode::class,
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
'backup_compression_cpu_percentage' => 'integer',
@@ -218,7 +222,8 @@ class ServerSetting extends Model
$settings->wasChanged('traffic_retention_1d_days') ||
$settings->wasChanged('is_geoip_enabled') ||
$settings->wasChanged('geoip_refresh_days') ||
$settings->wasChanged('geoip_maxmind_license_key')
$settings->wasChanged('geoip_maxmind_license_key') ||
$settings->wasChanged('traffic_ip_mode')
) {
// Only recreate Sentinel when it is already enabled. Otherwise a change to a
// traffic/geoip tuning knob would turn Sentinel on as a side effect, because
@@ -162,7 +162,7 @@ class GithubRunnerContainer
#!/bin/bash
case "\${GITHUB_EVENT_NAME:-}" in
{$events}|"")
echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the build server to run them."
echo "::error::This Coolify runner does not run pull request jobs. Allow pull request jobs in the GitHub runner settings of the server to run them."
exit 1
;;
esac
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->string('traffic_ip_mode')->default('full');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('traffic_ip_mode');
});
}
};
+49 -6
View File
@@ -1021,7 +1021,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -1642,7 +1642,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -2263,7 +2263,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -2779,7 +2779,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -3275,7 +3275,7 @@
"autogenerate_domain": {
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
},
"is_container_label_escape_enabled": {
"type": "boolean",
@@ -9337,6 +9337,9 @@
},
"409": {
"description": "Another start, restart or import of this database is already in progress."
},
"422": {
"description": "The database cannot start yet, for example because a CA certificate is missing."
}
},
"security": [
@@ -9455,6 +9458,9 @@
},
"409": {
"description": "Another start, restart or import of this database is already in progress."
},
"422": {
"description": "The database cannot start yet, for example because a CA certificate is missing."
}
},
"security": [
@@ -17221,6 +17227,15 @@
"geoip_refresh_days": {
"type": "integer"
},
"traffic_ip_mode": {
"description": "How Sentinel stores client IPs for the Top IPs breakdown.",
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
},
"geoip_maxmind_license_key": {
"description": "Only present with read:sensitive.",
"type": "string"
@@ -17329,6 +17344,15 @@
"string",
"null"
]
},
"traffic_ip_mode": {
"description": "How Sentinel stores client IPs: full, anonymized (IPv4 \/24, IPv6 \/48), or off.",
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
}
},
"type": "object"
@@ -23400,6 +23424,13 @@
"default": 36000,
"maximum": 36000,
"minimum": 60
},
"missing_backup_notification_days": {
"description": "Alert after this many days without an execution; 0 disables alerts",
"type": "integer",
"default": 0,
"maximum": 365,
"minimum": 0
}
},
"type": "object",
@@ -23422,7 +23453,8 @@
"retention_amount_s3",
"retention_days_s3",
"retention_max_storage_s3",
"timeout"
"timeout",
"missing_backup_notification_days"
],
"properties": {
"uuid": {
@@ -23484,6 +23516,9 @@
},
"timeout": {
"type": "integer"
},
"missing_backup_notification_days": {
"type": "integer"
}
},
"type": "object"
@@ -24546,6 +24581,14 @@
"geoip_refresh_days": {
"type": "integer"
},
"traffic_ip_mode": {
"type": "string",
"enum": [
"full",
"anonymized",
"off"
]
},
"is_reachable": {
"type": "boolean"
},
+29 -5
View File
@@ -678,7 +678,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1087,7 +1087,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1496,7 +1496,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -1846,7 +1846,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -2182,7 +2182,7 @@ paths:
autogenerate_domain:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true."
is_container_label_escape_enabled:
type: boolean
default: true
@@ -6119,6 +6119,8 @@ paths:
$ref: '#/components/responses/404'
'409':
description: 'Another start, restart or import of this database is already in progress.'
'422':
description: 'The database cannot start yet, for example because a CA certificate is missing.'
security:
-
bearerAuth: []
@@ -6194,6 +6196,8 @@ paths:
$ref: '#/components/responses/404'
'409':
description: 'Another start, restart or import of this database is already in progress.'
'422':
description: 'The database cannot start yet, for example because a CA certificate is missing.'
security:
-
bearerAuth: []
@@ -10892,6 +10896,7 @@ paths:
traffic_retention_1d_days: { type: integer }
is_geoip_enabled: { type: boolean }
geoip_refresh_days: { type: integer }
traffic_ip_mode: { description: 'How Sentinel stores client IPs for the Top IPs breakdown.', type: string, enum: [full, anonymized, 'off'] }
geoip_maxmind_license_key: { description: 'Only present with read:sensitive.', type: string }
type: object
'401':
@@ -10962,6 +10967,10 @@ paths:
minimum: 1
geoip_maxmind_license_key:
type: [string, 'null']
traffic_ip_mode:
description: 'How Sentinel stores client IPs: full, anonymized (IPv4 /24, IPv6 /48), or off.'
type: string
enum: [full, anonymized, 'off']
type: object
responses:
'200':
@@ -14875,6 +14884,12 @@ components:
default: 36000
maximum: 36000
minimum: 60
missing_backup_notification_days:
description: 'Alert after this many days without an execution; 0 disables alerts'
type: integer
default: 0
maximum: 365
minimum: 0
type: object
additionalProperties: false
VolumeBackupScheduleResponse:
@@ -14895,6 +14910,7 @@ components:
- retention_days_s3
- retention_max_storage_s3
- timeout
- missing_backup_notification_days
properties:
uuid:
type: string
@@ -14937,6 +14953,8 @@ components:
format: float
timeout:
type: integer
missing_backup_notification_days:
type: integer
type: object
Application:
description: 'Application model'
@@ -15717,6 +15735,12 @@ components:
type: boolean
geoip_refresh_days:
type: integer
traffic_ip_mode:
type: string
enum:
- full
- anonymized
- 'off'
is_reachable:
type: boolean
is_sentinel_enabled:
+9 -6
View File
@@ -370,12 +370,15 @@ $appListboxOptions = array_merge(
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => 'Top IPs',
'rows' => data_get($breakdowns, 'ip', []),
'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@@ -214,12 +214,15 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => 'Top IPs',
'rows' => data_get($breakdowns, 'ip', []),
'helper' => 'Busiest client IPs (real visitor IP, resolved behind Cloudflare / reverse proxies).',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@@ -10,7 +10,7 @@
<x-server.sidebar :server="$server" activeMenu="github-runners" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if (!$server->isBuildServer())
@if (! $server->canBuildApplications())
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
helper="Each workflow job runs in a new container that is deleted after the job.">
<x-slot:actions>
@@ -22,13 +22,13 @@
</a>
</x-slot:actions>
<x-empty size="sm" title="This server is not enabled for builds"
description="GitHub Actions runners only run on servers with the Builds only role. Change the server role in the General settings."
description="GitHub Actions runners need the Builds only or the Deployments and builds role. Change the server role in the General settings."
icon-name="play-circle" />
</x-application.settings-section>
@elseif ($this->githubApps->isEmpty())
<x-callout type="info" title="No organization GitHub App">
Runners are registered at organization level. Add a GitHub App that belongs to an organization
under Sources, and select "Run workflow jobs on build servers" when you register it.
under Sources, and select "Run workflow jobs on your servers" when you register it.
</x-callout>
@elseif (! $this->config?->is_enabled)
<x-application.settings-section id="github-runners-section" title="GitHub Actions runners"
@@ -40,10 +40,24 @@
description="Enable runners to take GitHub Actions workflow jobs on this server."
icon-name="play-circle">
<x-slot:contents>
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click="toggleEnabled" wire:loading.attr="disabled" wire:target="toggleEnabled">
Enable runners
</x-forms.button>
@if ($server->isBuildServer())
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click="toggleEnabled" wire:loading.attr="disabled" wire:target="toggleEnabled">
Enable runners
</x-forms.button>
@else
<x-modal-confirmation title="Enable runners on this server?"
submitAction="toggleEnabled" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Enable runners"
warningMessage="This server also runs your resources. We do not recommend GitHub Actions runners here: workflow jobs use the same CPU, memory, and disk, and a heavy job can slow down or stop your resources. Use a server with the Builds only role for runners when you can."
:actions="['Workflow jobs of the GitHub organization will run on this server next to your resources.']">
<x-slot:trigger>
<x-forms.button type="button" canGate="update" :canResource="$server" isHighlighted>
Enable runners
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</x-slot:contents>
</x-empty>
</x-application.settings-section>
@@ -63,6 +77,13 @@
@endcan
</x-slot:actions>
@if (! $server->isBuildServer())
<x-callout type="warning" title="Runners share this server with your resources" class="mb-4">
Workflow jobs use the same CPU, memory, and disk as the resources on this server. A heavy
job can slow down or stop them. Use a server with the Builds only role for runners when
you can.
</x-callout>
@endif
<div x-cloak x-show="$wire.dockerMode === 'dind'">
<x-callout type="warning" title="Use only for trusted repositories">
In the privileged Docker mode, a malicious workflow can take control of this server.
@@ -99,12 +120,14 @@
<x-forms.input id="labels" label="Labels" required canGate="update" :canResource="$server"
placeholder="coolify"
helper="Comma-separated custom labels. Coolify also registers self-hosted and linux. Jobs must ask for at least one custom label." />
<x-forms.listbox id="isDedicated" label="Application builds" canGate="update"
:canResource="$server" :options="[
['value' => false, 'label' => 'Also build applications'],
['value' => true, 'label' => 'Dedicated to runners'],
]"
helper="Dedicated servers are not used for application builds while runners are enabled." />
@if ($server->isBuildServer())
<x-forms.listbox id="isDedicated" label="Application builds" canGate="update"
:canResource="$server" :options="[
['value' => false, 'label' => 'Also build applications'],
['value' => true, 'label' => 'Dedicated to runners'],
]"
helper="Dedicated servers are not used for application builds while runners are enabled." />
@endif
<x-forms.listbox id="allowPullRequests" label="Pull request jobs" canGate="update"
:canResource="$server" :options="[
['value' => false, 'label' => 'Refuse pull request jobs'],
@@ -189,7 +212,7 @@
@endcan
@endif
@if ($server->isBuildServer() && $this->config?->is_enabled)
@if ($server->canBuildApplications() && $this->config?->is_enabled)
<x-application.settings-section id="github-runners-executions-section" title="Recent runners"
helper="Queued jobs, active runners, and their results. The list updates every 10 seconds." flush>
<livewire:server.github-runner-executions :server="$server" />
@@ -84,11 +84,11 @@
<x-forms.input type="number" id="port" label="Port" required />
</div>
<x-forms.listbox id="server_role"
helper="Choose whether this server runs deployments, application builds, or both. GitHub Actions runners need the Builds only role."
helper="Choose whether this server runs deployments, application builds, or both. GitHub Actions runners need the Builds only or the Deployments and builds role."
label="Server role" :options="[
['value' => 'deployment', 'label' => 'Deployments only', 'description' => 'Runs your resources. Images are built on a build server.'],
['value' => 'build', 'label' => 'Builds only', 'description' => 'Builds images for other servers. Required for GitHub Actions runners.'],
['value' => 'both', 'label' => 'Deployments and builds', 'description' => 'Builds and runs your resources on this server.'],
['value' => 'build', 'label' => 'Builds only', 'description' => 'Builds images for other servers and runs GitHub Actions runners.'],
['value' => 'both', 'label' => 'Deployments and builds', 'description' => 'Builds and runs your resources on this server. Can also run GitHub Actions runners.'],
]" />
</x-forms.collapsible>
</x-application.settings-section>
@@ -238,11 +238,11 @@
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.listbox canGate="update" :canResource="$server" id="serverRole"
label="Server role" onChange="requestServerRoleChange"
helper="Builds can use large amounts of CPU and memory. Deployments on the same server can become slow or unreachable during a build. GitHub Actions runners need the Builds only role."
helper="Builds can use large amounts of CPU and memory. Deployments on the same server can become slow or unreachable during a build. GitHub Actions runners need the Builds only or the Deployments and builds role."
:disabled="$isValidating" :options="[
['value' => 'deployment', 'label' => 'Deployments only', 'description' => 'Runs your resources. Images are built on a build server.'],
['value' => 'build', 'label' => 'Builds only', 'description' => 'Builds images for other servers. Required for GitHub Actions runners.'],
['value' => 'both', 'label' => 'Deployments and builds', 'description' => 'Builds and runs your resources on this server.'],
['value' => 'build', 'label' => 'Builds only', 'description' => 'Builds images for other servers and runs GitHub Actions runners.'],
['value' => 'both', 'label' => 'Deployments and builds', 'description' => 'Builds and runs your resources on this server. Can also run GitHub Actions runners.'],
]" />
</div>
@endif
@@ -2,7 +2,7 @@
<form wire:submit.prevent="saveTrafficAnalyticsSettings" class="contents">
@if ($isTrafficAnalyticsEnabled)
<x-unsaved-bar action="saveTrafficAnalyticsSettings"
targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey" />
targets="trafficTopn,trafficSampleThreshold,trafficRetention1hDays,trafficRetention1dDays,isGeoipEnabled,geoipRefreshDays,geoipMaxmindLicenseKey,trafficIpMode" />
@endif
<x-application.settings-section id="server-traffic-analytics-settings-section" title="Traffic analytics"
@@ -54,6 +54,14 @@
['value' => false, 'label' => 'Disabled'],
]"
helper="Country enrichment from visitor IPs. Disable to skip GeoIP lookups." />
<x-forms.listbox canGate="update" :canResource="$server"
id="trafficIpMode" label="Client IP addresses"
:options="[
['value' => 'full', 'label' => 'Full'],
['value' => 'anonymized', 'label' => 'Anonymized (/24 IPv4, /48 IPv6)'],
['value' => 'off', 'label' => 'Disabled'],
]"
helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." />
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="geoipRefreshDays" label="GeoIP refresh interval" required
helper="Days between GeoIP database update checks." />
@@ -314,7 +314,7 @@
:disabled="blank($github_app->organization)"
:options="[
['value' => false, 'label' => 'Do not run workflow jobs'],
['value' => true, 'label' => 'Run workflow jobs on build servers'],
['value' => true, 'label' => 'Run workflow jobs on your servers'],
]"
:helper="filled($github_app->organization)
? 'Adds the organization Self-hosted runners (write) and Actions (read) permissions and the Workflow job webhook event.'
@@ -37,7 +37,7 @@
<x-forms.checkbox id="use_for_pull_request_previews" label="Pull request previews"
helper="Deploys pull requests and posts deployment status on them." />
<x-forms.checkbox id="use_for_github_runners" live label="GitHub Actions runners"
helper="Runs workflow jobs on your build servers. Needs an organization." />
helper="Runs workflow jobs on your servers. Needs an organization." />
</div>
</div>
@@ -27,7 +27,7 @@
@php($missingRunnerRequirements = $github_app->missingRunnerRequirements())
<x-application.settings-section title="GitHub Actions runners"
description="Build servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a build server.">
description="Your servers can run the organization's workflow jobs in Docker containers. Configure them in the GitHub Runners menu of a server.">
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="view" :canResource="$github_app"
label="Self-hosted runners" readonly placeholder="N/A"
+28 -1
View File
@@ -221,7 +221,8 @@ describe('Sentinel API', function () {
->assertJsonPath('is_sentinel_enabled', true)
->assertJsonPath('is_metrics_enabled', true)
->assertJsonPath('traffic_topn', 50)
->assertJsonPath('is_geoip_enabled', true);
->assertJsonPath('is_geoip_enabled', true)
->assertJsonPath('traffic_ip_mode', 'full');
expect($response->json())->not->toHaveKey('sentinel_token')
->and($response->json())->not->toHaveKey('sentinel_custom_url');
@@ -247,6 +248,32 @@ describe('Sentinel API', function () {
->toHaveCount(1);
});
test('PATCH updates the client IP mode and restarts Sentinel', function (string $mode) {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => $mode,
])
->assertOk()
->assertJsonPath('traffic_ip_mode', $mode);
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe($mode);
expect(Queue::pushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel))
->toHaveCount(1);
})->with(['anonymized', 'off']);
test('PATCH rejects an unknown client IP mode', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
'traffic_ip_mode' => 'partial',
])
->assertUnprocessable()
->assertJsonValidationErrors('traffic_ip_mode');
expect($this->server->settings->fresh()->traffic_ip_mode->value)->toBe('full');
});
test('PATCH rejects disabling mandatory Sentinel', function () {
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
+4 -4
View File
@@ -480,14 +480,14 @@ describe('workflow_job webhook', function () {
});
describe('provisioning', function () {
it('starts a runner on the least busy matching build server', function () {
it('starts a runner on the least busy matching server that can build', function () {
Queue::fake();
fakeRunnerGithubApi($this->githubApp);
Process::fake(['*' => Process::result(output: '29.8.0')]);
$busyServer = runnerTestServer($this->team);
$busyConfig = runnerTestConfig($busyServer, $this->githubApp);
runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $busyServer->id, 'github_runner_config_id' => $busyConfig->id]);
$freeServer = runnerTestServer($this->team);
$freeServer = runnerTestServer($this->team, 'both');
runnerTestConfig($freeServer, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
@@ -615,7 +615,7 @@ describe('provisioning', function () {
Process::assertNothingRan();
});
it('does not use servers without the build role, disabled configs, or other labels', function (string $role, array $configAttributes) {
it('does not use deployments only servers, disabled configs, or other labels', function (string $role, array $configAttributes) {
Queue::fake();
Process::fake();
$server = runnerTestServer($this->team, $role);
@@ -627,7 +627,7 @@ describe('provisioning', function () {
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Queued);
Process::assertNothingRan();
})->with([
'combined role' => ['both', []],
'deployments only role' => ['deployment', []],
'disabled config' => ['build', ['is_enabled' => false]],
'other labels' => ['build', ['labels' => ['gpu']]],
]);
+58 -8
View File
@@ -268,21 +268,47 @@ describe('runner settings page', function () {
->assertRedirect(route('server.index'));
});
it('refuses to save runners on a server without the build role', function () {
it('asks for confirmation before it enables runners on a server that also runs resources', function () {
$server = settingsTestServer($this->team, 'both');
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertSee('Enable runners on this server?')
->assertDontSee('Application builds')
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Runners share this server with your resources');
expect(GithubRunnerConfig::sole())->server_id->toBe($server->id)->is_enabled->toBeTrue();
});
it('refuses runners on a deployments only server', function () {
$server = settingsTestServer($this->team, 'deployment');
Http::fake();
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $server->uuid])
->assertSee('This server is not enabled for builds')
->assertSee(route('server.show', ['server_uuid' => $server->uuid]))
->assertSee(route('server.github-runners', ['server_uuid' => $server->uuid]))
->assertDontSee('Recent runners')
->set('githubAppId', $this->githubApp->id)
->call('submit')
->call('toggleEnabled')
->assertDispatched('error');
expect(GithubRunnerConfig::count())->toBe(0);
Http::assertNothingSent();
});
it('enables runners on a build server without a confirmation', function () {
fakeRunnerGroupApi($this->githubApp);
Livewire::actingAs($this->owner)
->test(GithubRunners::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Enable runners on this server?')
->call('toggleEnabled')
->assertDispatched('success')
->assertSee('Application builds')
->assertDontSee('Runners share this server with your resources');
});
it('disables runners and removes only the idle ones', function () {
@@ -539,25 +565,49 @@ describe('build server role', function () {
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
});
it('blocks a role change in the UI while runners are enabled', function () {
it('changes the role in the UI while runners are enabled', function () {
Livewire::actingAs($this->owner)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->set('serverRole', 'both')
->call('requestServerRoleChange')
->assertNotDispatched('error')
->assertDispatched('open-server-role-confirmation')
->call('confirmServerRoleChange');
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('blocks a change to deployments only in the UI while runners are enabled', function () {
Livewire::actingAs($this->owner)
->test(Show::class, ['server_uuid' => $this->server->uuid])
->set('serverRole', 'deployment')
->call('requestServerRoleChange')
->assertSet('serverRole', 'build')
->assertDispatched('error');
expect($this->server->settings->fresh()->server_role->value)->toBe('build');
});
it('blocks a role change through the API while runners are enabled', function () {
it('blocks a change to deployments only through the API while runners are enabled', function () {
$token = $this->owner->createToken('runner-test', ['*']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
settingsTestServer($this->team, 'build');
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'deployment'])
->assertStatus(422)
->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you set this server to deployments only.');
});
it('changes the role through the API while runners are enabled', function () {
$token = $this->owner->createToken('runner-test', ['*']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->patchJson('/api/v1/servers/'.$this->server->uuid, ['server_role' => 'both'])
->assertStatus(422)
->assertJsonPath('errors.server_role.0', 'Disable the GitHub runners before you change the role of this server.');
->assertSuccessful();
expect($this->server->settings->fresh()->server_role->value)->toBe('both');
});
it('leaves dedicated runner servers out of application builds', function () {
@@ -200,6 +200,24 @@ it('decorates per-app paths with the app domain and surfaces AI agents', functio
expect($component->instance()->topPaths[0]['domain'])->toBe('api.example.com');
});
it('hides the IP breakdown when the server does not record client IPs', function () {
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, true);
$server = $application->destination->server;
$server->settings->traffic_ip_mode = 'off';
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
$fake = new FakeAnalyticsTrafficClient($server);
$fake->responses = fakeAnalyticsResponses();
app()->bind(SentinelTrafficClient::class, fn () => $fake);
loadLazy(Livewire::test(Analytics::class, ['application' => $application->fresh()]))
->assertOk()
->assertSee('Top user agents')
->assertDontSee('Top IPs')
->assertDontSee('198.51.100.42');
});
it('shows an empty state when traffic analytics is disabled for the server', function () {
$application = makeAnalyticsApplication($this->team, $this->privateKey, $this->environment, false);
@@ -1,5 +1,6 @@
<?php
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Models\Application;
use App\Models\Environment;
@@ -469,3 +470,52 @@ it('queries Sentinel with the same window for loads within one cache interval',
->and($fake->urls)->not->toBeEmpty()
->and(array_values(array_diff($fake->urls, $firstUrls)))->toBe([]);
});
it('labels the IP breakdown by the client IP mode of the queried servers', function (string $mode, ?string $label) {
$server = bootEnabledGlobalServer();
$server->settings->traffic_ip_mode = $mode;
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
$fake = new FakeGlobalAnalyticsTrafficClient($server);
$fake->responses = fakeGlobalAnalyticsResponses();
app()->bind(SentinelTrafficClient::class, fn () => $fake);
$component = loadLazy(Livewire::test(Analytics::class))
->assertOk()
->assertSet('ipMode', $mode)
->assertSee('Top user agents');
if ($label === null) {
$component->assertDontSee('Top IPs')
->assertDontSee('Top networks')
->assertDontSee('203.0.113.7');
expect($component->instance()->breakdowns)->not->toHaveKey('ip');
return;
}
$component->assertSee($label)->assertSee('203.0.113.7');
})->with([
'full' => ['full', 'Top IPs'],
'anonymized' => ['anonymized', 'Top networks'],
'off' => ['off', null],
]);
it('combines the client IP mode of several servers', function (array $modes, string $expected) {
$servers = collect($modes)->map(function (string $mode) {
$server = bootEnabledGlobalServer();
$server->settings->traffic_ip_mode = $mode;
// Saved quietly: a mode change restarts Sentinel, which needs SSH.
$server->settings->saveQuietly();
return $server->fresh();
});
expect(TrafficIpMode::forServers($servers))->toBe(TrafficIpMode::from($expected));
})->with([
'no servers' => [[], 'full'],
'all off' => [['off', 'off'], 'off'],
'anonymized and off' => [['anonymized', 'off'], 'anonymized'],
'full and anonymized' => [['full', 'anonymized'], 'full'],
]);
@@ -44,9 +44,19 @@ it('produces traffic + geoip env when enabled', function () {
expect($env['GEOIP_ENABLED'])->toBe('true');
expect($env['GEOIP_REFRESH_DAYS'])->toBe('30');
expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic');
expect($env['TRAFFIC_IP_MODE'])->toBe('full');
expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH');
});
it('passes the saved client IP mode as sentinel env', function (string $mode) {
$server = trafficProxyServer($this);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->traffic_ip_mode = $mode;
$server->settings->save();
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_IP_MODE'])->toBe($mode);
})->with(['anonymized', 'off']);
it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) {
config()->set('app.env', 'local');
$server = trafficProxyServer($this);
@@ -2,6 +2,7 @@
use App\Actions\Server\ConfigureTrafficAnalytics;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use App\Livewire\Analytics;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\InstanceSettings;
@@ -105,6 +106,51 @@ it('saves traffic analytics settings from the sentinel form', function () {
->and($settings->geoip_refresh_days)->toBe(7);
});
it('saves the client IP mode from the sentinel form', function () {
Queue::fake();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->assertSet('trafficIpMode', 'full')
->assertSee('Client IP addresses')
->set('trafficIpMode', 'anonymized')
->call('saveTrafficAnalyticsSettings')
->assertHasNoErrors();
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Anonymized);
});
it('rejects an unknown client IP mode', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server])
->set('trafficIpMode', 'partial')
->call('saveTrafficAnalyticsSettings')
->assertHasErrors(['trafficIpMode']);
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full);
});
it('does not let a team member change the client IP mode', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$component = Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server]);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
$component->set('trafficIpMode', 'off')
->call('saveTrafficAnalyticsSettings');
expect($server->settings->fresh()->traffic_ip_mode)->toBe(TrafficIpMode::Full);
});
it('rejects a zero top-n cap', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);