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:
Andras Bacsai
2026-09-16 12:51:27 +02:00
parent e1bcf1fb86
commit 16b092336e
29 changed files with 770 additions and 54 deletions
+9 -2
View File
@@ -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);
+36
View File
@@ -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);
}
+17 -15
View File
@@ -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 {
+30
View File
@@ -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();
}
/**
+64
View File
@@ -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) {
+17
View File
@@ -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()
+21 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 -1
View File
@@ -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');
});
+5 -1
View File
@@ -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"');
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.3.21"
"version": "4.3.22"
},
"nightly": {
"version": "4.4-rc.1"