mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 17:30:46 -04:00
feat(sentinel): track synchronization state and refresh status UI
Add sentinel waiting-state tracking, synchronization broadcasts, and restore status handling across server and application interfaces.
This commit is contained in:
@@ -48,7 +48,11 @@ class StartSentinel
|
||||
}
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
$network = $server->isLocalhost() ? ' --network coolify' : '';
|
||||
$dockerCommand = "docker run -d$network $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
$server->sentinelHeartbeat(isReset: true);
|
||||
$server->forceFill(['sentinel_waiting_since' => now()])->save();
|
||||
|
||||
instant_remote_process([
|
||||
'docker rm -f coolify-sentinel || true',
|
||||
@@ -60,7 +64,10 @@ class StartSentinel
|
||||
|
||||
$server->settings->is_sentinel_enabled = true;
|
||||
$server->settings->save();
|
||||
$server->sentinelHeartbeat();
|
||||
$server->refresh();
|
||||
if ($server->sentinel_waiting_since !== null) {
|
||||
$server->forceFill(['sentinel_waiting_since' => now()])->save();
|
||||
}
|
||||
|
||||
// Dispatch event to notify UI components
|
||||
SentinelRestarted::dispatch($server, $version);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SentinelSynchronized implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public ?int $teamId = null;
|
||||
|
||||
public string $serverUuid;
|
||||
|
||||
public function __construct(Server $server)
|
||||
{
|
||||
$this->teamId = $server->team_id;
|
||||
$this->serverUuid = $server->uuid;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
if (is_null($this->teamId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new PrivateChannel("team.{$this->teamId}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\SentinelSynchronized;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
@@ -92,9 +93,16 @@ class SentinelController extends Controller
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
$wasSentinelLive = $server->sentinel_updated_at !== null && $server->isSentinelLive();
|
||||
|
||||
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||
$server->sentinel_waiting_since = null;
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
if (! $wasSentinelLive) {
|
||||
SentinelSynchronized::dispatch($server);
|
||||
}
|
||||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public $timeout = 60;
|
||||
|
||||
private ?array $previousOutdatedInfo = null;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
@@ -36,6 +38,7 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->previousOutdatedInfo = $this->server->traefik_outdated_info;
|
||||
$this->clearOutdatedInfo();
|
||||
|
||||
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
|
||||
@@ -106,12 +109,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Always check for newer branches first
|
||||
$newerBranchInfo = $this->getNewerBranchInfo($currentBranch);
|
||||
|
||||
if (version_compare($current, $latest, '<')) {
|
||||
// Patch update available
|
||||
$this->storeOutdatedInfo($current, $latest, 'patch_update', null, $newerBranchInfo);
|
||||
} elseif ($newerBranchInfo) {
|
||||
// Only newer branch available (no patch update)
|
||||
if ($newerBranchInfo) {
|
||||
$this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']);
|
||||
} elseif (version_compare($current, $latest, '<')) {
|
||||
$this->storeOutdatedInfo($current, $latest, 'patch_update');
|
||||
} else {
|
||||
// Fully up to date
|
||||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
@@ -158,10 +159,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Store outdated information in database and send immediate notification.
|
||||
* Store outdated information and notify for minor or major upgrades.
|
||||
*/
|
||||
private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void
|
||||
private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null): void
|
||||
{
|
||||
$previousOutdatedInfo = $this->previousOutdatedInfo ?? $this->server->traefik_outdated_info;
|
||||
$outdatedInfo = [
|
||||
'current' => $current,
|
||||
'latest' => $latest,
|
||||
@@ -174,16 +176,16 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$outdatedInfo['upgrade_target'] = $upgradeTarget;
|
||||
}
|
||||
|
||||
// If there's a newer branch available (even for patch updates), include that info
|
||||
if ($newerBranchInfo) {
|
||||
$outdatedInfo['newer_branch_target'] = $newerBranchInfo['target'];
|
||||
$outdatedInfo['newer_branch_latest'] = $newerBranchInfo['latest'];
|
||||
}
|
||||
|
||||
$this->server->update(['traefik_outdated_info' => $outdatedInfo]);
|
||||
|
||||
// Send immediate notification to the team
|
||||
$this->sendNotification($outdatedInfo);
|
||||
$isRepeatedUpgrade = ($previousOutdatedInfo['type'] ?? null) === $type
|
||||
&& ($previousOutdatedInfo['upgrade_target'] ?? null) === $upgradeTarget;
|
||||
|
||||
if ($type !== 'patch_update' && ! $isRepeatedUpgrade) {
|
||||
$this->sendNotification($outdatedInfo);
|
||||
}
|
||||
|
||||
$this->previousOutdatedInfo = $outdatedInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -487,6 +487,9 @@ class General extends Component
|
||||
if ($this->isContainerLabelReadonlyEnabled) {
|
||||
$this->resetDefaultLabels(false);
|
||||
}
|
||||
if ($oldPortsExposes !== $this->portsExposes) {
|
||||
$this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class);
|
||||
}
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -885,6 +888,9 @@ class General extends Component
|
||||
$this->application->save();
|
||||
$this->application->refresh();
|
||||
$this->syncData();
|
||||
if ($oldPortsExposes !== $this->portsExposes) {
|
||||
$this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class);
|
||||
}
|
||||
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
|
||||
} catch (\Throwable $e) {
|
||||
$this->application->refresh();
|
||||
|
||||
@@ -8,12 +8,21 @@ use Livewire\Component;
|
||||
|
||||
class InternalAccess extends Component
|
||||
{
|
||||
protected $listeners = [
|
||||
'applicationNetworkingUpdated' => 'refreshApplicationNetworking',
|
||||
];
|
||||
|
||||
public Application $application;
|
||||
|
||||
public ?string $currentInternalHostname = null;
|
||||
|
||||
public bool $currentInternalHostnameLoaded = false;
|
||||
|
||||
public function refreshApplicationNetworking(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
}
|
||||
|
||||
public function loadCurrentInternalHostname(): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Component;
|
||||
|
||||
class Navbar extends Component
|
||||
@@ -34,14 +35,18 @@ class Navbar extends Component
|
||||
|
||||
public array $serverSwitcherOptions = [];
|
||||
|
||||
public ?bool $sentinelWarningOverride = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServerShow' => 'refreshServer',
|
||||
'sentinel-restart-requested' => 'hideSentinelWarning',
|
||||
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
|
||||
"echo-private:team.{$teamId},SentinelSynchronized" => 'refreshSentinelStatus',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -247,6 +252,31 @@ class Navbar extends Component
|
||||
}
|
||||
|
||||
$this->refreshServer();
|
||||
$this->sentinelWarningOverride = null;
|
||||
$sentinelStatus = $this->server->sentinelStatus();
|
||||
$sentinelStatusStartedAt = $this->server->sentinel_waiting_since ?? Carbon::parse($this->server->sentinel_updated_at);
|
||||
$sentinelTimeoutSeconds = $this->server->sentinel_waiting_since !== null
|
||||
? $this->server->firstSentinelReportTimeoutSeconds()
|
||||
: $this->server->waitBeforeDoingSshCheck();
|
||||
$expiresInMilliseconds = max(
|
||||
0,
|
||||
($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000
|
||||
);
|
||||
$this->dispatch(
|
||||
'sentinel-status-changed',
|
||||
outOfSync: $this->server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
|
||||
expiresInMilliseconds: $expiresInMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
public function hideSentinelWarning(): void
|
||||
{
|
||||
$this->sentinelWarningOverride = false;
|
||||
}
|
||||
|
||||
public function refreshAgentStatus(): void
|
||||
{
|
||||
$this->refreshSentinelStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,10 @@ class Sentinel extends Component
|
||||
|
||||
public ?string $sentinelUpdatedAt = null;
|
||||
|
||||
public string $sentinelStatus = 'out_of_sync';
|
||||
|
||||
public ?int $sentinelRestartRequestedAt = null;
|
||||
|
||||
#[Validate(['required', 'integer', 'min:1'])]
|
||||
public int|string $sentinelMetricsRefreshRateSeconds;
|
||||
|
||||
@@ -42,6 +46,7 @@ class Sentinel extends Component
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
|
||||
"echo-private:team.{$teamId},SentinelSynchronized" => 'handleSentinelSynchronized',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -71,6 +76,7 @@ class Sentinel extends Component
|
||||
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
|
||||
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->sentinelStatus = $this->server->sentinelStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +87,52 @@ class Sentinel extends Component
|
||||
// Only refresh display-only state; never re-sync text-input properties
|
||||
// (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->sentinelStatus = $this->server->sentinelStatus();
|
||||
$this->sentinelRestartRequestedAt = null;
|
||||
$this->dispatch('success', 'Sentinel has been restarted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
public function handleSentinelSynchronized($event): void
|
||||
{
|
||||
if ($event['serverUuid'] === $this->server->uuid) {
|
||||
$this->server->refresh();
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->sentinelStatus = 'in_sync';
|
||||
$this->sentinelRestartRequestedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshSentinelStatus(): void
|
||||
{
|
||||
if ($this->sentinelStatus === 'restarting'
|
||||
&& $this->sentinelRestartRequestedAt !== null
|
||||
&& $this->sentinelRestartRequestedAt > now()->subSeconds($this->server->firstSentinelReportTimeoutSeconds())->timestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->server->refresh();
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->sentinelStatus = $this->server->sentinelStatus();
|
||||
}
|
||||
|
||||
private function setSentinelRestarting(): void
|
||||
{
|
||||
$this->sentinelStatus = 'restarting';
|
||||
$this->sentinelRestartRequestedAt = now()->timestamp;
|
||||
$this->dispatch(
|
||||
'sentinel-status-changed',
|
||||
outOfSync: false,
|
||||
expiresInMilliseconds: $this->server->firstSentinelReportTimeoutSeconds() * 1000,
|
||||
);
|
||||
$this->dispatch('sentinel-restart-requested');
|
||||
}
|
||||
|
||||
public function restartSentinel()
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$this->setSentinelRestarting();
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
$this->server->restartSentinel($customImage);
|
||||
$this->dispatch('info', 'Restarting Sentinel.');
|
||||
@@ -101,6 +145,7 @@ class Sentinel extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
$this->setSentinelRestarting();
|
||||
$this->server->settings->generateSentinelToken();
|
||||
$this->dispatch('success', 'Token regenerated. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
@@ -108,10 +153,29 @@ class Sentinel extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function restoreDefaultConfiguration(?string $password = null): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
|
||||
$this->server->settings->restoreDefaultSentinelConfiguration();
|
||||
|
||||
$this->sentinelCustomDockerImage = null;
|
||||
$this->syncData();
|
||||
$this->dispatch('sentinel-defaults-restored');
|
||||
$this->setSentinelRestarting();
|
||||
$this->server->restartSentinel();
|
||||
$this->dispatch('success', 'Default Sentinel configuration restored. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->setSentinelRestarting();
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -264,6 +264,7 @@ class Server extends BaseModel
|
||||
'unreachable_notification_sent' => 'boolean',
|
||||
'is_build_server' => 'boolean',
|
||||
'force_disabled' => 'boolean',
|
||||
'sentinel_waiting_since' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -967,11 +968,27 @@ $siteAddress {
|
||||
return $wait;
|
||||
}
|
||||
|
||||
public function firstSentinelReportTimeoutSeconds(): int
|
||||
{
|
||||
return max(30, $this->settings->sentinel_push_interval_seconds + 30);
|
||||
}
|
||||
|
||||
public function isSentinelLive()
|
||||
{
|
||||
return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck()));
|
||||
}
|
||||
|
||||
public function sentinelStatus(): string
|
||||
{
|
||||
if ($this->sentinel_waiting_since !== null) {
|
||||
return $this->sentinel_waiting_since->isAfter(now()->subSeconds($this->firstSentinelReportTimeoutSeconds()))
|
||||
? 'waiting'
|
||||
: 'out_of_sync';
|
||||
}
|
||||
|
||||
return $this->isSentinelLive() ? 'in_sync' : 'out_of_sync';
|
||||
}
|
||||
|
||||
public function isSentinelEnabled(): bool
|
||||
{
|
||||
return ! $this->isBuildServer()
|
||||
|
||||
@@ -60,6 +60,12 @@ use OpenApi\Attributes as OA;
|
||||
)]
|
||||
class ServerSetting extends Model
|
||||
{
|
||||
public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10;
|
||||
|
||||
public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7;
|
||||
|
||||
public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60;
|
||||
|
||||
protected $fillable = [
|
||||
'server_id',
|
||||
'is_swarm_manager',
|
||||
@@ -236,6 +242,10 @@ class ServerSetting extends Model
|
||||
{
|
||||
$url = $this->sentinel_custom_url;
|
||||
|
||||
if ($this->server->isLocalhost() && $url === 'http://host.docker.internal:8000') {
|
||||
$url = null;
|
||||
}
|
||||
|
||||
if (blank($url)) {
|
||||
$url = $this->generateSentinelUrl(ignoreEvent: true);
|
||||
}
|
||||
@@ -247,12 +257,22 @@ class ServerSetting extends Model
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function restoreDefaultSentinelConfiguration(): void
|
||||
{
|
||||
$this->generateSentinelUrl(save: false, ignoreEvent: true);
|
||||
$this->sentinel_metrics_refresh_rate_seconds = self::DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS;
|
||||
$this->sentinel_metrics_history_days = self::DEFAULT_SENTINEL_METRICS_HISTORY_DAYS;
|
||||
$this->sentinel_push_interval_seconds = self::DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS;
|
||||
$this->is_sentinel_debug_enabled = false;
|
||||
$this->saveQuietly();
|
||||
}
|
||||
|
||||
public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string
|
||||
{
|
||||
$domain = null;
|
||||
$settings = InstanceSettings::get();
|
||||
if ($this->server->isLocalhost()) {
|
||||
$domain = 'http://host.docker.internal:8000';
|
||||
$domain = 'http://coolify:8080';
|
||||
} elseif ($settings->fqdn) {
|
||||
$domain = $settings->fqdn;
|
||||
} elseif ($settings->public_ipv4) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.3.21',
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.3.22',
|
||||
'helper_version' => '1.0.17',
|
||||
'realtime_version' => '1.0.19',
|
||||
'railpack_version' => '0.23.0',
|
||||
|
||||
@@ -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('servers', function (Blueprint $table) {
|
||||
$table->timestamp('sentinel_waiting_since')->nullable()->after('sentinel_updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('servers', function (Blueprint $table) {
|
||||
$table->dropColumn('sentinel_waiting_since');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.21"
|
||||
"version": "4.3.22"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
@php
|
||||
$serverRouteParameters = ['server_uuid' => $server->uuid];
|
||||
$sentinelStatus = $server->sentinelStatus();
|
||||
$sentinelStatusStartedAt = $server->sentinel_waiting_since ?? \Illuminate\Support\Carbon::parse($server->sentinel_updated_at);
|
||||
$sentinelTimeoutSeconds = $server->sentinel_waiting_since !== null
|
||||
? $server->firstSentinelReportTimeoutSeconds()
|
||||
: $server->waitBeforeDoingSshCheck();
|
||||
$sentinelExpiresInMilliseconds = max(
|
||||
0,
|
||||
($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000,
|
||||
);
|
||||
$serverMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
@@ -70,7 +79,8 @@
|
||||
'icon' => 'shield-star',
|
||||
'group' => 'Platform',
|
||||
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
|
||||
'warning' => $server->isSentinelEnabled() && ! $server->isSentinelLive(),
|
||||
'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
|
||||
'tracks_sentinel_status' => true,
|
||||
'children' => [
|
||||
['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'],
|
||||
['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'],
|
||||
@@ -173,11 +183,24 @@
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
x-data="{
|
||||
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
|
||||
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo())
|
||||
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
|
||||
sentinelOutOfSync: @js($server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync'),
|
||||
sentinelExpiryTimer: null,
|
||||
scheduleSentinelExpiry(delay) {
|
||||
clearTimeout(this.sentinelExpiryTimer);
|
||||
if (!this.sentinelOutOfSync) {
|
||||
this.sentinelExpiryTimer = setTimeout(() => this.sentinelOutOfSync = true, delay);
|
||||
}
|
||||
}
|
||||
}"
|
||||
x-init="scheduleSentinelExpiry(@js($sentinelExpiresInMilliseconds))"
|
||||
@proxy-configuration-state-changed.window="
|
||||
proxyConfigurationPending = $event.detail.pending;
|
||||
traefikOutdated = $event.detail.traefikOutdated;
|
||||
"
|
||||
@sentinel-status-changed.window="
|
||||
sentinelOutOfSync = $event.detail.outOfSync;
|
||||
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
|
||||
">
|
||||
<nav aria-label="Server configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@@ -201,6 +224,9 @@
|
||||
<x-reicon name="alert-triangle" x-cloak
|
||||
x-show="proxyConfigurationPending || traefikOutdated"
|
||||
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
||||
@elseif ($menuItem['tracks_sentinel_status'] ?? false)
|
||||
<x-reicon name="alert-triangle" x-cloak x-show="sentinelOutOfSync"
|
||||
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
||||
@elseif ($menuItem['warning'] ?? false)
|
||||
<x-reicon name="alert-triangle"
|
||||
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav class="w-full max-w-none pb-3 lg:pb-0">
|
||||
<nav class="w-full max-w-none pb-3 lg:pb-0" wire:poll.30s="refreshAgentStatus">
|
||||
<x-process-dialog @startproxy.window="processDialogOpen = true" closeWithX>
|
||||
<x-slot:title>Proxy Startup Logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
@@ -53,7 +53,7 @@
|
||||
&& ! $server->isSwarm()
|
||||
&& ! $server->settings->is_build_server
|
||||
&& auth()->user()?->can('viewSentinel', $server),
|
||||
'warning' => $server->isSentinelEnabled() && ! $server->isSentinelLive(),
|
||||
'warning' => $sentinelWarningOverride ?? ($server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'),
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
@@ -116,7 +116,11 @@
|
||||
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">
|
||||
{{ $server->name }}
|
||||
</span>
|
||||
<x-reicon name="chevron-down" class="size-3 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
|
||||
fill="none" aria-hidden="true">
|
||||
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-9! left-1! z-[90]! w-64! min-w-0!">
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
@php
|
||||
$sentinelStatusLabel = match ($sentinelStatus) {
|
||||
'restarting' => 'Restarting',
|
||||
'waiting' => 'Waiting for first report',
|
||||
'in_sync' => 'In sync',
|
||||
default => 'Out of sync',
|
||||
};
|
||||
$sentinelStatusType = match ($sentinelStatus) {
|
||||
'in_sync' => 'success',
|
||||
'out_of_sync' => 'warning',
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="application-settings-form flex w-full flex-col gap-6" wire:poll.10s="refreshSentinelStatus">
|
||||
<form wire:submit.prevent="submit" class="contents">
|
||||
{{-- Scope dirty tracking to savable form fields only. Without wire:target,
|
||||
Livewire compares the entire component snapshot — so dev-only x-init
|
||||
@@ -11,33 +25,56 @@
|
||||
helper="Monitor server and container health while collecting historical metrics.">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
|
||||
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
|
||||
<x-status-badge :status="$sentinelStatusLabel" :type="$sentinelStatusType" />
|
||||
<x-forms.button wire:click="restartSentinel" canGate="update"
|
||||
:canResource="$server">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
{{ $server->isSentinelLive() ? 'Restart' : 'Sync' }}
|
||||
{{ $sentinelStatus === 'in_sync' ? 'Restart' : 'Sync' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
@if (!$server->isSentinelLive())
|
||||
@if ($sentinelStatus === 'out_of_sync')
|
||||
<x-callout type="warning" title="Sentinel is out of sync">
|
||||
Sync Sentinel to apply its current configuration and restore health reporting.
|
||||
<div class="space-y-3">
|
||||
<p>Sentinel has not reported within the expected interval. Check these items before syncing again:</p>
|
||||
<ul class="list-disc space-y-1 pl-4">
|
||||
<li>Confirm that the <code>coolify-sentinel</code> container is running.</li>
|
||||
<li>
|
||||
<a class="font-medium underline underline-offset-2"
|
||||
href="{{ route('server.sentinel.logs', ['server_uuid' => $server->uuid]) }}"
|
||||
wire:navigate>Open Sentinel logs</a>
|
||||
and review recent connection or push errors.
|
||||
</li>
|
||||
<li>Confirm that the Coolify URL and Sentinel token match this configuration.</li>
|
||||
</ul>
|
||||
|
||||
@if ($server->isLocalhost())
|
||||
<p>Sync Sentinel to recreate it on the Coolify Docker network.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
<p>The remote server needs outbound access to this Coolify URL. Sentinel reporting does not require an inbound listening port.</p>
|
||||
@if (filled($sentinelCustomUrl))
|
||||
<p>
|
||||
From the remote server, test
|
||||
<code class="break-all">curl -fsS {{ escapeshellarg(rtrim($sentinelCustomUrl, '/') . '/api/health') }}</code>.
|
||||
</p>
|
||||
@else
|
||||
<p>Set a reachable Coolify URL before syncing Sentinel.</p>
|
||||
@endif
|
||||
<p>Check DNS, TLS certificates, outbound firewall rules, and proxy settings.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-callout>
|
||||
@elseif ($sentinelStatus === 'in_sync')
|
||||
<p class="text-sm text-neutral-500 dark:text-fg-dim">
|
||||
Sentinel is connected and reporting server health to this Coolify instance.
|
||||
</p>
|
||||
@elseif ($sentinelStatus === 'restarting')
|
||||
<p class="text-sm text-neutral-500 dark:text-fg-dim">Sentinel is restarting.</p>
|
||||
@else
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
<x-reicon name="dashboard" class="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-950 dark:text-fg">Health reporting active</p>
|
||||
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Sentinel is connected and reporting server health to this Coolify instance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500 dark:text-fg-dim">Sentinel started and is waiting for its first authenticated report.</p>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -45,10 +82,24 @@
|
||||
<x-application.settings-section id="server-sentinel-connection-section" title="Connection"
|
||||
helper="Configure how Sentinel authenticates with and reports to Coolify.">
|
||||
<x-slot:actions>
|
||||
<x-forms.button canGate="update" :canResource="$server"
|
||||
wire:click="regenerateSentinelToken">
|
||||
Regenerate token
|
||||
</x-forms.button>
|
||||
<div class="flex items-center gap-2">
|
||||
@can('manageSentinel', $server)
|
||||
<x-modal-confirmation title="Restore default Sentinel configuration?"
|
||||
buttonTitle="Restore defaults" submitAction="restoreDefaultConfiguration"
|
||||
:actions="[
|
||||
'Restore the generated Coolify URL and default collection settings.',
|
||||
'Clear debug logging and the development image override.',
|
||||
'The Sentinel token and metrics setting will be preserved.',
|
||||
'Restart Sentinel to apply the restored configuration.',
|
||||
]" warningMessage="Your custom Sentinel configuration will be replaced with Coolify defaults."
|
||||
:confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Restore defaults" />
|
||||
@endcan
|
||||
<x-forms.button canGate="update" :canResource="$server"
|
||||
wire:click="regenerateSentinelToken">
|
||||
Regenerate token
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input canGate="update" :canResource="$server" id="sentinelCustomUrl"
|
||||
@@ -92,6 +143,7 @@
|
||||
$wire.set('sentinelCustomDockerImage', this.customImage || null);
|
||||
}
|
||||
}"
|
||||
@sentinel-defaults-restored.window="localStorage.removeItem('sentinel_custom_docker_image_{{ $server->uuid }}'); customImage = ''"
|
||||
{{-- Only hydrate Livewire when a real override exists. Unconditional
|
||||
$wire.set('', null→'') on every open marks the component dirty and
|
||||
flashes the unsaved bar until the round-trip completes. --}}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\InternalAccess;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('refreshes exposed ports when application networking changes', function () {
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['id' => 50, 'team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'ports_exposes' => '3000',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(InternalAccess::class, ['application' => $application])
|
||||
->assertSee('3000');
|
||||
|
||||
$application->update(['ports_exposes' => '8080']);
|
||||
|
||||
$component
|
||||
->dispatch('applicationNetworkingUpdated')
|
||||
->assertSee('8080')
|
||||
->assertDontSee('value="3000"', escape: false);
|
||||
});
|
||||
@@ -234,6 +234,56 @@ it('sends immediate notifications when outdated traefik is detected', function (
|
||||
expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update');
|
||||
});
|
||||
|
||||
it('does not notify about Traefik patch updates', function () {
|
||||
$team = Team::factory()->create();
|
||||
$team->webhookNotificationSettings()->update([
|
||||
'webhook_enabled' => true,
|
||||
'webhook_url' => 'https://example.com/webhook',
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']);
|
||||
(new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.7.7', '3.7.13', 'patch_update');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('notifies about Traefik minor updates', function () {
|
||||
$team = Team::factory()->create();
|
||||
$team->webhookNotificationSettings()->update([
|
||||
'webhook_enabled' => true,
|
||||
'webhook_url' => 'https://example.com/webhook',
|
||||
]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
$job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']);
|
||||
(new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.6.20', '3.7.13', 'minor_upgrade', 'v3.7');
|
||||
|
||||
Notification::assertCount(1);
|
||||
});
|
||||
|
||||
it('does not repeat a Traefik minor update notification when the target is unchanged', function () {
|
||||
$team = Team::factory()->create();
|
||||
$team->webhookNotificationSettings()->update([
|
||||
'webhook_enabled' => true,
|
||||
'webhook_url' => 'https://example.com/webhook',
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'traefik_outdated_info' => [
|
||||
'current' => '3.6.20',
|
||||
'latest' => '3.7.13',
|
||||
'type' => 'minor_upgrade',
|
||||
'upgrade_target' => 'v3.7',
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new CheckTraefikVersionForServerJob($server, ['v3.7' => '3.7.13']);
|
||||
(new ReflectionMethod($job, 'storeOutdatedInfo'))->invoke($job, '3.6.20', '3.7.13', 'minor_upgrade', 'v3.7');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('notification generates correct server proxy URLs', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
|
||||
@@ -10,6 +10,19 @@ it('keeps sentinel restarted events from re-syncing editable form fields', funct
|
||||
->not->toContain('$this->syncData();');
|
||||
});
|
||||
|
||||
it('refreshes display state after sentinel synchronization without changing editable fields', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
expect($componentSource)
|
||||
->toContain('SentinelSynchronized" => \'handleSentinelSynchronized\'');
|
||||
|
||||
preg_match('/public function handleSentinelSynchronized\([^)]*\)(?:: [^{]+)?\s*\{(?<body>.*?)\n \}/s', $componentSource, $matches);
|
||||
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain('$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;')
|
||||
->not->toContain('$this->syncData();');
|
||||
});
|
||||
|
||||
it('does not expose a Sentinel disable action', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
@@ -25,6 +38,15 @@ it('does not repeat a disabled status badge in the sentinel empty state', functi
|
||||
expect($view)->not->toContain("? 'Disabled'");
|
||||
});
|
||||
|
||||
it('shows the connected sentinel state without a decorative icon or redundant heading', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Sentinel is connected and reporting server health to this Coolify instance.')
|
||||
->not->toContain('Health reporting active')
|
||||
->not->toContain('<x-reicon name="dashboard"');
|
||||
});
|
||||
|
||||
it('tells the user that saving sentinel settings initiates a restart', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Livewire\Server\Sentinel;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::table('instance_settings')->insert(['id' => 0]);
|
||||
Once::flush();
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $team]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'id' => 0,
|
||||
'team_id' => $team->id,
|
||||
'ip' => 'host.docker.internal',
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores default Sentinel configuration without rotating credentials or disabling metrics', function () {
|
||||
$settings = $this->server->settings;
|
||||
$token = $settings->sentinel_token;
|
||||
$settings->forceFill([
|
||||
'is_metrics_enabled' => true,
|
||||
'is_sentinel_enabled' => true,
|
||||
'sentinel_custom_url' => 'https://coolify.example.com',
|
||||
'sentinel_metrics_refresh_rate_seconds' => 30,
|
||||
'sentinel_metrics_history_days' => 14,
|
||||
'sentinel_push_interval_seconds' => 120,
|
||||
'is_sentinel_debug_enabled' => true,
|
||||
])->saveQuietly();
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->set('sentinelCustomDockerImage', 'sentinel:development')
|
||||
->call('restoreDefaultConfiguration')
|
||||
->assertSet('sentinelCustomUrl', 'http://coolify:8080')
|
||||
->assertSet('sentinelMetricsRefreshRateSeconds', 10)
|
||||
->assertSet('sentinelMetricsHistoryDays', 7)
|
||||
->assertSet('sentinelPushIntervalSeconds', 60)
|
||||
->assertSet('isSentinelDebugEnabled', false)
|
||||
->assertSet('sentinelCustomDockerImage', null)
|
||||
->assertDispatched('sentinel-defaults-restored')
|
||||
->assertDispatched('success', 'Default Sentinel configuration restored. Restarting Sentinel.');
|
||||
|
||||
Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job): bool => $job->getAction() instanceof StartSentinel);
|
||||
expect(Queue::pushed(JobDecorator::class))->toHaveCount(1);
|
||||
|
||||
$settings->refresh();
|
||||
|
||||
expect($settings->sentinel_token)->toBe($token)
|
||||
->and((bool) $settings->is_metrics_enabled)->toBeTrue()
|
||||
->and((bool) $settings->is_sentinel_enabled)->toBeTrue()
|
||||
->and($settings->sentinel_custom_url)->toBe('http://coolify:8080')
|
||||
->and($settings->sentinel_metrics_refresh_rate_seconds)->toBe(10)
|
||||
->and($settings->sentinel_metrics_history_days)->toBe(7)
|
||||
->and($settings->sentinel_push_interval_seconds)->toBe(60)
|
||||
->and((bool) $settings->is_sentinel_debug_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('offers a confirmed restore action and explains what it preserves', function () {
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->assertSee('Restore defaults')
|
||||
->assertSee('The Sentinel token and metrics setting will be preserved.');
|
||||
});
|
||||
|
||||
it('does not let team members restore Sentinel defaults', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->server->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$before = $this->server->settings->fresh()->getAttributes();
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->server->team]);
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->call('restoreDefaultConfiguration');
|
||||
|
||||
expect($this->server->settings->fresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
it('shows local troubleshooting guidance when Sentinel is out of sync', function () {
|
||||
$this->server->sentinelHeartbeat(isReset: true);
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->assertSee('Sentinel has not reported within the expected interval.')
|
||||
->assertSee('Open Sentinel logs')
|
||||
->assertSeeHtml('href="'.route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]).'"')
|
||||
->assertDontSee('docker logs --tail 100 coolify-sentinel')
|
||||
->assertSee('Sync Sentinel to recreate it on the Coolify Docker network.')
|
||||
->assertDontSee('The remote server needs outbound access');
|
||||
});
|
||||
|
||||
it('shows remote connectivity checks when Sentinel is out of sync', function () {
|
||||
$remoteServer = Server::factory()->create([
|
||||
'team_id' => $this->server->team_id,
|
||||
'ip' => '192.0.2.10',
|
||||
]);
|
||||
$remoteServer->settings->forceFill([
|
||||
'sentinel_custom_url' => 'https://coolify.example.com',
|
||||
])->saveQuietly();
|
||||
$remoteServer->sentinelHeartbeat(isReset: true);
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $remoteServer])
|
||||
->assertSee('The remote server needs outbound access to this Coolify URL.')
|
||||
->assertSee('https://coolify.example.com/api/health')
|
||||
->assertSee('Check DNS, TLS certificates, outbound firewall rules, and proxy settings.')
|
||||
->assertDontSee('Sync Sentinel to recreate it on the Coolify Docker network.');
|
||||
});
|
||||
|
||||
it('shell quotes the remote health URL in troubleshooting guidance', function () {
|
||||
$remoteServer = Server::factory()->create([
|
||||
'team_id' => $this->server->team_id,
|
||||
'ip' => '192.0.2.10',
|
||||
]);
|
||||
$remoteServer->settings->forceFill([
|
||||
'sentinel_custom_url' => 'https://coolify.example.com/$(id)',
|
||||
])->saveQuietly();
|
||||
$remoteServer->sentinelHeartbeat(isReset: true);
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $remoteServer])
|
||||
->assertSee("curl -fsS 'https://coolify.example.com/\$(id)/api/health'");
|
||||
});
|
||||
|
||||
it('does not show troubleshooting guidance while Sentinel is live', function () {
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->assertDontSee('Sentinel has not reported within the expected interval.')
|
||||
->assertDontSee('Open Sentinel logs');
|
||||
});
|
||||
|
||||
it('shows restarting until the sentinel container starts', function () {
|
||||
Queue::fake();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->call('restartSentinel')
|
||||
->assertSet('sentinelStatus', 'restarting')
|
||||
->assertSee('Restarting')
|
||||
->assertDispatched('sentinel-status-changed', outOfSync: false, expiresInMilliseconds: 90000);
|
||||
});
|
||||
|
||||
it('shows waiting for first report after the sentinel container starts', function () {
|
||||
$this->server->forceFill(['sentinel_waiting_since' => now()])->save();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->call('handleSentinelRestarted', ['serverUuid' => $this->server->uuid])
|
||||
->assertSet('sentinelStatus', 'waiting')
|
||||
->assertSee('Waiting for first report')
|
||||
->assertDontSee('Sentinel is out of sync');
|
||||
});
|
||||
|
||||
it('uses one push interval plus thirty seconds for the first report timeout', function () {
|
||||
$this->server->settings->forceFill(['sentinel_push_interval_seconds' => 60])->saveQuietly();
|
||||
|
||||
expect($this->server->firstSentinelReportTimeoutSeconds())->toBe(90)
|
||||
->and($this->server->waitBeforeDoingSshCheck())->toBe(180);
|
||||
|
||||
$this->server->forceFill(['sentinel_waiting_since' => now()->subSeconds(89)])->save();
|
||||
expect($this->server->fresh()->sentinelStatus())->toBe('waiting');
|
||||
|
||||
$this->server->forceFill(['sentinel_waiting_since' => now()->subSeconds(91)])->save();
|
||||
expect($this->server->fresh()->sentinelStatus())->toBe('out_of_sync');
|
||||
});
|
||||
|
||||
it('leaves restarting state when no container-start event arrives before the timeout', function () {
|
||||
$this->server->sentinelHeartbeat(isReset: true);
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->set('sentinelStatus', 'restarting')
|
||||
->set('sentinelRestartRequestedAt', now()->subSeconds($this->server->firstSentinelReportTimeoutSeconds() + 1)->timestamp)
|
||||
->call('refreshSentinelStatus')
|
||||
->assertSet('sentinelStatus', 'out_of_sync');
|
||||
});
|
||||
|
||||
it('shows in sync after the first authenticated report', function () {
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server])
|
||||
->call('handleSentinelSynchronized', ['serverUuid' => $this->server->uuid])
|
||||
->assertSet('sentinelStatus', 'in_sync')
|
||||
->assertSee('In sync');
|
||||
});
|
||||
|
||||
it('shows out of sync when the first report timeout expires', function () {
|
||||
$this->server->forceFill([
|
||||
'sentinel_waiting_since' => now()->subSeconds($this->server->firstSentinelReportTimeoutSeconds() + 1),
|
||||
])->save();
|
||||
|
||||
Livewire::test(Sentinel::class, ['server' => $this->server->fresh()])
|
||||
->assertSet('sentinelStatus', 'out_of_sync')
|
||||
->assertSee('Out of sync');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Events\SentinelSynchronized;
|
||||
use App\Http\Controllers\Api\SentinelController;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
@@ -8,6 +9,7 @@ use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@@ -101,6 +103,32 @@ it('updates the heartbeat even when the job is skipped', function () use ($runni
|
||||
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('broadcasts when a successful push restores sentinel synchronization', function () use ($running) {
|
||||
Event::fake([SentinelSynchronized::class]);
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Event::assertDispatched(SentinelSynchronized::class, fn (SentinelSynchronized $event): bool => $event->serverUuid === $this->server->uuid);
|
||||
});
|
||||
|
||||
it('clears the waiting state after the first authenticated push', function () use ($running) {
|
||||
$this->server->forceFill(['sentinel_waiting_since' => now()])->save();
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
expect($this->server->fresh()->sentinel_waiting_since)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not broadcast synchronization for each healthy sentinel push', function () use ($running) {
|
||||
Event::fake([SentinelSynchronized::class]);
|
||||
$this->server->sentinelHeartbeat();
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Event::assertNotDispatched(SentinelSynchronized::class);
|
||||
});
|
||||
|
||||
it('accepts an empty container list as a heartbeat when no containers are running', function () {
|
||||
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
||||
|
||||
|
||||
@@ -151,6 +151,43 @@ describe('ServerSetting::ensureValidSentinelToken', function () {
|
||||
});
|
||||
|
||||
describe('ServerSetting::ensureSentinelUrl', function () {
|
||||
it('uses the internal Coolify endpoint for the local server', function () {
|
||||
InstanceSettings::query()->whereKey(0)->update([
|
||||
'fqdn' => 'https://coolify.example.com',
|
||||
]);
|
||||
Once::flush();
|
||||
$this->server->update(['ip' => 'host.docker.internal']);
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update(['sentinel_custom_url' => null]);
|
||||
|
||||
$url = $this->server->settings->fresh()->ensureSentinelUrl();
|
||||
|
||||
expect($url)->toBe('http://coolify:8080')
|
||||
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
|
||||
});
|
||||
|
||||
it('replaces the legacy local endpoint that depends on published port 8000', function () {
|
||||
$this->server->update(['ip' => 'host.docker.internal']);
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update([
|
||||
'sentinel_custom_url' => 'http://host.docker.internal:8000',
|
||||
]);
|
||||
|
||||
$url = $this->server->settings->fresh()->ensureSentinelUrl();
|
||||
|
||||
expect($url)->toBe('http://coolify:8080')
|
||||
->and($this->server->settings->fresh()->sentinel_custom_url)->toBe($url);
|
||||
});
|
||||
|
||||
it('preserves an explicit custom URL for the local server', function () {
|
||||
$this->server->update(['ip' => 'host.docker.internal']);
|
||||
DB::table('server_settings')->where('id', $this->server->settings->id)->update([
|
||||
'sentinel_custom_url' => 'https://coolify.example.com',
|
||||
]);
|
||||
|
||||
$url = $this->server->settings->fresh()->ensureSentinelUrl();
|
||||
|
||||
expect($url)->toBe('https://coolify.example.com');
|
||||
});
|
||||
|
||||
it('uses the current private instance URL when no public address is configured', function () {
|
||||
InstanceSettings::query()->whereKey(0)->update([
|
||||
'fqdn' => null,
|
||||
|
||||
@@ -38,6 +38,8 @@ it('uses the branded input focus state for the server filter', function () {
|
||||
expect($navbarView)
|
||||
->toContain('placeholder="Filter servers…"')
|
||||
->toContain('class="input h-7!')
|
||||
->toContain('M8 9l4-4 4 4M8 15l4 4 4-4')
|
||||
->not->toContain('<x-reicon name="chevron-down" class="size-3 shrink-0 text-neutral-400 dark:text-fg-faint" />')
|
||||
->toContain('<x-reicon name="check-circle"')
|
||||
->not->toContain('<x-reicon name="check"');
|
||||
});
|
||||
|
||||
@@ -82,14 +82,22 @@ it('places mobile status badges on a separate row below the server title', funct
|
||||
->and($titlePos)->toBeLessThan($badgesRowPos);
|
||||
});
|
||||
|
||||
it('listens for sentinel restarted broadcasts', function () {
|
||||
it('listens for sentinel status broadcasts', function () {
|
||||
[$server, , $team] = makeNavbarServer(isFunctional: true);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertSet('server.uuid', $server->uuid);
|
||||
|
||||
expect(app(Navbar::class)->getListeners())
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelRestarted", 'refreshSentinelStatus');
|
||||
->toHaveKey('sentinel-restart-requested', 'hideSentinelWarning')
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelRestarted", 'refreshSentinelStatus')
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelSynchronized", 'refreshSentinelStatus');
|
||||
});
|
||||
|
||||
it('polls heartbeat state so the sidebar deadline stays current', function () {
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($navbar)->toContain('wire:poll.30s="refreshAgentStatus"');
|
||||
});
|
||||
|
||||
it('refreshes sentinel status when sentinel restarts for the server', function () {
|
||||
@@ -107,5 +115,6 @@ it('refreshes sentinel status when sentinel restarts for the server', function (
|
||||
$component
|
||||
->call('refreshSentinelStatus', ['serverUuid' => $server->uuid])
|
||||
->assertSee('In sync')
|
||||
->assertDontSee('Out of sync');
|
||||
->assertDontSee('Out of sync')
|
||||
->assertDispatched('sentinel-status-changed');
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ it('shows a warning icon when sentinel is enabled but not working', function ()
|
||||
$contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain("'warning' => \$server->isSentinelEnabled() && ! \$server->isSentinelLive()");
|
||||
->toContain("'warning' => \$server->isSentinelEnabled() && \$sentinelStatus === 'out_of_sync'");
|
||||
});
|
||||
|
||||
it('uses the network reicon for proxy in the server sidebar', function () {
|
||||
@@ -62,6 +62,10 @@ it('shows a warning icon when a server menu item requires attention', function (
|
||||
->toContain('proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),')
|
||||
->toContain('traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo())')
|
||||
->toContain('@proxy-configuration-state-changed.window')
|
||||
->toContain('@sentinel-status-changed.window')
|
||||
->toContain('sentinelOutOfSync = $event.detail.outOfSync')
|
||||
->toContain('scheduleSentinelExpiry($event.detail.expiresInMilliseconds)')
|
||||
->toContain('setTimeout(() => this.sentinelOutOfSync = true, delay)')
|
||||
->toContain("\$menuItem['warning'] ?? false")
|
||||
->toContain('name="alert-triangle"');
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
|
||||
->toContain('ARG COOLIFY_VERSION')
|
||||
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
|
||||
->and($constants)
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.21'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.21')
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.22'")
|
||||
->and($versions['coolify']['v4']['version'])->toBe('4.3.22')
|
||||
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
|
||||
->and($nightlyVersions)->toBe($versions);
|
||||
});
|
||||
|
||||
@@ -21,3 +21,13 @@ test('sentinel startup allows legacy metrics migrations to finish before health
|
||||
|
||||
expect($action)->toContain('--health-start-period 120s');
|
||||
});
|
||||
|
||||
test('local sentinel joins the Coolify network and waits for an authenticated push before becoming live', function () {
|
||||
$action = file_get_contents(dirname(__DIR__, 2).'/app/Actions/Server/StartSentinel.php');
|
||||
|
||||
expect($action)
|
||||
->toContain('--network coolify')
|
||||
->toContain('sentinelHeartbeat(isReset: true)')
|
||||
->toContain("sentinel_waiting_since' => now()")
|
||||
->not->toContain('$server->sentinelHeartbeat();');
|
||||
});
|
||||
|
||||
@@ -116,6 +116,11 @@ it('saves application name description and ports from the general form', functio
|
||||
submitLivewireForm($page);
|
||||
|
||||
$page->assertValue('name', $updatedName)
|
||||
->assertScript(<<<'JS'
|
||||
() => [...document.querySelectorAll('#internal-access-section label')]
|
||||
.find((label) => label.textContent.trim() === 'Exposed ports')
|
||||
?.parentElement.querySelector('input')?.value === '8080'
|
||||
JS)
|
||||
->screenshot(filename: 'application-general-after-save');
|
||||
|
||||
$this->application->refresh();
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.21"
|
||||
"version": "4.3.22"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
||||
Reference in New Issue
Block a user