diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 4b84f9a95..a843e7fc2 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -14,9 +14,9 @@ public function handle(Service $service) $commands[] = "cd " . $service->workdir(); $commands[] = "echo '####### Starting service {$service->name} on {$service->server->name}.'"; $commands[] = "echo '####### Pulling images.'"; - $commands[] = "docker compose pull --quiet"; + $commands[] = "docker compose pull"; $commands[] = "echo '####### Starting containers.'"; - $commands[] = "docker compose up -d >/dev/null 2>&1"; + $commands[] = "docker compose up -d"; $commands[] = "docker network connect $service->uuid coolify-proxy 2>/dev/null || true"; $activity = remote_process($commands, $service->server); return $activity; diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 579a6b167..54aad3454 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -4,6 +4,9 @@ use App\Models\Project; use App\Models\Server; +use App\Models\Service; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; class ProjectController extends Controller { @@ -41,9 +44,10 @@ public function show() public function new() { - $type = request()->query('type'); + $services = Cache::get('services', []); + $type = Str::of(request()->query('type')); $destination_uuid = request()->query('destination'); - $server = requesT()->query('server'); + $server_id = request()->query('server'); $project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first(); if (!$project) { @@ -61,8 +65,28 @@ public function new() 'database_uuid' => $standalone_postgresql->uuid, ]); } + if ($type->startsWith('one-click-service-')) { + $oneClickServiceName = $type->after('one-click-service-')->value(); + $oneClickService = data_get($services, $oneClickServiceName); + if ($oneClickService) { + $service = Service::create([ + 'name' => "$oneClickServiceName-" . Str::random(10), + 'docker_compose_raw' => base64_decode($oneClickService), + 'environment_id' => $environment->id, + 'server_id' => (int) $server_id, + ]); + + $service->parse(isNew: true); + + return redirect()->route('project.service', [ + 'service_uuid' => $service->uuid, + 'environment_name' => $environment->name, + 'project_uuid' => $project->uuid, + ]); + } + } return view('project.new', [ - 'type' => $type + 'type' => $type->value() ]); } diff --git a/app/Http/Livewire/Project/New/DockerCompose.php b/app/Http/Livewire/Project/New/DockerCompose.php index 5db4fd113..2e95eaf9d 100644 --- a/app/Http/Livewire/Project/New/DockerCompose.php +++ b/app/Http/Livewire/Project/New/DockerCompose.php @@ -10,15 +10,16 @@ class DockerCompose extends Component { - public string $dockercompose = ''; + public string $dockerComposeRaw = ''; public array $parameters; public array $query; public function mount() { + $this->parameters = get_route_parameters(); $this->query = request()->query(); if (isDev()) { - $this->dockercompose = 'services: + $this->dockerComposeRaw = 'services: plausible_events_db: image: clickhouse/clickhouse-server:23.3.7.5-alpine restart: always @@ -37,9 +38,9 @@ public function submit() { try { $this->validate([ - 'dockercompose' => 'required' + 'dockerComposeRaw' => 'required' ]); - $this->dockercompose = Yaml::dump(Yaml::parse($this->dockercompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $server_id = $this->query['server_id']; $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); @@ -47,7 +48,7 @@ public function submit() $service = Service::create([ 'name' => 'service' . Str::random(10), - 'docker_compose_raw' => $this->dockercompose, + 'docker_compose_raw' => $this->dockerComposeRaw, 'environment_id' => $environment->id, 'server_id' => (int) $server_id, ]); diff --git a/app/Http/Livewire/Project/New/Select.php b/app/Http/Livewire/Project/New/Select.php index 811a679ba..db4574dae 100644 --- a/app/Http/Livewire/Project/New/Select.php +++ b/app/Http/Livewire/Project/New/Select.php @@ -3,12 +3,12 @@ namespace App\Http\Livewire\Project\New; use App\Models\Server; -use App\Models\StandaloneDocker; -use App\Models\SwarmDocker; use Countable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Livewire\Component; -use Route; +use Illuminate\Support\Str; class Select extends Component { @@ -21,6 +21,8 @@ class Select extends Component public Collection|array $standaloneDockers = []; public Collection|array $swarmDockers = []; public array $parameters; + public Collection|array $services = []; + public bool $loadingServices = true; public ?string $existingPostgresqlUrl = null; @@ -44,6 +46,35 @@ public function mount() // return handleError($e, $this); // } // } + public function loadThings() + { + $this->loadServices(); + $this->loadServers(); + } + public function loadServices(bool $forceReload = false) + { + try { + if ($forceReload) { + Cache::forget('services'); + } + $cached = Cache::remember('services', 3600, function () { + $services = Http::get(config('constants.services.offical')); + if ($services->failed()) { + throw new \Exception($services->body()); + } + + $services = collect($services->json()); + $this->emit('success', 'Successfully reloaded services from the internet.'); + return $services; + }); + $this->services = $cached; + } catch (\Throwable $e) { + ray($e); + return handleError($e, $this); + } finally { + $this->loadingServices = false; + } + } public function setType(string $type) { $this->type = $type; @@ -87,7 +118,7 @@ public function setDestination(string $destination_uuid) ]); } - public function load_servers() + public function loadServers() { $this->servers = Server::isUsable()->get(); } diff --git a/app/Http/Livewire/Project/Service/Application.php b/app/Http/Livewire/Project/Service/Application.php index 6542906c7..9bebb84e8 100644 --- a/app/Http/Livewire/Project/Service/Application.php +++ b/app/Http/Livewire/Project/Service/Application.php @@ -8,23 +8,39 @@ class Application extends Component { public ServiceApplication $application; + public $parameters; public $fileStorages = null; protected $listeners = ["refreshFileStorages"]; protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', 'application.fqdn' => 'nullable', + 'application.ignore_from_status' => 'required|boolean', ]; public function render() { return view('livewire.project.service.application'); } + public function instantSave() { + $this->submit(); + } public function refreshFileStorages() { $this->fileStorages = $this->application->fileStorages()->get(); } + public function delete() + { + try { + $this->application->delete(); + $this->emit('success', 'Application deleted successfully.'); + return redirect()->route('project.service', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } public function mount() { + $this->parameters = get_route_parameters(); $this->refreshFileStorages(); } public function submit() diff --git a/app/Http/Livewire/Project/Service/Database.php b/app/Http/Livewire/Project/Service/Database.php index 50c9478d1..1a6ce772a 100644 --- a/app/Http/Livewire/Project/Service/Database.php +++ b/app/Http/Livewire/Project/Service/Database.php @@ -12,16 +12,22 @@ class Database extends Component protected $rules = [ 'database.human_name' => 'nullable', 'database.description' => 'nullable', + 'database.ignore_from_status' => 'required|boolean', + ]; public function render() { return view('livewire.project.service.database'); } + public function instantSave() { + $this->submit(); + } public function submit() { try { $this->validate(); $this->database->save(); + $this->emit('success', 'Database saved successfully.'); } catch (\Throwable $e) { ray($e); } finally { diff --git a/app/Http/Livewire/Project/Service/Index.php b/app/Http/Livewire/Project/Service/Index.php index 9cfe8957c..c294edc54 100644 --- a/app/Http/Livewire/Project/Service/Index.php +++ b/app/Http/Livewire/Project/Service/Index.php @@ -3,14 +3,17 @@ namespace App\Http\Livewire\Project\Service; use App\Models\Service; +use App\Models\ServiceApplication; use Livewire\Component; class Index extends Component { public Service $service; - + public $applications; + public $databases; public array $parameters; public array $query; + protected $rules = [ 'service.docker_compose_raw' => 'required', 'service.docker_compose' => 'required', @@ -23,6 +26,9 @@ public function mount() $this->parameters = get_route_parameters(); $this->query = request()->query(); $this->service = Service::whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->applications = $this->service->applications->sort(); + $this->databases = $this->service->databases->sort(); + } public function render() { @@ -30,12 +36,16 @@ public function render() } public function save() { - $this->service->save(); - $this->service->parse(); - $this->service->refresh(); - $this->emit('refreshEnvs'); - $this->emit('success', 'Service saved successfully.'); - $this->service->saveComposeConfigs(); + try { + $this->service->save(); + $this->service->parse(); + $this->service->refresh(); + $this->emit('refreshEnvs'); + $this->emit('success', 'Service saved successfully.'); + $this->service->saveComposeConfigs(); + } catch(\Throwable $e) { + return handleError($e, $this); + } } public function submit() { diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php index 46c337260..137fadb6e 100644 --- a/app/Jobs/ContainerStatusJob.php +++ b/app/Jobs/ContainerStatusJob.php @@ -76,7 +76,6 @@ public function handle(): void $databases = $this->server->databases(); $services = $this->server->services(); $previews = $this->server->previews(); - $this->server->proxyType(); /// Check if proxy is running $foundProxyContainer = $containers->filter(function ($value, $key) { @@ -149,19 +148,20 @@ public function handle(): void } $serviceLabelId = data_get($labels, 'coolify.serviceId'); if ($serviceLabelId) { - $coolifyName = data_get($labels, 'coolify.name'); - $serviceName = Str::of($coolifyName)->before('-'); - $serviceUuid = Str::of($coolifyName)->after('-'); - $service = $services->where('uuid', $serviceUuid)->first(); + $subType = data_get($labels, 'coolify.service.subType'); + $subId = data_get($labels, 'coolify.service.subId'); + $service = $services->where('id', $serviceLabelId)->first(); + if ($subType === 'application') { + $service = $service->applications()->where('id', $subId)->first(); + } else { + $service = $service->databases()->where('id', $subId)->first(); + } if ($service) { - $foundService = $service->byName($serviceName); - if ($foundService) { - $foundServices[] = "$foundService->id-$serviceName"; - $statusFromDb = $foundService->status; - if ($statusFromDb !== $containerStatus) { - // ray('Updating status: ' . $containerStatus); - $foundService->update(['status' => $containerStatus]); - } + $foundServices[] = "$service->id-$service->name"; + $statusFromDb = $service->status; + if ($statusFromDb !== $containerStatus) { + // ray('Updating status: ' . $containerStatus); + $service->update(['status' => $containerStatus]); } } } diff --git a/app/Models/Service.php b/app/Models/Service.php index 68003cd48..c773153d4 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -79,7 +79,8 @@ public function environment_variables(): HasMany { return $this->hasMany(EnvironmentVariable::class)->orderBy('key', 'asc'); } - public function workdir() { + public function workdir() + { return service_configuration_dir() . "/{$this->uuid}"; } public function saveComposeConfigs() @@ -97,6 +98,16 @@ public function saveComposeConfigs() } instant_remote_process($commands, $this->server); } + private function generateFqdn($serviceVariables, $serviceName) + { + if (Str::of($serviceVariables)->contains('SERVICE_FQDN') || Str::of($serviceVariables)->contains('SERVICE_URL')) { + $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.{$this->server->ip}.sslip.io"; + if (isDev()) { + $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.127.0.0.1.sslip.io"; + } + } + return $defaultUsableFqdn ?? null; + } public function parse(bool $isNew = false): Collection { ray('parsing'); @@ -132,20 +143,27 @@ public function parse(bool $isNew = false): Collection data_set($service, 'is_database', true); } } - if ($isNew) { + if ($isDatabase) { + $savedService = ServiceDatabase::where([ + 'name' => $serviceName, + 'service_id' => $this->id + ])->first(); + } else { + $savedService = ServiceApplication::where([ + 'name' => $serviceName, + 'service_id' => $this->id + ])->first(); + } + if ($isNew || is_null($savedService)) { if ($isDatabase) { $savedService = ServiceDatabase::create([ 'name' => $serviceName, 'service_id' => $this->id ]); } else { - $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.{$this->server->ip}.sslip.io"; - if (isDev()) { - $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.127.0.0.1.sslip.io"; - } $savedService = ServiceApplication::create([ 'name' => $serviceName, - 'fqdn' => $defaultUsableFqdn, + 'fqdn' => $this->generateFqdn($serviceVariables, $serviceName), 'service_id' => $this->id ]); } @@ -157,14 +175,9 @@ public function parse(bool $isNew = false): Collection if (data_get($savedService, 'fqdn')) { $defaultUsableFqdn = data_get($savedService, 'fqdn', null); } else { - if (Str::of($serviceVariables)->contains('SERVICE_FQDN') || Str::of($serviceVariables)->contains('SERVICE_URL')) { - $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.{$this->server->ip}.sslip.io"; - if (isDev()) { - $defaultUsableFqdn = "http://$serviceName-{$this->uuid}.127.0.0.1.sslip.io"; - } - } + $defaultUsableFqdn = $this->generateFqdn($serviceVariables, $serviceName); } - $savedService->fqdn = $defaultUsableFqdn ?? null; + $savedService->fqdn = $defaultUsableFqdn; $savedService->save(); } } @@ -475,7 +488,7 @@ public function parse(bool $isNew = false): Collection // Add labels to the service $labels = collect(data_get($service, 'labels', [])); $labels = collect([]); - $labels = $labels->merge(defaultLabels($this->id, $container_name, type: 'service')); + $labels = $labels->merge(defaultLabels($this->id, $container_name, type: 'service', subType: $isDatabase ? 'database' : 'application', subId: $savedService->id)); if (!$isDatabase) { if ($fqdns) { $labels = $labels->merge(fqdnLabelsForTraefik($fqdns, $container_name, true)); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 27fa96fb8..6aa16d964 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -130,7 +130,7 @@ function get_port_from_dockerfile($dockerfile): int return 80; } -function defaultLabels($id, $name, $pull_request_id = 0, string $type = 'application') +function defaultLabels($id, $name, $pull_request_id = 0, string $type = 'application', $subType = null, $subId = null) { $labels = collect([]); $labels->push('coolify.managed=true'); @@ -141,6 +141,10 @@ function defaultLabels($id, $name, $pull_request_id = 0, string $type = 'applica if ($pull_request_id !== 0) { $labels->push('coolify.pullRequestId=' . $pull_request_id); } + if ($type === 'service') { + $labels->push('coolify.service.subId=' . $subId); + $labels->push('coolify.service.subType=' . $subType); + } return $labels; } function fqdnLabelsForTraefik(Collection $domains, $container_name, $is_force_https_enabled) diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index d7f810775..af99f77f0 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -25,6 +25,9 @@ function serviceStatus(Service $service) $applications = $service->applications; $databases = $service->databases; foreach ($applications as $application) { + if ($application->ignore_from_status) { + continue; + } if (Str::of($application->status)->startsWith('running')) { $foundRunning = true; } else { @@ -32,6 +35,9 @@ function serviceStatus(Service $service) } } foreach ($databases as $database) { + if ($database->ignore_from_status) { + continue; + } if (Str::of($database->status)->startsWith('running')) { $foundRunning = true; } else { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 63f7fb8b1..803ff7062 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -100,8 +100,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n return "Too many requests. Please try again in {$error->secondsUntilAvailable} seconds."; } if (isset($livewire)) { - $livewire->emit('error', $message); - throw new RuntimeException($message); + return $livewire->emit('error', $message); } throw new RuntimeException($message); diff --git a/config/constants.php b/config/constants.php index 49d122228..c0b41b78f 100644 --- a/config/constants.php +++ b/config/constants.php @@ -14,6 +14,9 @@ 'expiration' => 10, ], ], + 'services' => [ + 'offical' => 'https://cdn.coollabs.io/coolify/service-templates.json', + ], 'limits' => [ 'trial_period'=> 7, 'server' => [ diff --git a/database/migrations/2023_09_23_111811_update_service_applications_table.php b/database/migrations/2023_09_23_111811_update_service_applications_table.php new file mode 100644 index 000000000..9d241aec2 --- /dev/null +++ b/database/migrations/2023_09_23_111811_update_service_applications_table.php @@ -0,0 +1,28 @@ +boolean('ignore_from_status')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('ignore_from_status'); + }); + } +}; diff --git a/database/migrations/2023_09_23_111812_update_service_databases_table.php b/database/migrations/2023_09_23_111812_update_service_databases_table.php new file mode 100644 index 000000000..659cbd2a3 --- /dev/null +++ b/database/migrations/2023_09_23_111812_update_service_databases_table.php @@ -0,0 +1,29 @@ +boolean('ignore_from_status')->default(false); + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn('ignore_from_status'); + }); + } +}; diff --git a/examples/service-templates.json b/examples/service-templates.json new file mode 100644 index 000000000..3eb092f16 --- /dev/null +++ b/examples/service-templates.json @@ -0,0 +1,3 @@ +{ + "plausible-analytics": "dmVyc2lvbjogIjMuMyIKc2VydmljZXM6CiAgcGxhdXNpYmxlOgogICAgaW1hZ2U6IHBsYXVzaWJsZS9hbmFseXRpY3M6djIuMAogICAgY29tbWFuZDogc2ggLWMgInNsZWVwIDEwICYmIC9lbnRyeXBvaW50LnNoIGRiIGNyZWF0ZWRiICYmIC9lbnRyeXBvaW50LnNoIGRiIG1pZ3JhdGUgJiYgL2VudHJ5cG9pbnQuc2ggcnVuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vcG9zdGdyZXM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhdXNpYmxlX2RiL3BsYXVzaWJsZQogICAgICAtIEJBU0VfVVJMPSRTRVJWSUNFX0ZRRE5fUExBVVNJQkxFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF82NF9QTEFVU0lCTEUKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhdXNpYmxlX2RiCiAgICAgIC0gcGxhdXNpYmxlX2V2ZW50c19kYgogICAgICAtIG1haWwKCiAgbWFpbDoKICAgIGltYWdlOiBieXRlbWFyay9zbXRwCgogIHBsYXVzaWJsZV9kYjoKICAgIGltYWdlOiBwb3N0Z3JlczoxNC1hbHBpbmUKICAgIHZvbHVtZXM6CiAgICAgIC0gZGItZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX0RCPXBsYXVzaWJsZQogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCgogIHBsYXVzaWJsZV9ldmVudHNfZGI6CiAgICBpbWFnZTogY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyMy4zLjcuNS1hbHBpbmUKICAgIHZvbHVtZXM6CiAgICAgIC0gdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBldmVudC1kYXRhCiAgICAgICAgdGFyZ2V0OiAvdmFyL2xpYi9jbGlja2hvdXNlCiAgICAgIC0gdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2NsaWNraG91c2UtY29uZmlnLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9sb2dnaW5nLnhtbAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ID4tCiAgICAgICAgICA8Y2xpY2tob3VzZT48cHJvZmlsZXM+PGRlZmF1bHQ+PGxvZ19xdWVyaWVzPjA8L2xvZ19xdWVyaWVzPjxsb2dfcXVlcnlfdGhyZWFkcz4wPC9sb2dfcXVlcnlfdGhyZWFkcz48L2RlZmF1bHQ+PC9wcm9maWxlcz48L2NsaWNraG91c2U+CiAgICAgIC0gdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2NsaWNraG91c2UtdXNlci1jb25maWcueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL3VzZXJzLmQvbG9nZ2luZy54bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiA+LQogICAgICAgICAgPGNsaWNraG91c2U+PGxvZ2dlcj48bGV2ZWw+d2FybmluZzwvbGV2ZWw+PGNvbnNvbGU+dHJ1ZTwvY29uc29sZT48L2xvZ2dlcj48cXVlcnlfdGhyZWFkX2xvZwogICAgICAgICAgcmVtb3ZlPSJyZW1vdmUiLz48cXVlcnlfbG9nIHJlbW92ZT0icmVtb3ZlIi8+PHRleHRfbG9nCiAgICAgICAgICByZW1vdmU9InJlbW92ZSIvPjx0cmFjZV9sb2cgcmVtb3ZlPSJyZW1vdmUiLz48bWV0cmljX2xvZwogICAgICAgICAgcmVtb3ZlPSJyZW1vdmUiLz48YXN5bmNocm9ub3VzX21ldHJpY19sb2cKICAgICAgICAgIHJlbW92ZT0icmVtb3ZlIi8+PHNlc3Npb25fbG9nIHJlbW92ZT0icmVtb3ZlIi8+PHBhcnRfbG9nCiAgICAgICAgICByZW1vdmU9InJlbW92ZSIvPjwvY2xpY2tob3VzZT4KICAgIHVsaW1pdHM6CiAgICAgIG5vZmlsZToKICAgICAgICBzb2Z0OiAyNjIxNDQKICAgICAgICBoYXJkOiAyNjIxNDQK" +} diff --git a/resources/js/components/MagicBar.vue b/resources/js/components/MagicBar.vue index 8f052e96e..3ccac78b7 100644 --- a/resources/js/components/MagicBar.vue +++ b/resources/js/components/MagicBar.vue @@ -1,7 +1,7 @@