Merge branch 'next' into feat/servicedatabase-restore

This commit is contained in:
Andras Bacsai
2026-01-02 13:45:15 +01:00
290 changed files with 18736 additions and 3492 deletions

View File

@@ -79,8 +79,10 @@ class ActivityMonitor extends Component
$causer_id = data_get($this->activity, 'causer_id');
$user = User::find($causer_id);
if ($user) {
$teamId = $user->currentTeam()->id;
if (! self::$eventDispatched) {
$teamId = data_get($this->activity, 'properties.team_id')
?? $user->currentTeam()?->id
?? $user->teams->first()?->id;
if ($teamId && ! self::$eventDispatched) {
if (filled($this->eventData)) {
$this->eventToDispatch::dispatch($teamId, $this->eventData);
} else {

View File

@@ -38,6 +38,12 @@ class DeploymentsIndicator extends Component
return $this->deployments->count();
}
#[Computed]
public function shouldReduceOpacity(): bool
{
return request()->routeIs('project.application.deployment.*');
}
public function toggleExpanded()
{
$this->expanded = ! $this->expanded;

View File

@@ -95,7 +95,7 @@ class Docker extends Component
]);
}
}
$this->redirect(route('destination.show', $docker->uuid));
redirectRoute($this, 'destination.show', [$docker->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -1314,15 +1314,10 @@ class GlobalSearch extends Component
'server_id' => $this->selectedServerId,
];
// PostgreSQL requires a database_image parameter
if ($this->selectedResourceType === 'postgresql') {
$queryParams['database_image'] = 'postgres:16-alpine';
}
$this->redirect(route('project.resource.create', [
redirectRoute($this, 'project.resource.create', [
'project_uuid' => $this->selectedProjectUuid,
'environment_uuid' => $this->selectedEnvironmentUuid,
] + $queryParams));
] + $queryParams);
}
}
@@ -1341,6 +1336,42 @@ class GlobalSearch extends Component
$this->autoOpenResource = null;
}
public function goBack()
{
// From Environment Selection → go back to Project (if multiple) or further
if ($this->selectedProjectUuid !== null) {
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableProjects) > 1) {
return; // Stop here - user can choose a project
}
}
// From Project Selection → go back to Destination (if multiple) or further
if ($this->selectedDestinationUuid !== null) {
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableDestinations) > 1) {
return; // Stop here - user can choose a destination
}
}
// From Destination Selection → go back to Server (if multiple) or cancel
if ($this->selectedServerId !== null) {
$this->selectedServerId = null;
$this->selectedDestinationUuid = null;
$this->selectedProjectUuid = null;
$this->selectedEnvironmentUuid = null;
if (count($this->availableServers) > 1) {
return; // Stop here - user can choose a server
}
}
// All previous steps were auto-selected, cancel entirely
$this->cancelResourceSelection();
}
public function getFilteredCreatableItemsProperty()
{
$query = strtolower(trim($this->searchQuery));

View File

@@ -2,10 +2,8 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@@ -19,12 +17,8 @@ class NavbarDeleteTeam extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$currentTeam = currentTeam();
@@ -43,7 +37,7 @@ class NavbarDeleteTeam extends Component
refreshSession();
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
}
public function render()

View File

@@ -20,12 +20,13 @@ class Show extends Component
public bool $is_debug_enabled = false;
public bool $fullscreen = false;
private bool $deploymentFinishedDispatched = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
@@ -91,24 +92,52 @@ class Show extends Component
public function polling()
{
$this->dispatch('deploymentFinished');
$this->application_deployment_queue->refresh();
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->isKeepAliveOn();
// Dispatch event when deployment finishes to stop auto-scroll (only once)
if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
$this->deploymentFinishedDispatched = true;
$this->dispatch('deploymentFinished');
}
}
public function getLogLinesProperty()
{
return decode_remote_command_output($this->application_deployment_queue)->map(function ($logLine) {
$logLine['line'] = e($logLine['line']);
$logLine['line'] = preg_replace(
'/(https?:\/\/[^\s]+)/',
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-neutral-400">$1</a>',
$logLine['line'],
);
return decode_remote_command_output($this->application_deployment_queue);
}
return $logLine;
});
public function copyLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue)
->map(function ($line) {
return $line['timestamp'].' '.
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
->map(function ($line) {
$prefix = '';
if ($line['hidden']) {
$prefix = '[DEBUG] ';
}
if (isset($line['command']) && $line['command']) {
$prefix .= '[CMD]: ';
}
return $line['timestamp'].' '.$prefix.trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function render()

View File

@@ -558,8 +558,11 @@ class General extends Component
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
$this->application->docker_compose_location = $this->initialDockerComposeLocation;
$this->application->save();
// Refresh model to get restored values from Application::loadComposeFile
$this->application->refresh();
// Sync restored values back to component properties for UI update
$this->syncData();
return handleError($e, $this);
} finally {
@@ -936,73 +939,6 @@ class General extends Component
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
$domain = data_get($service, 'domain');
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
$this->application->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$urlValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
$fqdnValue = str($domain)->after('://');
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
// Create/update SERVICE_FQDN_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if (filled($port)) {
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_preview' => false,
]);
$this->application->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_preview' => false,
]);
}
}
}
}
public function getDetectedPortInfoProperty(): ?array
{
$detectedPort = $this->application->detectPortFromEnvironment();

View File

@@ -100,19 +100,17 @@ class Heading extends Component
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on successful deployment
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -151,19 +149,17 @@ class Heading extends Component
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
// Reset restart count on manual restart
$this->application->update([
'restart_count' => 0,
'last_restart_at' => now(),
'last_restart_type' => 'manual',
]);
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],

View File

@@ -249,6 +249,11 @@ class Previews extends Component
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);

View File

@@ -52,7 +52,7 @@ class Rollback extends Component
$deployment_uuid = new Cuid2;
queue_application_deployment(
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $deployment_uuid,
commit: $commit,
@@ -60,7 +60,13 @@ class Rollback extends Component
force_rebuild: false,
);
return redirect()->route('project.application.deployment.show', [
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $deployment_uuid,

View File

@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -154,12 +151,8 @@ class BackupEdit extends Component
{
$this->authorize('manageBackups', $this->backup->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View File

@@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class BackupExecutions extends Component
@@ -69,12 +67,8 @@ class BackupExecutions extends Component
public function deleteBackup($executionId, $password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$execution = $this->backup->executions()->where('id', $executionId)->first();

View File

@@ -39,7 +39,7 @@ class DeleteEnvironment extends Component
if ($environment->isEmpty()) {
$environment->delete();
return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]);
return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]);
}
return $this->dispatch('error', "<strong>Environment {$environment->name}</strong> has defined resources, please delete them first.");

View File

@@ -35,7 +35,7 @@ class DeleteProject extends Component
if ($project->isEmpty()) {
$project->delete();
return redirect()->route('project.index');
return redirectRoute($this, 'project.index');
}
return $this->dispatch('error', "<strong>Project {$project->name}</strong> has resources defined, please delete them first.");

View File

@@ -63,7 +63,7 @@ class EnvironmentEdit extends Component
{
try {
$this->syncData(true);
$this->redirectRoute('project.environment.edit', [
redirectRoute($this, 'project.environment.edit', [
'environment_uuid' => $this->environment->uuid,
'project_uuid' => $this->project->uuid,
]);

View File

@@ -154,7 +154,7 @@ class DockerImage extends Component
'fqdn' => $fqdn,
]);
return redirect()->route('project.application.configuration', [
return redirectRoute($this, 'project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,

View File

@@ -16,6 +16,6 @@ class EmptyProject extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]);
}
}

View File

@@ -128,6 +128,7 @@ class GithubPrivateRepository extends Component
$this->loadBranchByPage();
}
}
$this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
}

View File

@@ -53,6 +53,8 @@ class Select extends Component
protected $queryString = [
'server_id',
'type' => ['except' => ''],
'destination_uuid' => ['except' => '', 'as' => 'destination'],
];
public function mount()
@@ -66,6 +68,20 @@ class Select extends Component
$project = Project::whereUuid($projectUuid)->firstOrFail();
$this->environments = $project->environments;
$this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name;
// Check if we have all required params for PostgreSQL type selection
// This handles navigation from global search
$queryType = request()->query('type');
$queryServerId = request()->query('server_id');
$queryDestination = request()->query('destination');
if ($queryType === 'postgresql' && $queryServerId !== null && $queryDestination) {
$this->type = $queryType;
$this->server_id = $queryServerId;
$this->destination_uuid = $queryDestination;
$this->server = Server::find($queryServerId);
$this->current_step = 'select-postgresql-type';
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View File

@@ -35,6 +35,13 @@ class Create extends Component
if (in_array($type, DATABASE_TYPES)) {
if ($type->value() === 'postgresql') {
// PostgreSQL requires database_image to be explicitly set
// If not provided, fall through to Select component for version selection
if (! $database_image) {
$this->type = $type->value();
return;
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
destinationUuid: $destination_uuid,

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Project\Resource;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Component;
class EnvironmentSelect extends Component
{
public Collection $environments;
public string $project_uuid = '';
public string $selectedEnvironment = '';
public function mount()
{
$this->selectedEnvironment = request()->route('environment_uuid');
$this->project_uuid = request()->route('project_uuid');
}
public function updatedSelectedEnvironment($value)
{
if ($value === 'edit') {
return redirect()->route('project.show', [
'project_uuid' => $this->project_uuid,
]);
} else {
return redirect()->route('project.resource.index', [
'project_uuid' => $this->project_uuid,
'environment_uuid' => $value,
]);
}
}
}

View File

@@ -4,12 +4,9 @@ namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\InstanceSettings;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Database extends Component
@@ -96,18 +93,14 @@ class Database extends Component
try {
$this->authorize('delete', $this->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();
$this->dispatch('success', 'Database deleted.');
return redirect()->route('project.service.configuration', $this->parameters);
return redirectRoute($this, 'project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -171,7 +164,7 @@ class Database extends Component
$serviceDatabase->delete();
});
return redirect()->route('project.service.configuration', $redirectParams);
return redirectRoute($this, 'project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -3,7 +3,6 @@
namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -16,8 +15,6 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -62,7 +59,7 @@ class FileStorage extends Component
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@@ -104,7 +101,8 @@ class FileStorage extends Component
public function loadStorageOnServer()
{
try {
$this->authorize('update', $this->resource);
// Loading content is a read operation, so we use 'view' permission
$this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@@ -140,12 +138,8 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View File

@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Service;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
@@ -128,12 +125,8 @@ class ServiceApplicationView extends Component
try {
$this->authorize('delete', $this->application);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();

View File

@@ -67,7 +67,7 @@ class Storage extends Component
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->resource->refresh();
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()

View File

@@ -3,13 +3,10 @@
namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob;
use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -93,12 +90,8 @@ class Danger extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! $this->resource) {
@@ -118,7 +111,7 @@ class Danger extends Component
$this->docker_cleanup
);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);

View File

@@ -5,12 +5,9 @@ namespace App\Livewire\Project\Shared;
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -89,13 +86,18 @@ class Destination extends Component
only_this_server: true,
no_questions_asked: true,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
return;
}
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
return redirect()->route('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => data_get($this->resource, 'environment.project.uuid'),
'application_uuid' => data_get($this->resource, 'uuid'),
'deployment_uuid' => $deployment_uuid,
@@ -135,12 +137,8 @@ class Destination extends Component
public function removeServer(int $network_id, int $server_id, $password)
{
try {
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {

View File

@@ -21,6 +21,10 @@ use Livewire\Component;
class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
public string $errors = '';
@@ -67,11 +71,6 @@ class GetLogs extends Component
}
}
public function doSomethingWithThisChunkOfOutput($output)
{
$this->outputs .= removeAnsiColors($output);
}
public function instantSave()
{
if (! is_null($this->resource)) {
@@ -128,6 +127,9 @@ class GetLogs extends Component
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
$this->numberOfLines = 1000;
}
if ($this->numberOfLines > self::MAX_LOG_LINES) {
$this->numberOfLines = self::MAX_LOG_LINES;
}
if ($this->container) {
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
@@ -162,23 +164,107 @@ class GetLogs extends Component
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
}
if ($refresh) {
$this->outputs = '';
}
Process::run($sshCommand, function (string $type, string $output) {
$this->doSomethingWithThisChunkOfOutput($output);
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$logChunks[] = removeAnsiColors($output);
});
$newOutputs = implode('', $logChunks);
if ($this->showTimeStamps) {
$this->outputs = str($this->outputs)->split('/\n/')->sort(function ($a, $b) {
$newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
}
}
public function copyLogs(): string
{
return sanitizeLogsForExport($this->outputs);
}
public function downloadAllLogs(): string
{
if (! $this->server->isFunctional() || ! $this->container) {
return '';
}
if ($this->showTimeStamps) {
if ($this->server->isSwarm()) {
$command = "docker service logs -t {$this->container}";
} else {
$command = "docker logs -t {$this->container}";
}
} else {
if ($this->server->isSwarm()) {
$command = "docker service logs {$this->container}";
} else {
$command = "docker logs {$this->container}";
}
}
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
// Enforce 50MB size limit to prevent memory exhaustion from large logs
$logChunks = [];
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
}
$truncated = true;
return;
}
$logChunks[] = $output;
$accumulatedBytes += $outputBytes;
});
$allLogs = implode('', $logChunks);
if ($truncated) {
$allLogs .= "\n\n[... Output truncated at 50MB limit ...]";
}
if ($this->showTimeStamps) {
$allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) {
$a = explode(' ', $a);
$b = explode(' ', $b);
return $a[0] <=> $b[0];
})->join("\n");
}
return sanitizeLogsForExport($allLogs);
}
public function render()
{
return view('livewire.project.shared.get-logs');

View File

@@ -2,11 +2,8 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Show extends Component
@@ -67,7 +64,7 @@ class Show extends Component
public function mount()
{
$this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
@@ -84,12 +81,8 @@ class Show extends Component
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->storage->delete();

View File

@@ -57,7 +57,14 @@ class Terminal extends Component
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
// Add sudo for non-root users to access Docker socket
$dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'";
if ($server->isNonRoot()) {
$dockerCommand = "sudo {$dockerCommand}";
}
$command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.

View File

@@ -48,7 +48,7 @@ class Show extends Component
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]);
@@ -59,7 +59,7 @@ class Show extends Component
public function navigateToEnvironment($projectUuid, $environmentUuid)
{
return redirect()->route('project.resource.index', [
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $projectUuid,
'environment_uuid' => $environmentUuid,
]);

View File

@@ -114,7 +114,7 @@ class Create extends Component
private function redirectAfterCreation(PrivateKey $privateKey)
{
return $this->from === 'server'
? redirect()->route('dashboard')
: redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
? redirectRoute($this, 'dashboard')
: redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
}
}

View File

@@ -107,7 +107,7 @@ class Show extends Component
$this->private_key->safeDelete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirect()->route('security.private-key.index');
return redirectRoute($this, 'security.private-key.index');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
} catch (\Throwable $e) {

View File

@@ -24,6 +24,9 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1;
#[Validate(['integer', 'min:1'])]
public int $deploymentQueueLimit = 25;
public function mount(string $server_uuid)
{
try {
@@ -43,12 +46,14 @@ class Advanced extends Component
$this->validate();
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
$this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit;
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
$this->server->settings->save();
} else {
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
}

View File

@@ -3,11 +3,8 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Delete extends Component
@@ -29,12 +26,8 @@ class Delete extends Component
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
$this->authorize('delete', $this->server);
@@ -53,7 +46,7 @@ class Delete extends Component
$this->server->team_id
);
return redirect()->route('server.index');
return redirectRoute($this, 'server.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Server;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
@@ -29,8 +30,7 @@ class Destinations extends Component
private function createNetworkAndAttachToProxy()
{
$connectProxyToDockerNetworks = connectProxyToNetworks($this->server);
instant_remote_process($connectProxyToDockerNetworks, $this->server, false);
ConnectProxyToNetworksJob::dispatchSync($this->server);
}
public function add($name)

View File

@@ -567,10 +567,10 @@ class ByHetzner extends Component
]);
refreshSession();
return $this->redirect(route('server.show', $server->uuid));
return redirectRoute($this, 'server.show', [$server->uuid]);
}
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -128,7 +128,7 @@ class ByIp extends Component
$server->settings->is_build_server = $this->is_build_server;
$server->settings->save();
return redirect()->route('server.show', $server->uuid);
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -4,6 +4,7 @@ namespace App\Livewire\Server\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Rules\ValidProxyConfigFilename;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -38,11 +39,11 @@ class NewDynamicConfiguration extends Component
try {
$this->authorize('update', $this->server);
$this->validate([
'fileName' => 'required',
'fileName' => ['required', new ValidProxyConfigFilename],
'value' => 'required',
]);
// Validate filename to prevent command injection
// Additional security validation to prevent command injection
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {

View File

@@ -4,7 +4,6 @@ namespace App\Livewire\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class Resources extends Component
@@ -15,7 +14,7 @@ class Resources extends Component
public $parameters = [];
public Collection $containers;
public array $unmanagedContainers = [];
public $activeTab = 'managed';
@@ -64,7 +63,7 @@ class Resources extends Component
{
try {
$this->activeTab = 'managed';
$this->containers = $this->server->refresh()->definedResources();
$this->server->refresh();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -74,7 +73,7 @@ class Resources extends Component
{
$this->activeTab = 'unmanaged';
try {
$this->containers = $this->server->loadUnmanagedContainers();
$this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -82,14 +81,12 @@ class Resources extends Component
public function mount()
{
$this->containers = collect();
$this->parameters = get_route_parameters();
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first();
if (is_null($this->server)) {
return redirect()->route('server.index');
}
$this->loadManagedContainers();
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -2,11 +2,8 @@
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -44,13 +41,9 @@ class TerminalAccess extends Component
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
// Toggle the terminal setting

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Sentinel extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
public bool $isMetricsEnabled;
#[Validate(['required'])]
public string $sentinelToken;
public ?string $sentinelUpdatedAt = null;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsRefreshRateSeconds;
#[Validate(['required', 'integer', 'min:1'])]
public int $sentinelMetricsHistoryDays;
#[Validate(['required', 'integer', 'min:10'])]
public int $sentinelPushIntervalSeconds;
#[Validate(['nullable', 'url'])]
public ?string $sentinelCustomUrl = null;
public bool $isSentinelEnabled;
public bool $isSentinelDebugEnabled;
public ?string $sentinelCustomDockerImage = null;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
];
}
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->validate();
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
$this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled;
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
$this->server->settings->save();
} else {
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = $this->server->settings->sentinel_token;
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
$this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled;
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
}
}
public function handleSentinelRestarted($event)
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
$this->syncData();
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
public function restartSentinel()
{
try {
$this->authorize('manageSentinel', $this->server);
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
$this->server->restartSentinel($customImage);
$this->dispatch('info', 'Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function updatedIsSentinelEnabled($value)
{
try {
$this->authorize('manageSentinel', $this->server);
if ($value === true) {
if ($this->server->isBuildServer()) {
$this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
} else {
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server);
}
$this->submit();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function regenerateSentinelToken()
{
try {
$this->authorize('manageSentinel', $this->server);
$this->server->settings->generateSentinelToken();
$this->dispatch('success', 'Token regenerated. Restarting Sentinel.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->syncData(true);
$this->restartSentinel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.sentinel');
}
}

View File

@@ -5,9 +5,12 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Services\HetznerService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -73,6 +76,19 @@ class Show extends Component
public bool $isValidating = false;
// Hetzner linking properties
public Collection $availableHetznerTokens;
public ?int $selectedHetznerTokenId = null;
public ?string $manualHetznerServerId = null;
public ?array $matchedHetznerServer = null;
public ?string $hetznerSearchError = null;
public bool $hetznerNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@@ -150,6 +166,9 @@ class Show extends Component
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking
$this->loadHetznerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -430,6 +449,10 @@ class Show extends Component
// Update validation state
$this->isValidating = $this->server->is_validating ?? false;
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
}
@@ -465,6 +488,140 @@ class Show extends Component
}
}
public function loadHetznerTokens(): void
{
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function searchHetznerServer(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$matched = $hetznerService->findServerByIp($this->server->ip);
if ($matched) {
$this->matchedHetznerServer = $matched;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
}
}
public function searchHetznerServerById(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token first.';
return;
}
if (! $this->manualHetznerServerId) {
$this->hetznerSearchError = 'Please enter a Hetzner Server ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer((int) $this->manualHetznerServerId);
if (! empty($serverData)) {
$this->matchedHetznerServer = $serverData;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to fetch Hetzner server: '.$e->getMessage();
}
}
public function linkToHetzner()
{
if (! $this->matchedHetznerServer) {
$this->dispatch('error', 'No Hetzner server selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
// Verify the server exists and is accessible with the token
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
if (empty($serverData)) {
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
return;
}
// Update the server with Hetzner details
$this->server->update([
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
'hetzner_server_id' => $this->matchedHetznerServer['id'],
'hetzner_server_status' => $serverData['status'] ?? null,
]);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Clear the linking state
$this->matchedHetznerServer = null;
$this->selectedHetznerTokenId = null;
$this->manualHetznerServerId = null;
$this->hetznerNoMatchFound = false;
$this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.show');

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Swarm extends Component
{
use AuthorizesRequests;
public Server $server;
public array $parameters = [];
public bool $isSwarmManager;
public bool $isSwarmWorker;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->parameters = get_route_parameters();
$this->syncData();
} catch (\Throwable) {
return redirect()->route('server.index');
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->authorize('update', $this->server);
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
$this->server->settings->save();
} else {
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
}
}
public function instantSave()
{
try {
$this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.swarm');
}
}

View File

@@ -3,18 +3,12 @@
namespace App\Livewire\Settings;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
use Auth;
use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Advanced extends Component
{
#[Validate('required')]
public Server $server;
public InstanceSettings $settings;
#[Validate('boolean')]
@@ -40,10 +34,12 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
public function rules()
{
return [
'server' => 'required',
'is_registration_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
@@ -52,6 +48,7 @@ class Advanced extends Component
'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr],
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
];
}
@@ -60,7 +57,6 @@ class Advanced extends Component
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->server = Server::findOrFail(0);
$this->settings = instanceSettings();
$this->custom_dns_servers = $this->settings->custom_dns_servers;
$this->allowed_ips = $this->settings->allowed_ips;
@@ -70,6 +66,7 @@ class Advanced extends Component
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
}
public function submit()
@@ -148,6 +145,7 @@ class Advanced extends Component
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
@@ -157,9 +155,7 @@ class Advanced extends Component
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}

View File

@@ -12,7 +12,7 @@ class Index extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('nullable|string|max:255')]
public ?string $fqdn = null;
@@ -57,7 +57,9 @@ class Index extends Component
return redirect()->route('dashboard');
}
$this->settings = instanceSettings();
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->fqdn = $this->settings->fqdn;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
@@ -80,7 +82,7 @@ class Index extends Component
public function instantSave($isSave = true)
{
$this->validate();
$this->settings->fqdn = $this->fqdn;
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name;
@@ -119,9 +121,15 @@ class Index extends Component
return;
}
// Trim FQDN to remove leading/trailing whitespace before validation
if ($this->fqdn) {
$this->fqdn = trim($this->fqdn);
}
$this->validate();
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
if (! validateDNSEntry($this->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$error_show = true;
@@ -145,7 +153,9 @@ class Index extends Component
$this->instantSave(isSave: false);
$this->settings->save();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
if (! $error_show) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
@@ -163,6 +173,12 @@ class Index extends Component
return;
}
if (! $this->server) {
$this->dispatch('error', 'Server not available.');
return;
}
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');

View File

@@ -12,7 +12,7 @@ class Updates extends Component
{
public InstanceSettings $settings;
public Server $server;
public ?Server $server = null;
#[Validate('string')]
public string $auto_update_frequency;
@@ -25,7 +25,9 @@ class Updates extends Component
public function mount()
{
$this->server = Server::findOrFail(0);
if (! isCloud()) {
$this->server = Server::findOrFail(0);
}
$this->settings = instanceSettings();
$this->auto_update_frequency = $this->settings->auto_update_frequency;
@@ -76,7 +78,9 @@ class Updates extends Component
}
$this->instantSave();
$this->server->setupDynamicProxyConfiguration();
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
} catch (\Exception $e) {
return handleError($e, $this);
}

View File

@@ -58,7 +58,7 @@ class Create extends Component
session(['from' => session('from') + ['source_id' => $github_app->id]]);
}
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -116,7 +116,7 @@ class Create extends Component
$this->storage->testConnection();
$this->storage->save();
return redirect()->route('storage.show', $this->storage->uuid);
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// return handleError($e, $this);

View File

@@ -2,10 +2,7 @@
namespace App\Livewire\Team;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class AdminView extends Component
@@ -58,12 +55,8 @@ class AdminView extends Component
return redirect()->route('dashboard');
}
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! auth()->user()->isInstanceAdmin()) {

View File

@@ -37,7 +37,7 @@ class Create extends Component
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
return redirect()->route('team.index');
return redirectRoute($this, 'team.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View File

@@ -48,7 +48,7 @@ class InviteLink extends Component
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {

View File

@@ -71,11 +71,11 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam());
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::remember('team:'.$this->member->id, 3600, function () {
return $this->member->teams()->first();
});
Cache::forget("user:{$this->member->id}:team:{$teamId}");
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());

View File

@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use Livewire\Component;
class Upgrade extends Component
@@ -14,12 +15,23 @@ class Upgrade extends Component
public string $latestVersion = '';
public string $currentVersion = '';
public bool $devMode = false;
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()
{
$this->currentVersion = config('constants.coolify.version');
$this->devMode = isDev();
}
public function checkUpdate()
{
try {
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
if (isDev()) {
$this->isUpgradeAvailable = true;
@@ -41,4 +53,71 @@ class Upgrade extends Component
return handleError($e, $this);
}
}
public function getUpgradeStatus(): array
{
// Only root team members can view upgrade status
if (auth()->user()?->currentTeam()?->id !== 0) {
return ['status' => 'none'];
}
$server = Server::find(0);
if (! $server) {
return ['status' => 'none'];
}
$statusFile = '/data/coolify/source/.upgrade-status';
try {
$content = instant_remote_process(
["cat {$statusFile} 2>/dev/null || echo ''"],
$server,
false
);
$content = trim($content ?? '');
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if (empty($content)) {
return ['status' => 'none'];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none'];
}
[$step, $message, $timestamp] = $parts;
// Check if status is stale (older than 10 minutes)
try {
$statusTime = new \DateTime($timestamp);
$now = new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > 10) {
return ['status' => 'none'];
}
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
];
}
$stepInt = (int) $step;
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
];
}
}