Merge pull request #3063 from peaklabs-dev/set-server-timzone-setting

Feat: Add server timezone dropdown
This commit is contained in:
Andras Bacsai
2024-08-26 15:27:20 +02:00
committed by GitHub
18 changed files with 727 additions and 285 deletions

View File

@@ -44,8 +44,8 @@ class Kernel extends ConsoleKernel
// Instance Jobs // Instance Jobs
$schedule->command('horizon:snapshot')->everyFiveMinutes(); $schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('cleanup:unreachable-servers')->daily()->onOneServer(); $schedule->command('cleanup:unreachable-servers')->daily()->onOneServer();
$schedule->job(new PullCoolifyImageJob)->cron($settings->update_check_frequency)->onOneServer(); $schedule->job(new PullCoolifyImageJob)->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer();
$schedule->job(new PullTemplatesFromCDN)->cron($settings->update_check_frequency)->onOneServer(); $schedule->job(new PullTemplatesFromCDN)->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer();
$schedule->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $schedule->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer();
$this->schedule_updates($schedule); $this->schedule_updates($schedule);
@@ -73,9 +73,12 @@ class Kernel extends ConsoleKernel
if ($status !== 'running') { if ($status !== 'running') {
PullSentinelImageJob::dispatch($server); PullSentinelImageJob::dispatch($server);
} }
})->cron($settings->update_check_frequency)->onOneServer(); })->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer();
} }
$schedule->job(new PullHelperImageJob($server))->cron($settings->update_check_frequency)->onOneServer(); $schedule->job(new PullHelperImageJob($server))
->cron($settings->update_check_frequency)
->timezone($settings->instance_timezone)
->onOneServer();
} }
} }
@@ -84,11 +87,17 @@ class Kernel extends ConsoleKernel
$settings = InstanceSettings::get(); $settings = InstanceSettings::get();
$updateCheckFrequency = $settings->update_check_frequency; $updateCheckFrequency = $settings->update_check_frequency;
$schedule->job(new CheckForUpdatesJob)->cron($updateCheckFrequency)->onOneServer(); $schedule->job(new CheckForUpdatesJob)
->cron($updateCheckFrequency)
->timezone($settings->instance_timezone)
->onOneServer();
if ($settings->is_auto_update_enabled) { if ($settings->is_auto_update_enabled) {
$autoUpdateFrequency = $settings->auto_update_frequency; $autoUpdateFrequency = $settings->auto_update_frequency;
$schedule->job(new UpdateCoolifyJob)->cron($autoUpdateFrequency)->onOneServer(); $schedule->job(new UpdateCoolifyJob)
->cron($autoUpdateFrequency)
->timezone($settings->instance_timezone)
->onOneServer();
} }
} }
@@ -103,15 +112,12 @@ class Kernel extends ConsoleKernel
} }
foreach ($servers as $server) { foreach ($servers as $server) {
$schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer(); $schedule->job(new ServerCheckJob($server))->everyMinute()->onOneServer();
//The lines below need to be added as soon as timzone is merged!! $serverTimezone = $server->settings->server_timezone;
//$serverTimezone = $server->settings->server_timezone;
//$schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer();
if ($server->settings->force_docker_cleanup) { if ($server->settings->force_docker_cleanup) {
$schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->onOneServer(); $schedule->job(new DockerCleanupJob($server))->cron($server->settings->docker_cleanup_frequency)->timezone($serverTimezone)->onOneServer();
} else { } else {
$schedule->job(new DockerCleanupJob($server))->everyTenMinutes()->onOneServer(); $schedule->job(new DockerCleanupJob($server))->everyTenMinutes()->timezone($serverTimezone)->onOneServer();
} }
} }
} }
@@ -128,16 +134,18 @@ class Kernel extends ConsoleKernel
if (is_null(data_get($scheduled_backup, 'database'))) { if (is_null(data_get($scheduled_backup, 'database'))) {
ray('database not found'); ray('database not found');
$scheduled_backup->delete(); $scheduled_backup->delete();
continue; continue;
} }
$server = $scheduled_backup->server();
$serverTimezone = $server->settings->server_timezone;
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) { if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency]; $scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
} }
$schedule->job(new DatabaseBackupJob( $schedule->job(new DatabaseBackupJob(
backup: $scheduled_backup backup: $scheduled_backup
))->cron($scheduled_backup->frequency)->onOneServer(); ))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
} }
} }
@@ -157,7 +165,6 @@ class Kernel extends ConsoleKernel
if (! $application && ! $service) { if (! $application && ! $service) {
ray('application/service attached to scheduled task does not exist'); ray('application/service attached to scheduled task does not exist');
$scheduled_task->delete(); $scheduled_task->delete();
continue; continue;
} }
if ($application) { if ($application) {
@@ -170,18 +177,22 @@ class Kernel extends ConsoleKernel
continue; continue;
} }
} }
$server = $scheduled_task->server();
$serverTimezone = $server->settings->server_timezone ?: config('app.timezone');
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) { if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency]; $scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
} }
$schedule->job(new ScheduledTaskJob( $schedule->job(new ScheduledTaskJob(
task: $scheduled_task task: $scheduled_task
))->cron($scheduled_task->frequency)->onOneServer(); ))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
} }
} }
protected function commands(): void protected function commands(): void
{ {
$this->load(__DIR__.'/Commands'); $this->load(__DIR__ . '/Commands');
require base_path('routes/console.php'); require base_path('routes/console.php');
} }

View File

@@ -36,6 +36,8 @@ class ScheduledTaskJob implements ShouldQueue
public array $containers = []; public array $containers = [];
public string $server_timezone;
public function __construct($task) public function __construct($task)
{ {
$this->task = $task; $this->task = $task;
@@ -47,6 +49,19 @@ class ScheduledTaskJob implements ShouldQueue
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.'); throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
} }
$this->team = Team::find($task->team_id); $this->team = Team::find($task->team_id);
$this->server_timezone = $this->getServerTimezone();
}
private function getServerTimezone(): string
{
if ($this->resource instanceof Application) {
$timezone = $this->resource->destination->server->settings->server_timezone;
return $timezone;
} elseif ($this->resource instanceof Service) {
$timezone = $this->resource->server->settings->server_timezone;
return $timezone;
}
return 'UTC';
} }
public function middleware(): array public function middleware(): array
@@ -61,6 +76,7 @@ class ScheduledTaskJob implements ShouldQueue
public function handle(): void public function handle(): void
{ {
try { try {
$this->task_log = ScheduledTaskExecution::create([ $this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id, 'scheduled_task_id' => $this->task->id,
@@ -78,12 +94,12 @@ class ScheduledTaskJob implements ShouldQueue
} elseif ($this->resource->type() == 'service') { } elseif ($this->resource->type() == 'service') {
$this->resource->applications()->get()->each(function ($application) { $this->resource->applications()->get()->each(function ($application) {
if (str(data_get($application, 'status'))->contains('running')) { if (str(data_get($application, 'status'))->contains('running')) {
$this->containers[] = data_get($application, 'name').'-'.data_get($this->resource, 'uuid'); $this->containers[] = data_get($application, 'name') . '-' . data_get($this->resource, 'uuid');
} }
}); });
$this->resource->databases()->get()->each(function ($database) { $this->resource->databases()->get()->each(function ($database) {
if (str(data_get($database, 'status'))->contains('running')) { if (str(data_get($database, 'status'))->contains('running')) {
$this->containers[] = data_get($database, 'name').'-'.data_get($this->resource, 'uuid'); $this->containers[] = data_get($database, 'name') . '-' . data_get($this->resource, 'uuid');
} }
}); });
} }
@@ -96,8 +112,8 @@ class ScheduledTaskJob implements ShouldQueue
} }
foreach ($this->containers as $containerName) { foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container . '-' . $this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $cmd = "sh -c '" . str_replace("'", "'\''", $this->task->command) . "'";
$exec = "docker exec {$containerName} {$cmd}"; $exec = "docker exec {$containerName} {$cmd}";
$this->task_output = instant_remote_process([$exec], $this->server, true); $this->task_output = instant_remote_process([$exec], $this->server, true);
$this->task_log->update([ $this->task_log->update([
@@ -121,6 +137,7 @@ class ScheduledTaskJob implements ShouldQueue
$this->team?->notify(new TaskFailed($this->task, $e->getMessage())); $this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
// send_internal_notification('ScheduledTaskJob failed with: ' . $e->getMessage()); // send_internal_notification('ScheduledTaskJob failed with: ' . $e->getMessage());
throw $e; throw $e;
} finally {
} }
} }
} }

View File

@@ -8,9 +8,8 @@ use Livewire\Component;
class BackupExecutions extends Component class BackupExecutions extends Component
{ {
public ?ScheduledDatabaseBackup $backup = null; public ?ScheduledDatabaseBackup $backup = null;
public $database;
public $executions = []; public $executions = [];
public $setDeletableBackup; public $setDeletableBackup;
public function getListeners() public function getListeners()
@@ -61,4 +60,50 @@ class BackupExecutions extends Component
$this->executions = $this->backup->executions()->get(); $this->executions = $this->backup->executions()->get();
} }
} }
public function mount(ScheduledDatabaseBackup $backup)
{
$this->backup = $backup;
$this->database = $backup->database;
$this->refreshBackupExecutions();
}
public function server()
{
if ($this->database) {
$server = null;
if ($this->database instanceof \App\Models\ServiceDatabase) {
$server = $this->database->service->destination->server;
} elseif ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;
}
if ($server) {
return $server;
}
}
return null;
}
public function getServerTimezone()
{
$server = $this->server();
if (!$server) {
return 'UTC';
}
$serverTimezone = $server->settings->server_timezone;
return $serverTimezone;
}
public function formatDateInServerTimezone($date)
{
$serverTimezone = $this->getServerTimezone();
$dateObj = new \DateTime($date);
try {
$dateObj->setTimezone(new \DateTimeZone($serverTimezone));
} catch (\Exception $e) {
$dateObj->setTimezone(new \DateTimeZone('UTC'));
}
return $dateObj->format('Y-m-d H:i:s T');
}
} }

View File

@@ -7,8 +7,8 @@ use Livewire\Component;
class Executions extends Component class Executions extends Component
{ {
public $executions = []; public $executions = [];
public $selectedKey; public $selectedKey;
public $task;
public function getListeners() public function getListeners()
{ {
@@ -26,4 +26,44 @@ class Executions extends Component
} }
$this->selectedKey = $key; $this->selectedKey = $key;
} }
public function server()
{
if (!$this->task) {
return null;
}
if ($this->task->application) {
if ($this->task->application->destination && $this->task->application->destination->server) {
return $this->task->application->destination->server;
}
} elseif ($this->task->service) {
if ($this->task->service->destination && $this->task->service->destination->server) {
return $this->task->service->destination->server;
}
}
return null;
}
public function getServerTimezone()
{
$server = $this->server();
if (!$server) {
return 'UTC';
}
$serverTimezone = $server->settings->server_timezone;
return $serverTimezone;
}
public function formatDateInServerTimezone($date)
{
$serverTimezone = $this->getServerTimezone();
$dateObj = new \DateTime($date);
try {
$dateObj->setTimezone(new \DateTimeZone($serverTimezone));
} catch (\Exception $e) {
$dateObj->setTimezone(new \DateTimeZone('UTC'));
}
return $dateObj->format('Y-m-d H:i:s T');
}
} }

View File

@@ -5,7 +5,8 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartSentinel; use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel; use App\Actions\Server\StopSentinel;
use App\Jobs\PullSentinelImageJob; use App\Jobs\PullSentinelImageJob;
use App\Models\Server; use App\Models\Server;;
use Livewire\Component; use Livewire\Component;
class Form extends Component class Form extends Component
@@ -43,6 +44,7 @@ class Form extends Component
'server.settings.metrics_history_days' => 'required|integer|min:1', 'server.settings.metrics_history_days' => 'required|integer|min:1',
'wildcard_domain' => 'nullable|url', 'wildcard_domain' => 'nullable|url',
'server.settings.is_server_api_enabled' => 'required|boolean', 'server.settings.is_server_api_enabled' => 'required|boolean',
'server.settings.server_timezone' => 'required|string|timezone',
'server.settings.force_docker_cleanup' => 'required|boolean', 'server.settings.force_docker_cleanup' => 'required|boolean',
'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string', 'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string',
'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100', 'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100',
@@ -66,10 +68,15 @@ class Form extends Component
'server.settings.metrics_refresh_rate_seconds' => 'Metrics Interval', 'server.settings.metrics_refresh_rate_seconds' => 'Metrics Interval',
'server.settings.metrics_history_days' => 'Metrics History', 'server.settings.metrics_history_days' => 'Metrics History',
'server.settings.is_server_api_enabled' => 'Server API', 'server.settings.is_server_api_enabled' => 'Server API',
'server.settings.server_timezone' => 'Server Timezone',
]; ];
public function mount() public $timezones;
public function mount(Server $server)
{ {
$this->server = $server;
$this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray();
$this->wildcard_domain = $this->server->settings->wildcard_domain; $this->wildcard_domain = $this->server->settings->wildcard_domain;
$this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold; $this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold;
$this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency; $this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency;
@@ -167,7 +174,7 @@ class Form extends Component
$this->server->settings->save(); $this->server->settings->save();
$this->dispatch('proxyStatusUpdated'); $this->dispatch('proxyStatusUpdated');
} else { } else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error); $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: ' . $error);
return; return;
} }
@@ -207,6 +214,23 @@ class Form extends Component
} else { } else {
$this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold; $this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold;
} }
$currentTimezone = $this->server->settings->getOriginal('server_timezone');
$newTimezone = $this->server->settings->server_timezone;
if ($currentTimezone !== $newTimezone || $currentTimezone === '') {
try {
$timezoneUpdated = $this->updateServerTimezone($newTimezone);
if ($timezoneUpdated) {
$this->server->settings->server_timezone = $newTimezone;
$this->server->settings->save();
} else {
return;
}
} catch (\Exception $e) {
$this->dispatch('error', 'Failed to update server timezone: ' . $e->getMessage());
return;
}
}
$this->server->settings->save(); $this->server->settings->save();
$this->server->save(); $this->server->save();
$this->dispatch('success', 'Server updated.'); $this->dispatch('success', 'Server updated.');
@@ -214,4 +238,90 @@ class Form extends Component
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function updatedServerTimezone($value)
{
if (!is_string($value) || !in_array($value, timezone_identifiers_list())) {
$this->addError('server.settings.server_timezone', 'Invalid timezone.');
return;
}
$this->server->settings->server_timezone = $value;
$this->updateServerTimezone($value);
}
private function updateServerTimezone($desired_timezone)
{
try {
$commands = [
"if command -v timedatectl > /dev/null 2>&1 && pidof systemd > /dev/null; then",
" timedatectl set-timezone " . escapeshellarg($desired_timezone),
"elif [ -f /etc/timezone ]; then",
" echo " . escapeshellarg($desired_timezone) . " > /etc/timezone",
" rm -f /etc/localtime",
" ln -sf /usr/share/zoneinfo/" . escapeshellarg($desired_timezone) . " /etc/localtime",
"elif [ -f /etc/localtime ]; then",
" rm -f /etc/localtime",
" ln -sf /usr/share/zoneinfo/" . escapeshellarg($desired_timezone) . " /etc/localtime",
"else",
" echo 'Unable to set timezone'",
" exit 1",
"fi",
"if command -v dpkg-reconfigure > /dev/null 2>&1; then",
" dpkg-reconfigure -f noninteractive tzdata",
"elif command -v tzdata-update > /dev/null 2>&1; then",
" tzdata-update",
"elif [ -f /etc/sysconfig/clock ]; then",
" sed -i 's/^ZONE=.*/ZONE=\"" . $desired_timezone . "\"/' /etc/sysconfig/clock",
" source /etc/sysconfig/clock",
"fi",
"if command -v systemctl > /dev/null 2>&1 && pidof systemd > /dev/null; then",
" systemctl try-restart systemd-timesyncd.service || true",
"elif command -v service > /dev/null 2>&1; then",
" service ntpd restart || service ntp restart || true",
"fi",
"echo \"Timezone updated to: $desired_timezone\"",
"date"
];
instant_remote_process($commands, $this->server);
$verificationCommands = [
"readlink /etc/localtime | sed 's#/usr/share/zoneinfo/##'",
"date +'%Z %:z'"
];
$verificationResult = instant_remote_process($verificationCommands, $this->server, false);
$verificationLines = explode("\n", trim($verificationResult));
if (count($verificationLines) !== 2) {
$this->dispatch('error', 'Failed to verify timezone update. Unexpected server response.');
return false;
}
$actualTimezone = trim($verificationLines[0]);
[$abbreviation, $offset] = explode(' ', trim($verificationLines[1]));
$desiredTz = new \DateTimeZone($desired_timezone);
$desiredAbbr = (new \DateTime('now', $desiredTz))->format('T');
$desiredOffset = $this->formatOffset($desiredTz->getOffset(new \DateTime('now', $desiredTz)));
if ($actualTimezone === $desired_timezone && $abbreviation === $desiredAbbr && $offset === $desiredOffset) {
$this->server->settings->server_timezone = $desired_timezone;
$this->server->settings->save();
return true;
} else {
$this->dispatch('error', 'Failed to update server timezone. The server reported a different timezone than requested.');
return false;
}
} catch (\Exception $e) {
$this->dispatch('error', 'Failed to update server timezone: ' . $e->getMessage());
return false;
}
}
private function formatOffset($offsetSeconds)
{
$hours = abs($offsetSeconds) / 3600;
$minutes = (abs($offsetSeconds) % 3600) / 60;
return sprintf('%s%02d:%02d', $offsetSeconds >= 0 ? '+' : '-', $hours, $minutes);
}
} }

View File

@@ -40,6 +40,7 @@ class Index extends Component
'settings.is_auto_update_enabled' => 'boolean', 'settings.is_auto_update_enabled' => 'boolean',
'auto_update_frequency' => 'string', 'auto_update_frequency' => 'string',
'update_check_frequency' => 'string', 'update_check_frequency' => 'string',
'settings.instance_timezone' => 'required|string|timezone',
]; ];
protected $validationAttributes = [ protected $validationAttributes = [
@@ -54,6 +55,8 @@ class Index extends Component
'update_check_frequency' => 'Update Check Frequency', 'update_check_frequency' => 'Update Check Frequency',
]; ];
public $timezones;
public function mount() public function mount()
{ {
if (isInstanceAdmin()) { if (isInstanceAdmin()) {
@@ -65,6 +68,7 @@ class Index extends Component
$this->is_api_enabled = $this->settings->is_api_enabled; $this->is_api_enabled = $this->settings->is_api_enabled;
$this->auto_update_frequency = $this->settings->auto_update_frequency; $this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency; $this->update_check_frequency = $this->settings->update_check_frequency;
$this->timezones = collect(timezone_identifiers_list())->sort()->values()->toArray();
} else { } else {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }

View File

@@ -34,4 +34,14 @@ class ScheduledDatabaseBackup extends BaseModel
{ {
return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get();
} }
public function server()
{
if ($this->database) {
if ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;
return $server;
}
}
return null;
}
} }

View File

@@ -4,6 +4,8 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Models\Service;
use App\Models\Application;
class ScheduledTask extends BaseModel class ScheduledTask extends BaseModel
{ {
@@ -28,4 +30,25 @@ class ScheduledTask extends BaseModel
{ {
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc'); return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
} }
public function server()
{
if ($this->application) {
if ($this->application->destination && $this->application->destination->server) {
$server = $this->application->destination->server;
return $server;
}
} elseif ($this->service) {
if ($this->service->destination && $this->service->destination->server) {
$server = $this->service->destination->server;
return $server;
}
} elseif ($this->database) {
if ($this->database->destination && $this->database->destination->server) {
$server = $this->database->destination->server;
return $server;
}
}
return null;
}
} }

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTimezoneToServerAndInstanceSettings extends Migration
{
public function up()
{
Schema::table('server_settings', function (Blueprint $table) {
$table->string('server_timezone')->default('');
});
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('instance_timezone')->default('UTC');
});
}
public function down()
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('server_timezone');
});
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('instance_timezone');
});
}
}

View File

@@ -33,6 +33,7 @@ class DatabaseSeeder extends Seeder
ScheduledDatabaseBackupSeeder::class, ScheduledDatabaseBackupSeeder::class,
ScheduledDatabaseBackupExecutionSeeder::class, ScheduledDatabaseBackupExecutionSeeder::class,
OauthSettingSeeder::class, OauthSettingSeeder::class,
ServerTimezoneSeeder::class,
]); ]);
} }
} }

View File

@@ -79,7 +79,8 @@ class ProductionSeeder extends Seeder
], ],
[ [
'name' => 'localhost\'s key', 'name' => 'localhost\'s key',
'description' => 'The private key for the Coolify host machine (localhost).', 'private_key' => $coolify_key, 'description' => 'The private key for the Coolify host machine (localhost).',
'private_key' => $coolify_key,
] ]
); );
} else { } else {
@@ -180,5 +181,8 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
$oauth_settings_seeder = new OauthSettingSeeder; $oauth_settings_seeder = new OauthSettingSeeder;
$oauth_settings_seeder->run(); $oauth_settings_seeder->run();
$server_timezone_seeder = new ServerTimezoneSeeder;
$server_timezone_seeder->run();
} }
} }

View File

@@ -0,0 +1,58 @@
<?php
namespace Database\Seeders;
use App\Models\Server;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class ServerTimezoneSeeder extends Seeder
{
public function run(): void
{
$defaultTimezone = config('app.timezone');
Server::whereHas('settings', function ($query) {
$query->whereNull('server_timezone')->orWhere('server_timezone', '');
})->each(function ($server) use ($defaultTimezone) {
DB::transaction(function () use ($server, $defaultTimezone) {
$this->updateServerTimezone($server, $defaultTimezone);
});
});
}
private function updateServerTimezone($server, $desired_timezone)
{
$commands = [
"if command -v timedatectl > /dev/null 2>&1 && pidof systemd > /dev/null; then",
" timedatectl set-timezone " . escapeshellarg($desired_timezone),
"elif [ -f /etc/timezone ]; then",
" echo " . escapeshellarg($desired_timezone) . " > /etc/timezone",
" rm -f /etc/localtime",
" ln -sf /usr/share/zoneinfo/" . escapeshellarg($desired_timezone) . " /etc/localtime",
"elif [ -f /etc/localtime ]; then",
" rm -f /etc/localtime",
" ln -sf /usr/share/zoneinfo/" . escapeshellarg($desired_timezone) . " /etc/localtime",
"fi",
"if command -v dpkg-reconfigure > /dev/null 2>&1; then",
" dpkg-reconfigure -f noninteractive tzdata",
"elif command -v tzdata-update > /dev/null 2>&1; then",
" tzdata-update",
"elif [ -f /etc/sysconfig/clock ]; then",
" sed -i 's/^ZONE=.*/ZONE=\"" . $desired_timezone . "\"/' /etc/sysconfig/clock",
" source /etc/sysconfig/clock",
"fi",
"if command -v systemctl > /dev/null 2>&1 && pidof systemd > /dev/null; then",
" systemctl try-restart systemd-timesyncd.service || true",
"elif command -v service > /dev/null 2>&1; then",
" service ntpd restart || service ntp restart || true",
"fi"
];
instant_remote_process($commands, $server);
$server->settings->server_timezone = $desired_timezone;
$server->settings->save();
}
}

View File

@@ -4,34 +4,45 @@
<h3 class="py-4">Executions</h3> <h3 class="py-4">Executions</h3>
<x-forms.button wire:click='cleanupFailed'>Cleanup Failed Backups</x-forms.button> <x-forms.button wire:click='cleanupFailed'>Cleanup Failed Backups</x-forms.button>
</div> </div>
<div class="flex flex-col-reverse gap-2"> <div class="flex flex-col-reverse gap-4">
@forelse($executions as $execution) @forelse($executions as $execution)
<form wire:key="{{ data_get($execution, 'id') }}" <div wire:key="{{ data_get($execution, 'id') }}"
class="relative flex flex-col p-4 bg-white box-without-bg dark:bg-coolgray-100"
@class([ @class([
'flex flex-col border-l-4 transition-colors cursor-pointer p-4 rounded',
'bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200',
'text-black dark:text-white',
'border-green-500' => data_get($execution, 'status') === 'success', 'border-green-500' => data_get($execution, 'status') === 'success',
'border-red-500' => data_get($execution, 'status') === 'failed', 'border-red-500' => data_get($execution, 'status') === 'failed',
'border-yellow-500' => data_get($execution, 'status') === 'running',
])> ])>
@if (data_get($execution, 'status') === 'running') @if (data_get($execution, 'status') === 'running')
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<x-loading /> <x-loading />
</div> </div>
@endif @endif
<div>Database: {{ data_get($execution, 'database_name', 'N/A') }}</div> <div class="text-gray-700 dark:text-gray-300 font-semibold mb-1">Status: {{ data_get($execution, 'status') }}</div>
<div>Status: {{ data_get($execution, 'status') }}</div> <div class="text-gray-600 dark:text-gray-400 text-sm">
<div>Started At: {{ data_get($execution, 'created_at') }}</div> Started At: {{ $this->formatDateInServerTimezone(data_get($execution, 'created_at')) }}
@if (data_get($execution, 'message'))
<div>Message: {{ data_get($execution, 'message') }}</div>
@endif
<div>Size: {{ data_get($execution, 'size') }} B /
{{ round((int) data_get($execution, 'size') / 1024, 2) }}
kB / {{ round((int) data_get($execution, 'size') / 1024 / 1024, 3) }} MB
</div> </div>
<div>Location: {{ data_get($execution, 'filename', 'N/A') }}</div> <div class="text-gray-600 dark:text-gray-400 text-sm">
<div class="flex gap-2"> Database: {{ data_get($execution, 'database_name', 'N/A') }}
<div class="flex-1"></div> </div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
Size: {{ data_get($execution, 'size') }} B /
{{ round((int) data_get($execution, 'size') / 1024, 2) }} kB /
{{ round((int) data_get($execution, 'size') / 1024 / 1024, 3) }} MB
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
Location: {{ data_get($execution, 'filename', 'N/A') }}
</div>
@if (data_get($execution, 'message'))
<div class="mt-2 p-2 bg-gray-100 dark:bg-coolgray-200 rounded">
<pre class="whitespace-pre-wrap text-sm">{{ data_get($execution, 'message') }}</pre>
</div>
@endif
<div class="flex gap-2 mt-4">
@if (data_get($execution, 'status') === 'success') @if (data_get($execution, 'status') === 'success')
<x-forms.button class=" dark:hover:bg-coolgray-400" <x-forms.button class="dark:hover:bg-coolgray-400"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button> x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@endif @endif
<x-modal-confirmation isErrorButton action="deleteBackup({{ data_get($execution, 'id') }})"> <x-modal-confirmation isErrorButton action="deleteBackup({{ data_get($execution, 'id') }})">
@@ -41,10 +52,9 @@
This will delete this backup. It is not reversible.<br>Please think again. This will delete this backup. It is not reversible.<br>Please think again.
</x-modal-confirmation> </x-modal-confirmation>
</div> </div>
</form> </div>
@empty @empty
<div>No executions found.</div> <div class="p-4 bg-gray-100 dark:bg-coolgray-200 rounded">No executions found.</div>
@endforelse @endforelse
</div> </div>
<script> <script>
@@ -53,5 +63,4 @@
} }
</script> </script>
@endisset @endisset
</div> </div>

View File

@@ -12,8 +12,7 @@
</a> </a>
@else @else
<div class="box" wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')"> <div class="box" wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')">
<div @class([ <div @class([ 'border-coollabs'=>
'border-coollabs' =>
data_get($backup, 'id') === data_get($selectedBackup, 'id'), data_get($backup, 'id') === data_get($selectedBackup, 'id'),
'flex flex-col border-l-2 border-transparent', 'flex flex-col border-l-2 border-transparent',
])> ])>
@@ -32,7 +31,7 @@
<livewire:project.database.backup-edit wire:key="{{ $selectedBackup->id }}" :backup="$selectedBackup" <livewire:project.database.backup-edit wire:key="{{ $selectedBackup->id }}" :backup="$selectedBackup"
:s3s="$s3s" :status="data_get($database, 'status')" /> :s3s="$s3s" :status="data_get($database, 'status')" />
<h3 class="py-4">Executions</h3> <h3 class="py-4">Executions</h3>
<livewire:project.database.backup-executions wire:key="{{ $selectedBackup->id }}" :backup="$selectedBackup" /> <livewire:project.database.backup-executions wire:key="{{ $selectedBackup->id }}" :backup="$selectedBackup" :database="$database" />
</div> </div>
@endif @endif
</div> </div>

View File

@@ -1,10 +1,10 @@
<div class="flex flex-col-reverse gap-2"> <div class="flex flex-col-reverse gap-4">
@forelse($executions as $execution) @forelse($executions as $execution)
@if (data_get($execution, 'id') == $selectedKey) @if (data_get($execution, 'id') == $selectedKey)
<div class="p-2"> <div class="p-4 mb-2 bg-gray-100 dark:bg-coolgray-200 rounded">
@if (data_get($execution, 'message')) @if (data_get($execution, 'message'))
<div> <div>
<pre>{{ data_get($execution, 'message') }}</pre> <pre class="whitespace-pre-wrap">{{ data_get($execution, 'message') }}</pre>
</div> </div>
@else @else
<div>No output was recorded for this execution.</div> <div>No output was recorded for this execution.</div>
@@ -12,21 +12,25 @@
</div> </div>
@endif @endif
<a wire:click="selectTask({{ data_get($execution, 'id') }})" @class([ <a wire:click="selectTask({{ data_get($execution, 'id') }})" @class([
'flex flex-col border-l transition-colors box-without-bg bg-coolgray-100 hover:bg-coolgray-200 cursor-pointer', 'flex flex-col border-l-4 transition-colors cursor-pointer p-4 rounded',
'bg-coolgray-200 dark:text-white hover:bg-coolgray-200' => 'bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200',
data_get($execution, 'id') == $selectedKey, 'text-black dark:text-white',
'bg-gray-200 dark:bg-coolgray-200' => data_get($execution, 'id') == $selectedKey,
'border-green-500' => data_get($execution, 'status') === 'success', 'border-green-500' => data_get($execution, 'status') === 'success',
'border-red-500' => data_get($execution, 'status') === 'failed', 'border-red-500' => data_get($execution, 'status') === 'failed',
'border-yellow-500' => data_get($execution, 'status') === 'running',
])> ])>
@if (data_get($execution, 'status') === 'running') @if (data_get($execution, 'status') === 'running')
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<x-loading /> <x-loading />
</div> </div>
@endif @endif
<div>Status: {{ data_get($execution, 'status') }}</div> <div class="text-gray-700 dark:text-gray-300 font-semibold mb-1">Status: {{ data_get($execution, 'status') }}</div>
<div>Started At: {{ data_get($execution, 'created_at') }}</div> <div class="text-gray-600 dark:text-gray-400 text-sm">
Started At: {{ $this->formatDateInServerTimezone(data_get($execution, 'created_at', now())) }}
</div>
</a> </a>
@empty @empty
<div>No executions found.</div> <div class="p-4 bg-gray-100 dark:bg-coolgray-200 rounded">No executions found.</div>
@endforelse @endforelse
</div> </div>

View File

@@ -42,6 +42,6 @@
<div class="pt-4"> <div class="pt-4">
<h3 class="py-4">Recent executions <span class="text-xs text-neutral-500">(click to check output)</span></h3> <h3 class="py-4">Recent executions <span class="text-xs text-neutral-500">(click to check output)</span></h3>
<livewire:project.shared.scheduled-task.executions key="{{ $task->id }}" selectedKey="" :executions="$task->executions->take(-20)" /> <livewire:project.shared.scheduled-task.executions :task="$task" key="{{ $task->id }}" selectedKey="" :executions="$task->executions->take(-20)" />
</div> </div>
</div> </div>

View File

@@ -65,7 +65,42 @@
<x-forms.input placeholder="https://example.com" id="wildcard_domain" label="Wildcard Domain" <x-forms.input placeholder="https://example.com" id="wildcard_domain" label="Wildcard Domain"
helper='A wildcard domain allows you to receive a randomly generated domain for your new applications. <br><br>For instance, if you set "https://example.com" as your wildcard domain, your applications will receive domains like "https://randomId.example.com".' /> helper='A wildcard domain allows you to receive a randomly generated domain for your new applications. <br><br>For instance, if you set "https://example.com" as your wildcard domain, your applications will receive domains like "https://randomId.example.com".' />
@endif @endif
<div class="w-full" x-data="{
open: false,
search: '{{ $server->settings->server_timezone ?: '' }}',
timezones: @js($timezones),
placeholder: '{{ $server->settings->server_timezone ? 'Search timezone...' : 'Select Server Timezone' }}',
init() {
this.$watch('search', value => {
if (value === '') {
this.open = true;
}
})
}
}">
<div class="flex items-center">
<label for="server.settings.server_timezone" class="dark:text-white">Server Timezone</label>
<x-helper class="ml-2" helper="Current server's timezone (This setting changes your server's timezone in /etc/timezone, /etc/localtime, etc.). This is used for backups, cron jobs, etc." />
</div>
<div class="relative">
<input
x-model="search"
@focus="open = true"
@click.away="open = false"
@input="open = true"
class="w-full input"
:placeholder="placeholder"
wire:model.debounce.300ms="server.settings.server_timezone">
<div x-show="open" class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-gray-300 dark:border-white rounded-md shadow-lg max-h-60 overflow-auto">
<template x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))" :key="timezone">
<div
@click="search = timezone; open = false; $wire.set('server.settings.server_timezone', timezone)"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-200"
x-text="timezone"></div>
</template>
</div>
</div>
</div>
</div> </div>
<div class="flex flex-col w-full gap-2 lg:flex-row"> <div class="flex flex-col w-full gap-2 lg:flex-row">
<x-forms.input type="password" id="server.ip" label="IP Address/Domain" <x-forms.input type="password" id="server.ip" label="IP Address/Domain"
@@ -159,6 +194,17 @@
helper="The Docker cleanup tasks will run when the disk usage exceeds this threshold." /> helper="The Docker cleanup tasks will run when the disk usage exceeds this threshold." />
@endif @endif
</div> </div>
@else
<x-forms.input id="cleanup_after_percentage" label="Disk cleanup threshold (%)" required
helper="The disk cleanup task will run when the disk usage exceeds this threshold." />
<div class="w-64">
<x-forms.checkbox
helper="This will cleanup build caches / unused images / etc every 10 minutes."
instantSave id="server.settings.is_force_cleanup_enabled"
label="Force Cleanup Docker Engine" />
</div>
@endif
</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap"> <div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input id="server.settings.concurrent_builds" label="Number of concurrent builds" required <x-forms.input id="server.settings.concurrent_builds" label="Number of concurrent builds" required
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." /> helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />

View File

@@ -17,6 +17,37 @@
<h4 class="pt-6">Instance Settings</h4> <h4 class="pt-6">Instance Settings</h4>
<x-forms.input id="settings.fqdn" label="Instance's Domain" helper="Enter the full domain name (FQDN) of the instance, including 'https://' if you want to secure the dashboard with HTTPS. Setting this will make the dashboard accessible via this domain, secured by HTTPS, instead of just the IP address." placeholder="https://coolify.yourdomain.com" /> <x-forms.input id="settings.fqdn" label="Instance's Domain" helper="Enter the full domain name (FQDN) of the instance, including 'https://' if you want to secure the dashboard with HTTPS. Setting this will make the dashboard accessible via this domain, secured by HTTPS, instead of just the IP address." placeholder="https://coolify.yourdomain.com" />
<x-forms.input id="settings.instance_name" label="Instance's Name" placeholder="Coolify" /> <x-forms.input id="settings.instance_name" label="Instance's Name" placeholder="Coolify" />
<div class="w-full" x-data="{
open: false,
search: '{{ $settings->instance_timezone }}',
timezones: @js($timezones),
placeholder: 'Select Instance Timezone'
}">
<label for="settings.instance_timezone" class="dark:text-white flex items-center">
Instance Timezone
<x-helper class="ml-2" helper="Timezone for the Coolify instance (this does NOT change your server's timezone in /etc/timezone, /etc/localtime, etc.). This is used for the update check and automatic update frequency." />
</label>
<div class="relative">
<input
x-model="search"
@focus="open = true"
@click.away="open = false"
@input="open = true"
class="w-full input"
:placeholder="placeholder"
wire:model.debounce.300ms="settings.instance_timezone"
>
<div x-show="open" class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-gray-300 dark:border-white rounded-md shadow-lg max-h-60 overflow-auto">
<template x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))" :key="timezone">
<div
@click="search = timezone; open = false; $wire.set('settings.instance_timezone', timezone)"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-200"
x-text="timezone"
></div>
</template>
</div>
</div>
</div>
<h4 class="w-full pt-6">DNS Validation</h4> <h4 class="w-full pt-6">DNS Validation</h4>
<div class="md:w-96"> <div class="md:w-96">
<x-forms.checkbox instantSave id="is_dns_validation_enabled" label="Enabled" /> <x-forms.checkbox instantSave id="is_dns_validation_enabled" label="Enabled" />