diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php
index 54034ef7a..d01b55afb 100644
--- a/app/Livewire/ActivityMonitor.php
+++ b/app/Livewire/ActivityMonitor.php
@@ -28,12 +28,20 @@ class ActivityMonitor extends Component
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
- public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null)
+ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{
+ // Reset event dispatched flag for new activity
+ self::$eventDispatched = false;
+
$this->activityId = $activityId;
$this->eventToDispatch = $eventToDispatch;
$this->eventData = $eventData;
+ // Update header if provided
+ if ($header !== null) {
+ $this->header = $header;
+ }
+
$this->hydrateActivity();
$this->isPollingActive = true;
diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php
index 25a2fd694..ab1a1aae9 100644
--- a/app/Livewire/Boarding/Index.php
+++ b/app/Livewire/Boarding/Index.php
@@ -14,7 +14,10 @@ use Visus\Cuid2\Cuid2;
class Index extends Component
{
- protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
+ protected $listeners = [
+ 'refreshBoardingIndex' => 'validateServer',
+ 'prerequisitesInstalled' => 'handlePrerequisitesInstalled',
+ ];
#[\Livewire\Attributes\Url(as: 'step', history: true)]
public string $currentState = 'welcome';
@@ -76,6 +79,10 @@ class Index extends Component
public ?string $minDockerVersion = null;
+ public int $prerequisiteInstallAttempts = 0;
+
+ public int $maxPrerequisiteInstallAttempts = 3;
+
public function mount()
{
if (auth()->user()?->isMember() && auth()->user()->currentTeam()->show_boarding === true) {
@@ -324,18 +331,58 @@ class Index extends Component
// Check prerequisites
$validationResult = $this->createdServer->validatePrerequisites();
if (! $validationResult['success']) {
- $this->createdServer->installPrerequisites();
- // Recheck after installation
- $validationResult = $this->createdServer->validatePrerequisites();
- if (! $validationResult['success']) {
+ // Check if we've exceeded max attempts
+ if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
$missingCommands = implode(', ', $validationResult['missing']);
- throw new \Exception("Prerequisites ({$missingCommands}) could not be installed. Please install them manually.");
+ throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
}
+
+ // Start async installation and wait for completion via ActivityMonitor
+ $activity = $this->createdServer->installPrerequisites();
+ $this->prerequisiteInstallAttempts++;
+ $this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
+
+ // Return early - handlePrerequisitesInstalled() will be called when installation completes
+ return;
}
+
+ // Prerequisites are already installed, continue with validation
+ $this->continueValidation();
} catch (\Throwable $e) {
return handleError(error: $e, livewire: $this);
}
+ }
+ public function handlePrerequisitesInstalled()
+ {
+ try {
+ // Revalidate prerequisites after installation completes
+ $validationResult = $this->createdServer->validatePrerequisites();
+ if (! $validationResult['success']) {
+ // Installation completed but prerequisites still missing - retry
+ $missingCommands = implode(', ', $validationResult['missing']);
+
+ if ($this->prerequisiteInstallAttempts >= $this->maxPrerequisiteInstallAttempts) {
+ throw new \Exception("Prerequisites ({$missingCommands}) could not be installed after {$this->maxPrerequisiteInstallAttempts} attempts. Please install them manually.");
+ }
+
+ // Try again
+ $activity = $this->createdServer->installPrerequisites();
+ $this->prerequisiteInstallAttempts++;
+ $this->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
+
+ return;
+ }
+
+ // Prerequisites validated successfully - continue with Docker validation
+ $this->continueValidation();
+ } catch (\Throwable $e) {
+ return handleError(error: $e, livewire: $this);
+ }
+ }
+
+ private function continueValidation()
+ {
try {
$dockerVersion = instant_remote_process(["docker version|head -2|grep -i version| awk '{print $2}'"], $this->createdServer, true);
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index d2e45ded2..c2dcd877b 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -132,7 +132,7 @@ class ValidateAndInstall extends Component
$this->installationStep = 'Prerequisites';
$activity = $this->server->installPrerequisites();
$this->number_of_tries++;
- $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
+ $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
return;
@@ -168,7 +168,7 @@ class ValidateAndInstall extends Component
$this->installationStep = 'Docker';
$activity = $this->server->installDocker();
$this->number_of_tries++;
- $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries);
+ $this->dispatch('activityMonitor', $activity->id, 'init', $this->number_of_tries, "{$this->installationStep} Installation Logs");
}
return;
diff --git a/resources/views/livewire/activity-monitor.blade.php b/resources/views/livewire/activity-monitor.blade.php
index 83f1ee5e3..386d8622d 100644
--- a/resources/views/livewire/activity-monitor.blade.php
+++ b/resources/views/livewire/activity-monitor.blade.php
@@ -5,7 +5,7 @@
])>
@if ($activity)
@if (isset($header))
-
+
{{ $header }}
@if ($isPollingActive)
diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php
index 81873c892..ec344e552 100644
--- a/resources/views/livewire/boarding/index.blade.php
+++ b/resources/views/livewire/boarding/index.blade.php
@@ -546,6 +546,13 @@
+ @if ($prerequisiteInstallAttempts > 0)
+
+
Installing Prerequisites
+
+
+ @endif
+
Server Validation
diff --git a/tests/Unit/Livewire/BoardingPrerequisitesTest.php b/tests/Unit/Livewire/BoardingPrerequisitesTest.php
new file mode 100644
index 000000000..180a274d2
--- /dev/null
+++ b/tests/Unit/Livewire/BoardingPrerequisitesTest.php
@@ -0,0 +1,181 @@
+makePartial();
+ $server->shouldReceive('validatePrerequisites')
+ ->andReturn([
+ 'success' => false,
+ 'missing' => ['git'],
+ 'found' => ['curl', 'jq'],
+ ]);
+
+ $activity = Mockery::mock(Activity::class);
+ $activity->id = 'test-activity-123';
+ $server->shouldReceive('installPrerequisites')
+ ->once()
+ ->andReturn($activity);
+
+ $component = Mockery::mock(Index::class)->makePartial();
+ $component->createdServer = $server;
+ $component->prerequisiteInstallAttempts = 0;
+ $component->maxPrerequisiteInstallAttempts = 3;
+
+ // Key assertion: verify activityMonitor event is dispatched with correct params
+ $component->shouldReceive('dispatch')
+ ->once()
+ ->with('activityMonitor', 'test-activity-123', 'prerequisitesInstalled')
+ ->andReturnSelf();
+
+ // Invoke the prerequisite check logic (simulating what validateServer does)
+ $validationResult = $component->createdServer->validatePrerequisites();
+ if (! $validationResult['success']) {
+ if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
+ throw new Exception('Max attempts exceeded');
+ }
+ $activity = $component->createdServer->installPrerequisites();
+ $component->prerequisiteInstallAttempts++;
+ $component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
+ }
+
+ expect($component->prerequisiteInstallAttempts)->toBe(1);
+});
+
+it('does not retry when prerequisites install successfully', function () {
+ // This test verifies the callback behavior when installation succeeds.
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('validatePrerequisites')
+ ->andReturn([
+ 'success' => true,
+ 'missing' => [],
+ 'found' => ['git', 'curl', 'jq'],
+ ]);
+
+ // installPrerequisites should NOT be called again
+ $server->shouldNotReceive('installPrerequisites');
+
+ $component = Mockery::mock(Index::class)->makePartial();
+ $component->createdServer = $server;
+ $component->prerequisiteInstallAttempts = 1;
+ $component->maxPrerequisiteInstallAttempts = 3;
+
+ // Simulate the callback logic
+ $validationResult = $component->createdServer->validatePrerequisites();
+ if ($validationResult['success']) {
+ // Prerequisites are now valid, we'd call continueValidation()
+ // For the test, just verify we don't try to install again
+ expect($validationResult['success'])->toBeTrue();
+ }
+});
+
+it('retries when prerequisites still missing after callback', function () {
+ // This test verifies retry logic in the callback.
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('validatePrerequisites')
+ ->andReturn([
+ 'success' => false,
+ 'missing' => ['git'],
+ 'found' => ['curl', 'jq'],
+ ]);
+
+ $activity = Mockery::mock(Activity::class);
+ $activity->id = 'retry-activity-456';
+ $server->shouldReceive('installPrerequisites')
+ ->once()
+ ->andReturn($activity);
+
+ $component = Mockery::mock(Index::class)->makePartial();
+ $component->createdServer = $server;
+ $component->prerequisiteInstallAttempts = 1; // Already tried once
+ $component->maxPrerequisiteInstallAttempts = 3;
+
+ $component->shouldReceive('dispatch')
+ ->once()
+ ->with('activityMonitor', 'retry-activity-456', 'prerequisitesInstalled')
+ ->andReturnSelf();
+
+ // Simulate callback logic
+ $validationResult = $component->createdServer->validatePrerequisites();
+ if (! $validationResult['success']) {
+ if ($component->prerequisiteInstallAttempts < $component->maxPrerequisiteInstallAttempts) {
+ $activity = $component->createdServer->installPrerequisites();
+ $component->prerequisiteInstallAttempts++;
+ $component->dispatch('activityMonitor', $activity->id, 'prerequisitesInstalled');
+ }
+ }
+
+ expect($component->prerequisiteInstallAttempts)->toBe(2);
+});
+
+it('throws exception when max attempts exceeded', function () {
+ // This test verifies that we stop retrying after max attempts.
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('validatePrerequisites')
+ ->andReturn([
+ 'success' => false,
+ 'missing' => ['git', 'curl'],
+ 'found' => ['jq'],
+ ]);
+
+ // installPrerequisites should NOT be called when at max attempts
+ $server->shouldNotReceive('installPrerequisites');
+
+ $component = Mockery::mock(Index::class)->makePartial();
+ $component->createdServer = $server;
+ $component->prerequisiteInstallAttempts = 3; // Already at max
+ $component->maxPrerequisiteInstallAttempts = 3;
+
+ // Simulate callback logic - should throw exception
+ $validationResult = $component->createdServer->validatePrerequisites();
+ if (! $validationResult['success']) {
+ if ($component->prerequisiteInstallAttempts >= $component->maxPrerequisiteInstallAttempts) {
+ $missingCommands = implode(', ', $validationResult['missing']);
+ throw new Exception("Prerequisites ({$missingCommands}) could not be installed after {$component->maxPrerequisiteInstallAttempts} attempts.");
+ }
+ }
+})->throws(Exception::class, 'Prerequisites (git, curl) could not be installed after 3 attempts');
+
+it('does not install when prerequisites already present', function () {
+ // This test verifies we skip installation when everything is already installed.
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('validatePrerequisites')
+ ->andReturn([
+ 'success' => true,
+ 'missing' => [],
+ 'found' => ['git', 'curl', 'jq'],
+ ]);
+
+ // installPrerequisites should NOT be called
+ $server->shouldNotReceive('installPrerequisites');
+
+ $component = Mockery::mock(Index::class)->makePartial();
+ $component->createdServer = $server;
+ $component->prerequisiteInstallAttempts = 0;
+ $component->maxPrerequisiteInstallAttempts = 3;
+
+ // Simulate validation logic
+ $validationResult = $component->createdServer->validatePrerequisites();
+ if (! $validationResult['success']) {
+ // Should not reach here
+ $component->prerequisiteInstallAttempts++;
+ }
+
+ // Attempts should remain 0
+ expect($component->prerequisiteInstallAttempts)->toBe(0);
+ expect($validationResult['success'])->toBeTrue();
+});