mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
fix: comprehensive SERVICE_URL/SERVICE_FQDN handling improvements and queue reliability fixes (#7275)
This commit is contained in:
@@ -105,6 +105,20 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
|
// Defensive initialization for Collection properties to handle queue deserialization edge cases
|
||||||
|
$this->serviceContainerStatuses ??= collect();
|
||||||
|
$this->applicationContainerStatuses ??= collect();
|
||||||
|
$this->foundApplicationIds ??= collect();
|
||||||
|
$this->foundDatabaseUuids ??= collect();
|
||||||
|
$this->foundServiceApplicationIds ??= collect();
|
||||||
|
$this->foundApplicationPreviewsIds ??= collect();
|
||||||
|
$this->foundServiceDatabaseIds ??= collect();
|
||||||
|
$this->allApplicationIds ??= collect();
|
||||||
|
$this->allDatabaseUuids ??= collect();
|
||||||
|
$this->allTcpProxyUuids ??= collect();
|
||||||
|
$this->allServiceApplicationIds ??= collect();
|
||||||
|
$this->allServiceDatabaseIds ??= collect();
|
||||||
|
|
||||||
// TODO: Swarm is not supported yet
|
// TODO: Swarm is not supported yet
|
||||||
if (! $this->data) {
|
if (! $this->data) {
|
||||||
throw new \Exception('No data provided');
|
throw new \Exception('No data provided');
|
||||||
|
|||||||
@@ -189,65 +189,66 @@ class ServiceApplication extends BaseModel
|
|||||||
public function getRequiredPort(): ?int
|
public function getRequiredPort(): ?int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// Normalize container name same way as variable creation
|
// Parse the Docker Compose to find SERVICE_URL/SERVICE_FQDN variables DIRECTLY DECLARED
|
||||||
// (uppercase, replace - and . with _)
|
// for this specific service container (not just referenced from other containers)
|
||||||
$normalizedName = str($this->name)
|
$dockerComposeRaw = data_get($this->service, 'docker_compose_raw');
|
||||||
->upper()
|
if (! $dockerComposeRaw) {
|
||||||
->replace('-', '_')
|
// Fall back to service-level port if no compose file
|
||||||
->replace('.', '_')
|
return $this->service->getRequiredPort();
|
||||||
->value();
|
}
|
||||||
// Get all environment variables from the service
|
|
||||||
$serviceEnvVars = $this->service->environment_variables()->get();
|
|
||||||
|
|
||||||
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
|
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||||
foreach ($serviceEnvVars as $envVar) {
|
$serviceConfig = data_get($dockerCompose, "services.{$this->name}");
|
||||||
$key = str($envVar->key);
|
if (! $serviceConfig) {
|
||||||
|
return $this->service->getRequiredPort();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
|
|
||||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
|
||||||
$suffix = $key->after('SERVICE_FQDN_');
|
|
||||||
} else {
|
|
||||||
$suffix = $key->after('SERVICE_URL_');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this variable starts with our normalized container name
|
// Extract SERVICE_URL and SERVICE_FQDN variables DIRECTLY DECLARED in this service's environment
|
||||||
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
|
// (not variables that are merely referenced with ${VAR} syntax)
|
||||||
if (! $suffix->startsWith($normalizedName)) {
|
$portFound = null;
|
||||||
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
|
foreach ($environment as $key => $value) {
|
||||||
'expected_start' => $normalizedName,
|
if (is_int($key) && is_string($value)) {
|
||||||
'actual_suffix' => $suffix->value(),
|
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
|
||||||
]);
|
// Extract variable name (before '=' if present)
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
|
||||||
continue;
|
// Only process direct declarations
|
||||||
}
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
// Parse to check if it has a port suffix
|
||||||
|
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||||
|
if ($parsed['has_port'] && $parsed['port']) {
|
||||||
|
// Found a port-specific variable for this service
|
||||||
|
$portFound = (int) $parsed['port'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost"
|
||||||
|
$envVarName = str($key);
|
||||||
|
|
||||||
// Check if there's a port suffix after the container name
|
// Only process direct declarations
|
||||||
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
$afterName = $suffix->after($normalizedName)->value();
|
// Parse to check if it has a port suffix
|
||||||
|
$parsed = parseServiceEnvironmentVariable($envVarName->value());
|
||||||
// If there's content after the name, it should start with underscore
|
if ($parsed['has_port'] && $parsed['port']) {
|
||||||
if ($afterName !== '' && str($afterName)->startsWith('_')) {
|
// Found a port-specific variable for this service
|
||||||
// Extract port: _3210 -> 3210
|
$portFound = (int) $parsed['port'];
|
||||||
$port = str($afterName)->after('_')->value();
|
break;
|
||||||
// Validate that the extracted port is numeric
|
}
|
||||||
if (is_numeric($port)) {
|
|
||||||
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
|
|
||||||
'port' => (int) $port,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (int) $port;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to service-level port if no port-specific variable is found
|
// If a port was found in the template, return it
|
||||||
$fallbackPort = $this->service->getRequiredPort();
|
if ($portFound !== null) {
|
||||||
|
return $portFound;
|
||||||
|
}
|
||||||
|
|
||||||
return $fallbackPort;
|
// No port-specific variables found for this service, return null
|
||||||
|
// (DO NOT fall back to service-level port, as that applies to all services)
|
||||||
|
return null;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,84 +514,96 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||||||
$key = str($key);
|
$key = str($key);
|
||||||
$value = replaceVariables($value);
|
$value = replaceVariables($value);
|
||||||
$command = parseCommandFromMagicEnvVariable($key);
|
$command = parseCommandFromMagicEnvVariable($key);
|
||||||
if ($command->value() === 'FQDN') {
|
if ($command->value() === 'FQDN' || $command->value() === 'URL') {
|
||||||
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
|
||||||
$originalFqdnFor = str($fqdnFor)->replace('_', '-');
|
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||||
if (str($fqdnFor)->contains('-')) {
|
$serviceName = $parsed['service_name'];
|
||||||
$fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
|
$port = $parsed['port'];
|
||||||
|
|
||||||
|
// Extract case-preserved service name from template
|
||||||
|
$strKey = str($key->value());
|
||||||
|
if ($parsed['has_port']) {
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->value();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Generated FQDN & URL
|
|
||||||
$fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
$url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
|
// Always normalize service names to match docker_compose_domains lookup
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
// Generate BOTH FQDN & URL
|
||||||
|
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
|
$url = generateUrl(server: $server, random: "$originalServiceName-$uuid");
|
||||||
|
|
||||||
|
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
|
||||||
|
// But $fqdn variable itself may contain scheme (used for database domain field)
|
||||||
|
// Strip scheme for environment variable values
|
||||||
|
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||||
|
|
||||||
|
// Append port if specified
|
||||||
|
$urlWithPort = $url;
|
||||||
|
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
|
||||||
|
if ($port && is_numeric($port)) {
|
||||||
|
$urlWithPort = "$url:$port";
|
||||||
|
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS create base SERVICE_FQDN variable (host only, no scheme)
|
||||||
$resource->environment_variables()->firstOrCreate([
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => "SERVICE_FQDN_{$serviceNamePreserved}",
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
], [
|
], [
|
||||||
'value' => $fqdn,
|
'value' => $fqdnValueForEnv,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
]);
|
]);
|
||||||
if ($resource->build_pack === 'dockercompose') {
|
|
||||||
// Check if a service with this name actually exists
|
|
||||||
$serviceExists = false;
|
|
||||||
foreach ($services as $serviceName => $service) {
|
|
||||||
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
|
||||||
if ($transformedServiceName === $fqdnFor) {
|
|
||||||
$serviceExists = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add domain if the service exists
|
// ALWAYS create base SERVICE_URL variable (with scheme)
|
||||||
if ($serviceExists) {
|
|
||||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
|
||||||
$domainExists = data_get($domains->get($fqdnFor), 'domain');
|
|
||||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
||||||
if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
|
|
||||||
$envExists->update([
|
|
||||||
'value' => $url,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (is_null($domainExists)) {
|
|
||||||
// Put URL in the domains array instead of FQDN
|
|
||||||
$domains->put((string) $fqdnFor, [
|
|
||||||
'domain' => $url,
|
|
||||||
]);
|
|
||||||
$resource->docker_compose_domains = $domains->toJson();
|
|
||||||
$resource->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} elseif ($command->value() === 'URL') {
|
|
||||||
// SERVICE_URL_APP or SERVICE_URL_APP_3000
|
|
||||||
// Detect if there's a port suffix
|
|
||||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
|
||||||
$urlFor = $parsed['service_name'];
|
|
||||||
$port = $parsed['port'];
|
|
||||||
$originalUrlFor = str($urlFor)->replace('_', '-');
|
|
||||||
if (str($urlFor)->contains('-')) {
|
|
||||||
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
|
|
||||||
}
|
|
||||||
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
|
|
||||||
// Append port if specified
|
|
||||||
$urlWithPort = $url;
|
|
||||||
if ($port && is_numeric($port)) {
|
|
||||||
$urlWithPort = "$url:$port";
|
|
||||||
}
|
|
||||||
$resource->environment_variables()->firstOrCreate([
|
$resource->environment_variables()->firstOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => "SERVICE_URL_{$serviceNamePreserved}",
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
], [
|
], [
|
||||||
'value' => $url,
|
'value' => $url,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// If port-specific, ALSO create port-specific pairs
|
||||||
|
if ($parsed['has_port'] && $port) {
|
||||||
|
$resource->environment_variables()->firstOrCreate([
|
||||||
|
'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}",
|
||||||
|
'resourceable_type' => get_class($resource),
|
||||||
|
'resourceable_id' => $resource->id,
|
||||||
|
], [
|
||||||
|
'value' => $fqdnValueForEnvWithPort,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->environment_variables()->firstOrCreate([
|
||||||
|
'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}",
|
||||||
|
'resourceable_type' => get_class($resource),
|
||||||
|
'resourceable_id' => $resource->id,
|
||||||
|
], [
|
||||||
|
'value' => $urlWithPort,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($resource->build_pack === 'dockercompose') {
|
if ($resource->build_pack === 'dockercompose') {
|
||||||
// Check if a service with this name actually exists
|
// Check if a service with this name actually exists
|
||||||
$serviceExists = false;
|
$serviceExists = false;
|
||||||
foreach ($services as $serviceName => $service) {
|
foreach ($services as $serviceNameKey => $service) {
|
||||||
$transformedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||||
if ($transformedServiceName === $urlFor) {
|
if ($transformedServiceName === $serviceName) {
|
||||||
$serviceExists = true;
|
$serviceExists = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -600,16 +612,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||||||
// Only add domain if the service exists
|
// Only add domain if the service exists
|
||||||
if ($serviceExists) {
|
if ($serviceExists) {
|
||||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
||||||
$domainExists = data_get($domains->get($urlFor), 'domain');
|
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
|
||||||
if ($domainExists !== $envExists->value) {
|
// Update domain using URL with port if applicable
|
||||||
$envExists->update([
|
$domainValue = $port ? $urlWithPort : $url;
|
||||||
'value' => $urlWithPort,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (is_null($domainExists)) {
|
if (is_null($domainExists)) {
|
||||||
$domains->put((string) $urlFor, [
|
$domains->put($serviceName, [
|
||||||
'domain' => $urlWithPort,
|
'domain' => $domainValue,
|
||||||
]);
|
]);
|
||||||
$resource->docker_compose_domains = $domains->toJson();
|
$resource->docker_compose_domains = $domains->toJson();
|
||||||
$resource->save();
|
$resource->save();
|
||||||
@@ -1584,109 +1594,115 @@ function serviceParser(Service $resource): Collection
|
|||||||
}
|
}
|
||||||
// Get magic environments where we need to preset the FQDN / URL
|
// Get magic environments where we need to preset the FQDN / URL
|
||||||
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
|
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
|
||||||
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 or SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||||
|
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
|
||||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
|
||||||
$urlFor = null;
|
// Extract service name preserving original case from template
|
||||||
$fqdnFor = $parsed['service_name'];
|
$strKey = str($key->value());
|
||||||
}
|
if ($parsed['has_port']) {
|
||||||
if ($key->startsWith('SERVICE_URL_')) {
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
$fqdnFor = null;
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
$urlFor = $parsed['service_name'];
|
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$port = $parsed['port'];
|
$port = $parsed['port'];
|
||||||
|
$fqdnFor = $parsed['service_name'];
|
||||||
|
|
||||||
// Only ServiceApplication has fqdn column, ServiceDatabase does not
|
// Only ServiceApplication has fqdn column, ServiceDatabase does not
|
||||||
$isServiceApplication = $savedService instanceof ServiceApplication;
|
$isServiceApplication = $savedService instanceof ServiceApplication;
|
||||||
|
|
||||||
if ($isServiceApplication && blank($savedService->fqdn)) {
|
if ($isServiceApplication && blank($savedService->fqdn)) {
|
||||||
if ($fqdnFor) {
|
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
$url = generateUrl($server, "$fqdnFor-$uuid");
|
||||||
} else {
|
|
||||||
$fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
||||||
}
|
|
||||||
if ($urlFor) {
|
|
||||||
$url = generateUrl($server, "$urlFor-$uuid");
|
|
||||||
} else {
|
|
||||||
$url = generateUrl($server, "{$savedService->name}-$uuid");
|
|
||||||
}
|
|
||||||
} elseif ($isServiceApplication) {
|
} elseif ($isServiceApplication) {
|
||||||
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
$fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
||||||
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
$url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
|
||||||
} else {
|
} else {
|
||||||
// For ServiceDatabase, generate fqdn/url without saving to the model
|
// For ServiceDatabase, generate fqdn/url without saving to the model
|
||||||
if ($fqdnFor) {
|
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
$url = generateUrl($server, "$fqdnFor-$uuid");
|
||||||
} else {
|
|
||||||
$fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
|
|
||||||
}
|
|
||||||
if ($urlFor) {
|
|
||||||
$url = generateUrl($server, "$urlFor-$uuid");
|
|
||||||
} else {
|
|
||||||
$url = generateUrl($server, "{$savedService->name}-$uuid");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
|
||||||
|
// But $fqdn variable itself may contain scheme (used for database domain field)
|
||||||
|
// Strip scheme for environment variable values
|
||||||
|
$fqdnValueForEnv = str($fqdn)->after('://')->value();
|
||||||
|
|
||||||
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
|
||||||
$path = $value->value();
|
$path = $value->value();
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
$fqdn = "$fqdn$path";
|
$fqdn = "$fqdn$path";
|
||||||
$url = "$url$path";
|
$url = "$url$path";
|
||||||
|
$fqdnValueForEnv = "$fqdnValueForEnv$path";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$fqdnWithPort = $fqdn;
|
|
||||||
$urlWithPort = $url;
|
$urlWithPort = $url;
|
||||||
|
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
|
||||||
if ($fqdn && $port) {
|
if ($fqdn && $port) {
|
||||||
$fqdnWithPort = "$fqdn:$port";
|
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
|
||||||
}
|
}
|
||||||
if ($url && $port) {
|
if ($url && $port) {
|
||||||
$urlWithPort = "$url:$port";
|
$urlWithPort = "$url:$port";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save fqdn to ServiceApplication, not ServiceDatabase
|
// Only save fqdn to ServiceApplication, not ServiceDatabase
|
||||||
if ($isServiceApplication && is_null($savedService->fqdn)) {
|
if ($isServiceApplication && is_null($savedService->fqdn)) {
|
||||||
|
// Save URL (with scheme) to database, not FQDN
|
||||||
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
|
||||||
if ($fqdnFor) {
|
$savedService->fqdn = $urlWithPort;
|
||||||
$savedService->fqdn = $fqdnWithPort;
|
|
||||||
}
|
|
||||||
if ($urlFor) {
|
|
||||||
$savedService->fqdn = $urlWithPort;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
$savedService->fqdn = $fqdnWithPort;
|
$savedService->fqdn = $urlWithPort;
|
||||||
}
|
}
|
||||||
$savedService->save();
|
$savedService->save();
|
||||||
}
|
}
|
||||||
if (! $parsed['has_port']) {
|
|
||||||
|
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
|
||||||
|
$resource->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_FQDN_{$serviceName}",
|
||||||
|
'resourceable_type' => get_class($resource),
|
||||||
|
'resourceable_id' => $resource->id,
|
||||||
|
], [
|
||||||
|
'value' => $fqdnValueForEnv,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->environment_variables()->updateOrCreate([
|
||||||
|
'key' => "SERVICE_URL_{$serviceName}",
|
||||||
|
'resourceable_type' => get_class($resource),
|
||||||
|
'resourceable_id' => $resource->id,
|
||||||
|
], [
|
||||||
|
'value' => $url,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For port-specific variables, ALSO create port-specific pairs
|
||||||
|
// If template variable has port, create both URL and FQDN with port suffix
|
||||||
|
if ($parsed['has_port'] && $port) {
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->updateOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
], [
|
], [
|
||||||
'value' => $fqdn,
|
'value' => $fqdnValueForEnvWithPort,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resource->environment_variables()->updateOrCreate([
|
$resource->environment_variables()->updateOrCreate([
|
||||||
'key' => $key->value(),
|
'key' => "SERVICE_URL_{$serviceName}_{$port}",
|
||||||
'resourceable_type' => get_class($resource),
|
|
||||||
'resourceable_id' => $resource->id,
|
|
||||||
], [
|
|
||||||
'value' => $url,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if ($parsed['has_port']) {
|
|
||||||
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
|
|
||||||
// keep the port suffix in the key and use the URL with port
|
|
||||||
$resource->environment_variables()->updateOrCreate([
|
|
||||||
'key' => $key->value(),
|
|
||||||
'resourceable_type' => get_class($resource),
|
|
||||||
'resourceable_id' => $resource->id,
|
|
||||||
], [
|
|
||||||
'value' => $fqdnWithPort,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
$resource->environment_variables()->updateOrCreate([
|
|
||||||
'key' => $key->value(),
|
|
||||||
'resourceable_type' => get_class($resource),
|
'resourceable_type' => get_class($resource),
|
||||||
'resourceable_id' => $resource->id,
|
'resourceable_id' => $resource->id,
|
||||||
], [
|
], [
|
||||||
|
|||||||
@@ -115,65 +115,170 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
|
|||||||
$resource->save();
|
$resource->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
// Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template
|
||||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
|
// to ensure we use the exact names defined in the template (which may be abbreviated)
|
||||||
$resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
|
// IMPORTANT: Only extract variables that are DIRECTLY DECLARED for this service,
|
||||||
|
// not variables that are merely referenced from other services
|
||||||
|
$serviceConfig = data_get($dockerCompose, "services.{$name}");
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
$templateVariableNames = [];
|
||||||
|
|
||||||
|
foreach ($environment as $key => $value) {
|
||||||
|
if (is_int($key) && is_string($value)) {
|
||||||
|
// List-style: "- SERVICE_URL_APP_3000" or "- SERVICE_URL_APP_3000=value"
|
||||||
|
// Extract variable name (before '=' if present)
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
// Only include if it's a direct declaration (not a reference like ${VAR})
|
||||||
|
// Direct declarations look like: SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||||
|
// References look like: NEXT_PUBLIC_URL=${SERVICE_URL_APP}
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style: "SERVICE_URL_APP_3000: value" or "SERVICE_FQDN_DB: localhost"
|
||||||
|
$envVarName = str($key);
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DO NOT extract variables that are only referenced with ${VAR_NAME} syntax
|
||||||
|
// Those belong to other services and will be updated when THOSE services are updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
$templateVariableNames = array_unique($templateVariableNames);
|
||||||
|
|
||||||
|
// Extract unique service names to process (preserving the original case from template)
|
||||||
|
// This allows us to create both URL and FQDN pairs regardless of which one is in the template
|
||||||
|
$serviceNamesToProcess = [];
|
||||||
|
foreach ($templateVariableNames as $templateVarName) {
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||||
|
|
||||||
|
// Extract the original service name with case preserved from the template
|
||||||
|
$strKey = str($templateVarName);
|
||||||
|
if ($parsed['has_port']) {
|
||||||
|
// For port-specific variables, get the name between SERVICE_URL_/SERVICE_FQDN_ and the last underscore
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For base variables, get everything after SERVICE_URL_/SERVICE_FQDN_
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use lowercase key for array indexing (to group case variations together)
|
||||||
|
$serviceKey = str($serviceName)->lower()->value();
|
||||||
|
|
||||||
|
// Track both base service name and port-specific variant
|
||||||
|
if (! isset($serviceNamesToProcess[$serviceKey])) {
|
||||||
|
$serviceNamesToProcess[$serviceKey] = [
|
||||||
|
'base' => $serviceName, // Preserve original case
|
||||||
|
'ports' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this variable has a port, track it
|
||||||
|
if ($parsed['has_port'] && $parsed['port']) {
|
||||||
|
$serviceNamesToProcess[$serviceKey]['ports'][] = $parsed['port'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all existing SERVICE_URL and SERVICE_FQDN variables for these service names
|
||||||
|
// We need to delete both URL and FQDN variants, with and without ports
|
||||||
|
foreach ($serviceNamesToProcess as $serviceInfo) {
|
||||||
|
$serviceName = $serviceInfo['base'];
|
||||||
|
|
||||||
|
// Delete base variables
|
||||||
|
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}")->delete();
|
||||||
|
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}")->delete();
|
||||||
|
|
||||||
|
// Delete port-specific variables
|
||||||
|
foreach ($serviceInfo['ports'] as $port) {
|
||||||
|
$resource->service->environment_variables()->where('key', "SERVICE_URL_{$serviceName}_{$port}")->delete();
|
||||||
|
$resource->service->environment_variables()->where('key', "SERVICE_FQDN_{$serviceName}_{$port}")->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($resource->fqdn) {
|
if ($resource->fqdn) {
|
||||||
$resourceFqdns = str($resource->fqdn)->explode(',');
|
$resourceFqdns = str($resource->fqdn)->explode(',');
|
||||||
$resourceFqdns = $resourceFqdns->first();
|
$resourceFqdns = $resourceFqdns->first();
|
||||||
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
|
||||||
$url = Url::fromString($resourceFqdns);
|
$url = Url::fromString($resourceFqdns);
|
||||||
$port = $url->getPort();
|
$port = $url->getPort();
|
||||||
$path = $url->getPath();
|
$path = $url->getPath();
|
||||||
|
|
||||||
|
// Prepare URL value (with scheme and host)
|
||||||
$urlValue = $url->getScheme().'://'.$url->getHost();
|
$urlValue = $url->getScheme().'://'.$url->getHost();
|
||||||
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
|
$urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
|
||||||
$resource->service->environment_variables()->updateOrCreate([
|
|
||||||
'resourceable_type' => Service::class,
|
// Prepare FQDN value (host only, no scheme)
|
||||||
'resourceable_id' => $resource->service_id,
|
$fqdnHost = $url->getHost();
|
||||||
'key' => $variableName,
|
$fqdnValue = str($fqdnHost)->after('://');
|
||||||
], [
|
if ($path !== '/') {
|
||||||
'value' => $urlValue,
|
$fqdnValue = $fqdnValue.$path;
|
||||||
'is_preview' => false,
|
}
|
||||||
]);
|
|
||||||
if ($port) {
|
// For each service name found in template, create BOTH SERVICE_URL and SERVICE_FQDN pairs
|
||||||
$variableName = $variableName."_$port";
|
foreach ($serviceNamesToProcess as $serviceInfo) {
|
||||||
|
$serviceName = $serviceInfo['base'];
|
||||||
|
$ports = array_unique($serviceInfo['ports']);
|
||||||
|
|
||||||
|
// ALWAYS create base pair (without port)
|
||||||
$resource->service->environment_variables()->updateOrCreate([
|
$resource->service->environment_variables()->updateOrCreate([
|
||||||
'resourceable_type' => Service::class,
|
'resourceable_type' => Service::class,
|
||||||
'resourceable_id' => $resource->service_id,
|
'resourceable_id' => $resource->service_id,
|
||||||
'key' => $variableName,
|
'key' => "SERVICE_URL_{$serviceName}",
|
||||||
], [
|
], [
|
||||||
'value' => $urlValue,
|
'value' => $urlValue,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
|
|
||||||
$fqdn = Url::fromString($resourceFqdns);
|
|
||||||
$port = $fqdn->getPort();
|
|
||||||
$path = $fqdn->getPath();
|
|
||||||
$fqdn = $fqdn->getHost();
|
|
||||||
$fqdnValue = str($fqdn)->after('://');
|
|
||||||
if ($path !== '/') {
|
|
||||||
$fqdnValue = $fqdnValue.$path;
|
|
||||||
}
|
|
||||||
$resource->service->environment_variables()->updateOrCreate([
|
|
||||||
'resourceable_type' => Service::class,
|
|
||||||
'resourceable_id' => $resource->service_id,
|
|
||||||
'key' => $variableName,
|
|
||||||
], [
|
|
||||||
'value' => $fqdnValue,
|
|
||||||
'is_preview' => false,
|
|
||||||
]);
|
|
||||||
if ($port) {
|
|
||||||
$variableName = $variableName."_$port";
|
|
||||||
$resource->service->environment_variables()->updateOrCreate([
|
$resource->service->environment_variables()->updateOrCreate([
|
||||||
'resourceable_type' => Service::class,
|
'resourceable_type' => Service::class,
|
||||||
'resourceable_id' => $resource->service_id,
|
'resourceable_id' => $resource->service_id,
|
||||||
'key' => $variableName,
|
'key' => "SERVICE_FQDN_{$serviceName}",
|
||||||
], [
|
], [
|
||||||
'value' => $fqdnValue,
|
'value' => $fqdnValue,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create port-specific pairs for each port found in template or FQDN
|
||||||
|
$allPorts = $ports;
|
||||||
|
if ($port && ! in_array($port, $allPorts)) {
|
||||||
|
$allPorts[] = $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($allPorts as $portNum) {
|
||||||
|
$urlWithPort = $urlValue.':'.$portNum;
|
||||||
|
$fqdnWithPort = $fqdnValue.':'.$portNum;
|
||||||
|
|
||||||
|
$resource->service->environment_variables()->updateOrCreate([
|
||||||
|
'resourceable_type' => Service::class,
|
||||||
|
'resourceable_id' => $resource->service_id,
|
||||||
|
'key' => "SERVICE_URL_{$serviceName}_{$portNum}",
|
||||||
|
], [
|
||||||
|
'value' => $urlWithPort,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resource->service->environment_variables()->updateOrCreate([
|
||||||
|
'resourceable_type' => Service::class,
|
||||||
|
'resourceable_id' => $resource->service_id,
|
||||||
|
'key' => "SERVICE_FQDN_{$serviceName}_{$portNum}",
|
||||||
|
], [
|
||||||
|
'value' => $fqdnWithPort,
|
||||||
|
'is_preview' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -254,7 +254,8 @@
|
|||||||
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
|
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
|
||||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
|
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
|
||||||
</div>
|
</div>
|
||||||
<div x-show="modalOpen" x-trap.inert="modalOpen" x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
<div x-show="modalOpen" x-trap.inert="modalOpen"
|
||||||
|
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
|
||||||
x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95"
|
x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95"
|
||||||
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150"
|
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150"
|
||||||
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
||||||
@@ -271,8 +272,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning"
|
<svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning"
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||||
stroke-width="4">
|
|
||||||
</circle>
|
</circle>
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
@@ -311,8 +311,8 @@
|
|||||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -327,13 +327,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if ($loadingServers)
|
@if ($loadingServers)
|
||||||
<div
|
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
fill="none" viewBox="0 0 24 24">
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
viewBox="0 0 24 24">
|
stroke-width="4"></circle>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
||||||
stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
</path>
|
</path>
|
||||||
@@ -343,8 +341,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@elseif (count($availableServers) > 0)
|
@elseif (count($availableServers) > 0)
|
||||||
@foreach ($availableServers as $index => $server)
|
@foreach ($availableServers as $index => $server)
|
||||||
<button type="button"
|
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)"
|
||||||
wire:click="selectServer({{ $server['id'] }}, true)"
|
|
||||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||||
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -352,8 +349,7 @@
|
|||||||
{{ $server['name'] }}
|
{{ $server['name'] }}
|
||||||
</div>
|
</div>
|
||||||
@if (!empty($server['description']))
|
@if (!empty($server['description']))
|
||||||
<div
|
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
{{ $server['description'] }}
|
{{ $server['description'] }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -363,10 +359,10 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -388,10 +384,10 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -406,13 +402,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if ($loadingDestinations)
|
@if ($loadingDestinations)
|
||||||
<div
|
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
fill="none" viewBox="0 0 24 24">
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
viewBox="0 0 24 24">
|
stroke-width="4"></circle>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
||||||
stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
</path>
|
</path>
|
||||||
@@ -422,25 +416,22 @@
|
|||||||
</div>
|
</div>
|
||||||
@elseif (count($availableDestinations) > 0)
|
@elseif (count($availableDestinations) > 0)
|
||||||
@foreach ($availableDestinations as $index => $destination)
|
@foreach ($availableDestinations as $index => $destination)
|
||||||
<button type="button"
|
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
|
||||||
wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
|
|
||||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||||
<div
|
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-neutral-900 dark:text-white">
|
<div class="font-medium text-neutral-900 dark:text-white">
|
||||||
{{ $destination['name'] }}
|
{{ $destination['name'] }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
Network: {{ $destination['network'] }}
|
Network: {{ $destination['network'] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -462,10 +453,10 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -480,13 +471,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if ($loadingProjects)
|
@if ($loadingProjects)
|
||||||
<div
|
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
fill="none" viewBox="0 0 24 24">
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
viewBox="0 0 24 24">
|
stroke-width="4"></circle>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
||||||
stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
</path>
|
</path>
|
||||||
@@ -496,18 +485,15 @@
|
|||||||
</div>
|
</div>
|
||||||
@elseif (count($availableProjects) > 0)
|
@elseif (count($availableProjects) > 0)
|
||||||
@foreach ($availableProjects as $index => $project)
|
@foreach ($availableProjects as $index => $project)
|
||||||
<button type="button"
|
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)"
|
||||||
wire:click="selectProject('{{ $project['uuid'] }}', true)"
|
|
||||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||||
<div
|
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-neutral-900 dark:text-white">
|
<div class="font-medium text-neutral-900 dark:text-white">
|
||||||
{{ $project['name'] }}
|
{{ $project['name'] }}
|
||||||
</div>
|
</div>
|
||||||
@if (!empty($project['description']))
|
@if (!empty($project['description']))
|
||||||
<div
|
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
{{ $project['description'] }}
|
{{ $project['description'] }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -517,10 +503,10 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -542,10 +528,10 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
|
||||||
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M15 19l-7-7 7-7" />
|
d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
@@ -560,13 +546,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if ($loadingEnvironments)
|
@if ($loadingEnvironments)
|
||||||
<div
|
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
||||||
class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
|
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg class="animate-spin h-5 w-5 text-yellow-500"
|
fill="none" viewBox="0 0 24 24">
|
||||||
xmlns="http://www.w3.org/2000/svg" fill="none"
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||||
viewBox="0 0 24 24">
|
stroke-width="4"></circle>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
||||||
stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor"
|
<path class="opacity-75" fill="currentColor"
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||||
</path>
|
</path>
|
||||||
@@ -576,18 +560,15 @@
|
|||||||
</div>
|
</div>
|
||||||
@elseif (count($availableEnvironments) > 0)
|
@elseif (count($availableEnvironments) > 0)
|
||||||
@foreach ($availableEnvironments as $index => $environment)
|
@foreach ($availableEnvironments as $index => $environment)
|
||||||
<button type="button"
|
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
|
||||||
wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
|
|
||||||
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
|
||||||
<div
|
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
||||||
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-neutral-900 dark:text-white">
|
<div class="font-medium text-neutral-900 dark:text-white">
|
||||||
{{ $environment['name'] }}
|
{{ $environment['name'] }}
|
||||||
</div>
|
</div>
|
||||||
@if (!empty($environment['description']))
|
@if (!empty($environment['description']))
|
||||||
<div
|
<div class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
|
||||||
{{ $environment['description'] }}
|
{{ $environment['description'] }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -597,10 +578,10 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
|
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -639,8 +620,7 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<span
|
<span class="font-medium text-neutral-900 dark:text-white truncate">
|
||||||
class="font-medium text-neutral-900 dark:text-white truncate">
|
|
||||||
{{ $result['name'] }}
|
{{ $result['name'] }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -661,15 +641,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (!empty($result['project']) && !empty($result['environment']))
|
@if (!empty($result['project']) && !empty($result['environment']))
|
||||||
<div
|
<div class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||||
class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
|
||||||
{{ $result['project'] }} /
|
{{ $result['project'] }} /
|
||||||
{{ $result['environment'] }}
|
{{ $result['environment'] }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@if (!empty($result['description']))
|
@if (!empty($result['description']))
|
||||||
<div
|
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
class="text-sm text-neutral-600 dark:text-neutral-400">
|
|
||||||
{{ Str::limit($result['description'], 80) }}
|
{{ Str::limit($result['description'], 80) }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -677,8 +655,8 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -708,16 +686,15 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div
|
<div class="font-medium text-neutral-900 dark:text-white truncate">
|
||||||
class="font-medium text-neutral-900 dark:text-white truncate">
|
|
||||||
{{ $item['name'] }}
|
{{ $item['name'] }}
|
||||||
</div>
|
</div>
|
||||||
@if (isset($item['quickcommand']))
|
@if (isset($item['quickcommand']))
|
||||||
@@ -725,8 +702,7 @@
|
|||||||
class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span>
|
class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||||
class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
|
||||||
{{ $item['description'] }}
|
{{ $item['description'] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,8 +710,8 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
|
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -820,8 +796,7 @@
|
|||||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||||
fill="none" viewBox="0 0 24 24"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -869,14 +844,6 @@
|
|||||||
<p class="mt-2 text-xs text-neutral-400 dark:text-neutral-500">
|
<p class="mt-2 text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
💡 Tip: Search for service names like "wordpress", "postgres", or "redis"
|
💡 Tip: Search for service names like "wordpress", "postgres", or "redis"
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4">
|
|
||||||
<a href="{{ route('onboarding') }}" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-coollabs dark:bg-warning hover:bg-coollabs-100 dark:hover:bg-warning/90 rounded-lg transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
||||||
</svg>
|
|
||||||
View Onboarding Guide
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -897,12 +864,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -915,8 +880,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New Project</h3>
|
<h3 class="text-2xl font-bold">New Project</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -939,12 +904,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -957,8 +920,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New Server</h3>
|
<h3 class="text-2xl font-bold">New Server</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -981,12 +944,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -999,8 +960,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New Team</h3>
|
<h3 class="text-2xl font-bold">New Team</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -1023,12 +984,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -1041,8 +1000,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New S3 Storage</h3>
|
<h3 class="text-2xl font-bold">New S3 Storage</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -1065,12 +1024,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -1083,8 +1040,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New Private Key</h3>
|
<h3 class="text-2xl font-bold">New Private Key</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -1107,12 +1064,10 @@
|
|||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
})"
|
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
||||||
class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
|
||||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
||||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
|
||||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||||
@@ -1125,8 +1080,8 @@
|
|||||||
<h3 class="text-2xl font-bold">New GitHub App</h3>
|
<h3 class="text-2xl font-bold">New GitHub App</h3>
|
||||||
<button @click="modalOpen=false"
|
<button @click="modalOpen=false"
|
||||||
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
|
||||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -1139,4 +1094,4 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
182
tests/Unit/ApplicationParserStringableTest.php
Normal file
182
tests/Unit/ApplicationParserStringableTest.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests to verify that the applicationParser function in parsers.php
|
||||||
|
* properly converts Stringable objects to plain strings to fix strict
|
||||||
|
* comparison and collection key lookup issues.
|
||||||
|
*
|
||||||
|
* Related issue: Lines 539 and 541 in parsers.php were creating Stringable
|
||||||
|
* objects which caused:
|
||||||
|
* - Strict comparisons (===) to fail (line 606)
|
||||||
|
* - Collection key lookups to fail (line 615)
|
||||||
|
*/
|
||||||
|
it('ensures service name normalization returns plain strings not Stringable objects', function () {
|
||||||
|
// Test the exact transformations that happen in parsers.php lines 539-541
|
||||||
|
|
||||||
|
// Simulate what happens at line 520
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
|
||||||
|
$serviceName = $parsed['service_name']; // 'my-service'
|
||||||
|
|
||||||
|
// Line 539: $originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
|
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
|
|
||||||
|
// Line 541: $serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
// Verify both are plain strings, not Stringable objects
|
||||||
|
expect(is_string($originalServiceName))->toBeTrue('$originalServiceName should be a plain string');
|
||||||
|
expect(is_string($serviceName))->toBeTrue('$serviceName should be a plain string');
|
||||||
|
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||||
|
expect($serviceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||||
|
|
||||||
|
// Verify the transformations work correctly
|
||||||
|
expect($originalServiceName)->toBe('my-service');
|
||||||
|
expect($serviceName)->toBe('my_service');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures strict comparison works with normalized service names', function () {
|
||||||
|
// This tests the fix for line 606 where strict comparison failed
|
||||||
|
|
||||||
|
// Simulate service name from docker-compose services array (line 604-605)
|
||||||
|
$serviceNameKey = 'my-service';
|
||||||
|
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
// Simulate service name from environment variable parsing (line 520, 541)
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-service');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
// Line 606: if ($transformedServiceName === $serviceName)
|
||||||
|
// This MUST work - both should be plain strings and match
|
||||||
|
expect($transformedServiceName === $serviceName)->toBeTrue(
|
||||||
|
'Strict comparison should work when both are plain strings'
|
||||||
|
);
|
||||||
|
expect($transformedServiceName)->toBe($serviceName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures collection key lookup works with normalized service names', function () {
|
||||||
|
// This tests the fix for line 615 where collection->get() failed
|
||||||
|
|
||||||
|
// Simulate service name normalization (line 541)
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_app-name');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
// Create a collection like $domains at line 614
|
||||||
|
$domains = collect([
|
||||||
|
'app_name' => [
|
||||||
|
'domain' => 'https://example.com',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Line 615: $domainExists = data_get($domains->get($serviceName), 'domain');
|
||||||
|
// This MUST work - $serviceName should be a plain string 'app_name'
|
||||||
|
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||||
|
|
||||||
|
expect($domainExists)->toBe('https://example.com', 'Collection lookup should find the domain');
|
||||||
|
expect($domainExists)->not->toBeNull('Collection lookup should not return null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles service names with dots correctly', function () {
|
||||||
|
// Test service names with dots (e.g., 'my.service')
|
||||||
|
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my.service');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
expect(is_string($serviceName))->toBeTrue();
|
||||||
|
expect($serviceName)->toBe('my_service');
|
||||||
|
|
||||||
|
// Verify it matches transformed service name from docker-compose
|
||||||
|
$serviceNameKey = 'my.service';
|
||||||
|
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
expect($transformedServiceName === $serviceName)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles service names with underscores correctly', function () {
|
||||||
|
// Test service names that already have underscores
|
||||||
|
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
expect(is_string($serviceName))->toBeTrue();
|
||||||
|
expect($serviceName)->toBe('my_service');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed special characters in service names', function () {
|
||||||
|
// Test service names with mix of dashes, dots, underscores
|
||||||
|
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my-app.service_v2');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
expect(is_string($serviceName))->toBeTrue();
|
||||||
|
expect($serviceName)->toBe('my_app_service_v2');
|
||||||
|
|
||||||
|
// Verify collection operations work
|
||||||
|
$domains = collect([
|
||||||
|
'my_app_service_v2' => ['domain' => 'https://test.com'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$found = $domains->get($serviceName);
|
||||||
|
expect($found)->not->toBeNull();
|
||||||
|
expect($found['domain'])->toBe('https://test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures originalServiceName conversion works for FQDN generation', function () {
|
||||||
|
// Test line 539: $originalServiceName conversion
|
||||||
|
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_my_service');
|
||||||
|
$serviceName = $parsed['service_name']; // 'my_service'
|
||||||
|
|
||||||
|
// Line 539: Convert underscores to dashes for FQDN generation
|
||||||
|
$originalServiceName = str($serviceName)->replace('_', '-')->value();
|
||||||
|
|
||||||
|
expect(is_string($originalServiceName))->toBeTrue();
|
||||||
|
expect($originalServiceName)->not->toBeInstanceOf(\Illuminate\Support\Stringable::class);
|
||||||
|
expect($originalServiceName)->toBe('my-service');
|
||||||
|
|
||||||
|
// Verify it can be used in string interpolation (line 544)
|
||||||
|
$uuid = 'test-uuid';
|
||||||
|
$random = "$originalServiceName-$uuid";
|
||||||
|
expect($random)->toBe('my-service-test-uuid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents duplicate domain entries in collection', function () {
|
||||||
|
// This tests that using plain strings prevents duplicate entries
|
||||||
|
// (one with Stringable key, one with string key)
|
||||||
|
|
||||||
|
$parsed = parseServiceEnvironmentVariable('SERVICE_URL_webapp');
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$serviceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||||
|
|
||||||
|
$domains = collect();
|
||||||
|
|
||||||
|
// Add domain entry (line 621)
|
||||||
|
$domains->put($serviceName, [
|
||||||
|
'domain' => 'https://webapp.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to lookup the domain (line 615)
|
||||||
|
$found = $domains->get($serviceName);
|
||||||
|
|
||||||
|
expect($found)->not->toBeNull('Should find the domain we just added');
|
||||||
|
expect($found['domain'])->toBe('https://webapp.com');
|
||||||
|
|
||||||
|
// Verify only one entry exists
|
||||||
|
expect($domains->count())->toBe(1);
|
||||||
|
expect($domains->has($serviceName))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies parsers.php has the ->value() calls', function () {
|
||||||
|
// Ensure the fix is actually in the code
|
||||||
|
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||||
|
|
||||||
|
// Line 539: Check originalServiceName conversion
|
||||||
|
expect($parsersFile)->toContain("str(\$serviceName)->replace('_', '-')->value()");
|
||||||
|
|
||||||
|
// Line 541: Check serviceName normalization
|
||||||
|
expect($parsersFile)->toContain("str(\$serviceName)->replace('-', '_')->replace('.', '_')->value()");
|
||||||
|
});
|
||||||
190
tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
Normal file
190
tests/Unit/ApplicationServiceEnvironmentVariablesTest.php
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests to verify that Applications using Docker Compose handle
|
||||||
|
* SERVICE_URL and SERVICE_FQDN environment variables correctly.
|
||||||
|
*
|
||||||
|
* This ensures consistency with Service behavior where BOTH URL and FQDN
|
||||||
|
* pairs are always created together, regardless of which one is in the template.
|
||||||
|
*/
|
||||||
|
it('ensures parsers.php creates both URL and FQDN pairs for applications', function () {
|
||||||
|
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||||
|
|
||||||
|
// Check that the fix is in place
|
||||||
|
expect($parsersFile)->toContain('ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs');
|
||||||
|
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||||
|
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts service name with case preservation for applications', function () {
|
||||||
|
// Simulate what the parser does for applications
|
||||||
|
$templateVar = 'SERVICE_URL_WORDPRESS';
|
||||||
|
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
if ($parsed['has_port']) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($serviceName)->toBe('WORDPRESS');
|
||||||
|
expect($parsed['service_name'])->toBe('wordpress'); // lowercase for internal use
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles port-specific application service variables', function () {
|
||||||
|
$templateVar = 'SERVICE_URL_APP_3000';
|
||||||
|
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
if ($parsed['has_port']) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($serviceName)->toBe('APP');
|
||||||
|
expect($parsed['port'])->toBe('3000');
|
||||||
|
expect($parsed['has_port'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('application should create 2 base variables when template has base SERVICE_URL', function () {
|
||||||
|
// Given: Template defines SERVICE_URL_WP
|
||||||
|
// Then: Should create both:
|
||||||
|
// 1. SERVICE_URL_WP
|
||||||
|
// 2. SERVICE_FQDN_WP
|
||||||
|
|
||||||
|
$templateVar = 'SERVICE_URL_WP';
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
|
||||||
|
$urlKey = "SERVICE_URL_{$serviceName}";
|
||||||
|
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||||
|
|
||||||
|
expect($urlKey)->toBe('SERVICE_URL_WP');
|
||||||
|
expect($fqdnKey)->toBe('SERVICE_FQDN_WP');
|
||||||
|
expect($parsed['has_port'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('application should create 4 variables when template has port-specific SERVICE_URL', function () {
|
||||||
|
// Given: Template defines SERVICE_URL_APP_8080
|
||||||
|
// Then: Should create all 4:
|
||||||
|
// 1. SERVICE_URL_APP (base)
|
||||||
|
// 2. SERVICE_FQDN_APP (base)
|
||||||
|
// 3. SERVICE_URL_APP_8080 (port-specific)
|
||||||
|
// 4. SERVICE_FQDN_APP_8080 (port-specific)
|
||||||
|
|
||||||
|
$templateVar = 'SERVICE_URL_APP_8080';
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
$port = $parsed['port'];
|
||||||
|
|
||||||
|
$baseUrlKey = "SERVICE_URL_{$serviceName}";
|
||||||
|
$baseFqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||||
|
$portUrlKey = "SERVICE_URL_{$serviceName}_{$port}";
|
||||||
|
$portFqdnKey = "SERVICE_FQDN_{$serviceName}_{$port}";
|
||||||
|
|
||||||
|
expect($baseUrlKey)->toBe('SERVICE_URL_APP');
|
||||||
|
expect($baseFqdnKey)->toBe('SERVICE_FQDN_APP');
|
||||||
|
expect($portUrlKey)->toBe('SERVICE_URL_APP_8080');
|
||||||
|
expect($portFqdnKey)->toBe('SERVICE_FQDN_APP_8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('application should create pairs when template has only SERVICE_FQDN', function () {
|
||||||
|
// Given: Template defines SERVICE_FQDN_DB
|
||||||
|
// Then: Should create both:
|
||||||
|
// 1. SERVICE_FQDN_DB
|
||||||
|
// 2. SERVICE_URL_DB (created automatically)
|
||||||
|
|
||||||
|
$templateVar = 'SERVICE_FQDN_DB';
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||||
|
|
||||||
|
$urlKey = "SERVICE_URL_{$serviceName}";
|
||||||
|
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||||
|
|
||||||
|
expect($fqdnKey)->toBe('SERVICE_FQDN_DB');
|
||||||
|
expect($urlKey)->toBe('SERVICE_URL_DB');
|
||||||
|
expect($parsed['has_port'])->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies application deletion nulls both URL and FQDN', function () {
|
||||||
|
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||||
|
|
||||||
|
// Check that deletion handles both types
|
||||||
|
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceNameFormatted}');
|
||||||
|
expect($parsersFile)->toContain('SERVICE_URL_{$serviceNameFormatted}');
|
||||||
|
|
||||||
|
// Both should be set to null when domain is empty
|
||||||
|
expect($parsersFile)->toContain('\'value\' => null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles abbreviated service names in applications', function () {
|
||||||
|
// Applications can have abbreviated names in compose files just like services
|
||||||
|
$templateVar = 'SERVICE_URL_WP'; // WordPress abbreviated
|
||||||
|
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
|
||||||
|
expect($serviceName)->toBe('WP');
|
||||||
|
expect($serviceName)->not->toBe('WORDPRESS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('application compose parsing creates pairs regardless of template type', function () {
|
||||||
|
// Test that whether template uses SERVICE_URL or SERVICE_FQDN,
|
||||||
|
// the parser creates both
|
||||||
|
|
||||||
|
$testCases = [
|
||||||
|
'SERVICE_URL_APP' => ['base' => 'APP', 'port' => null],
|
||||||
|
'SERVICE_FQDN_APP' => ['base' => 'APP', 'port' => null],
|
||||||
|
'SERVICE_URL_APP_3000' => ['base' => 'APP', 'port' => '3000'],
|
||||||
|
'SERVICE_FQDN_APP_3000' => ['base' => 'APP', 'port' => '3000'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testCases as $templateVar => $expected) {
|
||||||
|
$strKey = str($templateVar);
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
|
||||||
|
if ($parsed['has_port']) {
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||||
|
$serviceName = $strKey->after('SERVICE_URL_')->value();
|
||||||
|
} else {
|
||||||
|
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($serviceName)->toBe($expected['base'], "Failed for $templateVar");
|
||||||
|
expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $templateVar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies both application and service use same logic', function () {
|
||||||
|
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||||
|
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||||
|
|
||||||
|
// Both should have the same pattern of creating pairs
|
||||||
|
expect($servicesFile)->toContain('ALWAYS create base pair');
|
||||||
|
expect($parsersFile)->toContain('ALWAYS create BOTH');
|
||||||
|
|
||||||
|
// Both should create SERVICE_URL_
|
||||||
|
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||||
|
expect($parsersFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||||
|
|
||||||
|
// Both should create SERVICE_FQDN_
|
||||||
|
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||||
|
expect($parsersFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||||
|
});
|
||||||
@@ -151,3 +151,120 @@ it('checks if all FQDNs have port - null FQDN', function () {
|
|||||||
|
|
||||||
expect($result)->toBeFalse();
|
expect($result)->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects port from map-style SERVICE_URL environment variable', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
trigger:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_TRIGGER_3000: ""
|
||||||
|
OTHER_VAR: value
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$service->docker_compose_raw = $yaml;
|
||||||
|
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||||
|
|
||||||
|
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||||
|
$app->name = 'trigger';
|
||||||
|
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||||
|
$app->service = $service;
|
||||||
|
|
||||||
|
// Call the actual getRequiredPort method
|
||||||
|
$result = $app->getRequiredPort();
|
||||||
|
|
||||||
|
expect($result)->toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects port from map-style SERVICE_FQDN environment variable', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
langfuse:
|
||||||
|
environment:
|
||||||
|
SERVICE_FQDN_LANGFUSE_3000: localhost
|
||||||
|
DATABASE_URL: postgres://...
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$service->docker_compose_raw = $yaml;
|
||||||
|
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||||
|
|
||||||
|
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||||
|
$app->name = 'langfuse';
|
||||||
|
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||||
|
$app->service = $service;
|
||||||
|
|
||||||
|
$result = $app->getRequiredPort();
|
||||||
|
|
||||||
|
expect($result)->toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for map-style environment without port', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
environment:
|
||||||
|
SERVICE_FQDN_DB: localhost
|
||||||
|
SERVICE_URL_DB: http://localhost
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$service->docker_compose_raw = $yaml;
|
||||||
|
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||||
|
|
||||||
|
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||||
|
$app->name = 'db';
|
||||||
|
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||||
|
$app->service = $service;
|
||||||
|
|
||||||
|
$result = $app->getRequiredPort();
|
||||||
|
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles list-style environment with port', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
umami:
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_UMAMI_3000
|
||||||
|
- DATABASE_URL=postgres://db/umami
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$service->docker_compose_raw = $yaml;
|
||||||
|
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||||
|
|
||||||
|
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||||
|
$app->name = 'umami';
|
||||||
|
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||||
|
$app->service = $service;
|
||||||
|
|
||||||
|
$result = $app->getRequiredPort();
|
||||||
|
|
||||||
|
expect($result)->toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes first port found in environment', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
multi:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_MULTI_3000: ""
|
||||||
|
SERVICE_URL_MULTI_8080: ""
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$service = Mockery::mock(Service::class)->makePartial();
|
||||||
|
$service->docker_compose_raw = $yaml;
|
||||||
|
$service->shouldReceive('getRequiredPort')->andReturn(null);
|
||||||
|
|
||||||
|
$app = Mockery::mock(ServiceApplication::class)->makePartial();
|
||||||
|
$app->name = 'multi';
|
||||||
|
$app->shouldReceive('getAttribute')->with('service')->andReturn($service);
|
||||||
|
$app->service = $service;
|
||||||
|
|
||||||
|
$result = $app->getRequiredPort();
|
||||||
|
|
||||||
|
// Should return one of the ports (depends on array iteration order)
|
||||||
|
expect($result)->toBeIn([3000, 8080]);
|
||||||
|
});
|
||||||
|
|||||||
563
tests/Unit/UpdateComposeAbbreviatedVariablesTest.php
Normal file
563
tests/Unit/UpdateComposeAbbreviatedVariablesTest.php
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests to verify that updateCompose() correctly handles abbreviated
|
||||||
|
* SERVICE_URL and SERVICE_FQDN variable names from templates.
|
||||||
|
*
|
||||||
|
* This tests the fix for GitHub issue #7243 where SERVICE_URL_OPDASHBOARD
|
||||||
|
* wasn't being updated when the domain changed, while SERVICE_URL_OPDASHBOARD_3000
|
||||||
|
* was being updated correctly.
|
||||||
|
*
|
||||||
|
* The issue occurs when template variable names are abbreviated (e.g., OPDASHBOARD)
|
||||||
|
* instead of using the full container name (e.g., OPENPANEL_DASHBOARD).
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
it('detects SERVICE_URL variables directly declared in template environment', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
openpanel-dashboard:
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_OPDASHBOARD_3000
|
||||||
|
- OTHER_VAR=value
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $envVar) {
|
||||||
|
if (is_string($envVar)) {
|
||||||
|
$envVarName = str($envVar)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
|
||||||
|
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only detects directly declared SERVICE_URL variables not references', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
openpanel-dashboard:
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_OPDASHBOARD_3000
|
||||||
|
- NEXT_PUBLIC_DASHBOARD_URL=${SERVICE_URL_OPDASHBOARD}
|
||||||
|
- NEXT_PUBLIC_API_URL=${SERVICE_URL_OPAPI}
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.openpanel-dashboard');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $envVar) {
|
||||||
|
if (is_string($envVar)) {
|
||||||
|
$envVarName = str($envVar)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only detect the direct declaration
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_OPDASHBOARD_3000');
|
||||||
|
// Should NOT detect references (those belong to other services)
|
||||||
|
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPDASHBOARD');
|
||||||
|
expect($templateVariableNames)->not->toContain('SERVICE_URL_OPAPI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects multiple directly declared SERVICE_URL variables', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_APP
|
||||||
|
- SERVICE_URL_APP_3000
|
||||||
|
- SERVICE_FQDN_API
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $envVar) {
|
||||||
|
if (is_string($envVar)) {
|
||||||
|
// Extract variable name (before '=' if present)
|
||||||
|
$envVarName = str($envVar)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateVariableNames = array_unique($templateVariableNames);
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toHaveCount(3);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_FQDN_API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes duplicates from template variable names', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- SERVICE_URL_APP
|
||||||
|
- PUBLIC_URL=${SERVICE_URL_APP}
|
||||||
|
- PRIVATE_URL=${SERVICE_URL_APP}
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $envVar) {
|
||||||
|
if (is_string($envVar)) {
|
||||||
|
$envVarName = str($envVar)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_string($envVar) && str($envVar)->contains('${')) {
|
||||||
|
preg_match_all('/\$\{(SERVICE_(?:FQDN|URL)_[^}]+)\}/', $envVar, $matches);
|
||||||
|
if (! empty($matches[1])) {
|
||||||
|
foreach ($matches[1] as $match) {
|
||||||
|
$templateVariableNames[] = $match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateVariableNames = array_unique($templateVariableNames);
|
||||||
|
|
||||||
|
// SERVICE_URL_APP appears 3 times but should only be in array once
|
||||||
|
expect($templateVariableNames)->toHaveCount(1);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects SERVICE_FQDN variables in addition to SERVICE_URL', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
- SERVICE_FQDN_APP
|
||||||
|
- SERVICE_FQDN_APP_3000
|
||||||
|
- SERVICE_URL_APP
|
||||||
|
- SERVICE_URL_APP_8080
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $envVar) {
|
||||||
|
if (is_string($envVar)) {
|
||||||
|
$envVarName = str($envVar)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toHaveCount(4);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_FQDN_APP_3000');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP_8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles abbreviated service names that differ from container names', function () {
|
||||||
|
// This is the actual OpenPanel case from GitHub issue #7243
|
||||||
|
// Container name: openpanel-dashboard
|
||||||
|
// Template variable: SERVICE_URL_OPDASHBOARD (abbreviated)
|
||||||
|
|
||||||
|
$containerName = 'openpanel-dashboard';
|
||||||
|
$templateVariableName = 'SERVICE_URL_OPDASHBOARD';
|
||||||
|
|
||||||
|
// The old logic would generate this from container name:
|
||||||
|
$generatedFromContainer = 'SERVICE_URL_'.str($containerName)->upper()->replace('-', '_')->value();
|
||||||
|
|
||||||
|
// This shows the mismatch
|
||||||
|
expect($generatedFromContainer)->toBe('SERVICE_URL_OPENPANEL_DASHBOARD');
|
||||||
|
expect($generatedFromContainer)->not->toBe($templateVariableName);
|
||||||
|
|
||||||
|
// The template uses the abbreviated form
|
||||||
|
expect($templateVariableName)->toBe('SERVICE_URL_OPDASHBOARD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly identifies abbreviated variable patterns', function () {
|
||||||
|
$tests = [
|
||||||
|
// Full name transformations (old logic)
|
||||||
|
['container' => 'openpanel-dashboard', 'generated' => 'SERVICE_URL_OPENPANEL_DASHBOARD'],
|
||||||
|
['container' => 'my-long-service', 'generated' => 'SERVICE_URL_MY_LONG_SERVICE'],
|
||||||
|
|
||||||
|
// Abbreviated forms (template logic)
|
||||||
|
['container' => 'openpanel-dashboard', 'template' => 'SERVICE_URL_OPDASHBOARD'],
|
||||||
|
['container' => 'openpanel-api', 'template' => 'SERVICE_URL_OPAPI'],
|
||||||
|
['container' => 'my-long-service', 'template' => 'SERVICE_URL_MLS'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($tests as $test) {
|
||||||
|
if (isset($test['generated'])) {
|
||||||
|
$generated = 'SERVICE_URL_'.str($test['container'])->upper()->replace('-', '_')->value();
|
||||||
|
expect($generated)->toBe($test['generated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($test['template'])) {
|
||||||
|
// Template abbreviations can't be generated from container name
|
||||||
|
// They must be parsed from the actual template
|
||||||
|
expect($test['template'])->toMatch('/^SERVICE_URL_[A-Z0-9_]+$/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies direct declarations are not confused with references', function () {
|
||||||
|
// Direct declarations should be detected
|
||||||
|
$directDeclaration = 'SERVICE_URL_APP';
|
||||||
|
expect(str($directDeclaration)->startsWith('SERVICE_URL_'))->toBeTrue();
|
||||||
|
expect(str($directDeclaration)->before('=')->value())->toBe('SERVICE_URL_APP');
|
||||||
|
|
||||||
|
// References should not be detected as declarations
|
||||||
|
$reference = 'NEXT_PUBLIC_URL=${SERVICE_URL_APP}';
|
||||||
|
$varName = str($reference)->before('=')->trim();
|
||||||
|
expect($varName->startsWith('SERVICE_URL_'))->toBeFalse();
|
||||||
|
expect($varName->value())->toBe('NEXT_PUBLIC_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures updateCompose helper file has template parsing logic', function () {
|
||||||
|
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||||
|
|
||||||
|
// Check that the fix is in place
|
||||||
|
expect($servicesFile)->toContain('Extract SERVICE_URL and SERVICE_FQDN variable names from the compose template');
|
||||||
|
expect($servicesFile)->toContain('to ensure we use the exact names defined in the template');
|
||||||
|
expect($servicesFile)->toContain('$templateVariableNames');
|
||||||
|
expect($servicesFile)->toContain('DIRECTLY DECLARED');
|
||||||
|
expect($servicesFile)->toContain('not variables that are merely referenced from other services');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies that service names are extracted to create both URL and FQDN pairs', function () {
|
||||||
|
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||||
|
|
||||||
|
// Verify the logic to create both pairs exists
|
||||||
|
expect($servicesFile)->toContain('create BOTH SERVICE_URL and SERVICE_FQDN pairs');
|
||||||
|
expect($servicesFile)->toContain('ALWAYS create base pair');
|
||||||
|
expect($servicesFile)->toContain('SERVICE_URL_{$serviceName}');
|
||||||
|
expect($servicesFile)->toContain('SERVICE_FQDN_{$serviceName}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts service names correctly for pairing', function () {
|
||||||
|
// Simulate what the updateCompose function does
|
||||||
|
$templateVariableNames = [
|
||||||
|
'SERVICE_URL_OPDASHBOARD',
|
||||||
|
'SERVICE_URL_OPDASHBOARD_3000',
|
||||||
|
'SERVICE_URL_OPAPI',
|
||||||
|
];
|
||||||
|
|
||||||
|
$serviceNamesToProcess = [];
|
||||||
|
foreach ($templateVariableNames as $templateVarName) {
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
|
||||||
|
if (! isset($serviceNamesToProcess[$serviceName])) {
|
||||||
|
$serviceNamesToProcess[$serviceName] = [
|
||||||
|
'base' => $serviceName,
|
||||||
|
'ports' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed['has_port'] && $parsed['port']) {
|
||||||
|
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should extract 2 unique service names
|
||||||
|
expect($serviceNamesToProcess)->toHaveCount(2);
|
||||||
|
expect($serviceNamesToProcess)->toHaveKey('opdashboard');
|
||||||
|
expect($serviceNamesToProcess)->toHaveKey('opapi');
|
||||||
|
|
||||||
|
// OPDASHBOARD should have port 3000 tracked
|
||||||
|
expect($serviceNamesToProcess['opdashboard']['ports'])->toContain('3000');
|
||||||
|
|
||||||
|
// OPAPI should have no ports
|
||||||
|
expect($serviceNamesToProcess['opapi']['ports'])->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create both URL and FQDN when only URL is in template', function () {
|
||||||
|
// Given: Template defines only SERVICE_URL_APP
|
||||||
|
$templateVar = 'SERVICE_URL_APP';
|
||||||
|
|
||||||
|
// When: Processing this variable
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
|
||||||
|
// Then: We should create both:
|
||||||
|
// - SERVICE_URL_APP (or SERVICE_URL_app depending on template)
|
||||||
|
// - SERVICE_FQDN_APP (or SERVICE_FQDN_app depending on template)
|
||||||
|
expect($serviceName)->toBe('app');
|
||||||
|
|
||||||
|
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
|
||||||
|
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
|
||||||
|
|
||||||
|
expect($urlKey)->toBe('SERVICE_URL_APP');
|
||||||
|
expect($fqdnKey)->toBe('SERVICE_FQDN_APP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create both URL and FQDN when only FQDN is in template', function () {
|
||||||
|
// Given: Template defines only SERVICE_FQDN_DATABASE
|
||||||
|
$templateVar = 'SERVICE_FQDN_DATABASE';
|
||||||
|
|
||||||
|
// When: Processing this variable
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
|
||||||
|
// Then: We should create both:
|
||||||
|
// - SERVICE_URL_DATABASE (or SERVICE_URL_database depending on template)
|
||||||
|
// - SERVICE_FQDN_DATABASE (or SERVICE_FQDN_database depending on template)
|
||||||
|
expect($serviceName)->toBe('database');
|
||||||
|
|
||||||
|
$urlKey = 'SERVICE_URL_'.str($serviceName)->upper();
|
||||||
|
$fqdnKey = 'SERVICE_FQDN_'.str($serviceName)->upper();
|
||||||
|
|
||||||
|
expect($urlKey)->toBe('SERVICE_URL_DATABASE');
|
||||||
|
expect($fqdnKey)->toBe('SERVICE_FQDN_DATABASE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create all 4 variables when port-specific variable is in template', function () {
|
||||||
|
// Given: Template defines SERVICE_URL_UMAMI_3000
|
||||||
|
$templateVar = 'SERVICE_URL_UMAMI_3000';
|
||||||
|
|
||||||
|
// When: Processing this variable
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVar);
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
$port = $parsed['port'];
|
||||||
|
|
||||||
|
// Then: We should create all 4:
|
||||||
|
// 1. SERVICE_URL_UMAMI (base)
|
||||||
|
// 2. SERVICE_FQDN_UMAMI (base)
|
||||||
|
// 3. SERVICE_URL_UMAMI_3000 (port-specific)
|
||||||
|
// 4. SERVICE_FQDN_UMAMI_3000 (port-specific)
|
||||||
|
|
||||||
|
expect($serviceName)->toBe('umami');
|
||||||
|
expect($port)->toBe('3000');
|
||||||
|
|
||||||
|
$serviceNameUpper = str($serviceName)->upper();
|
||||||
|
$baseUrlKey = "SERVICE_URL_{$serviceNameUpper}";
|
||||||
|
$baseFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}";
|
||||||
|
$portUrlKey = "SERVICE_URL_{$serviceNameUpper}_{$port}";
|
||||||
|
$portFqdnKey = "SERVICE_FQDN_{$serviceNameUpper}_{$port}";
|
||||||
|
|
||||||
|
expect($baseUrlKey)->toBe('SERVICE_URL_UMAMI');
|
||||||
|
expect($baseFqdnKey)->toBe('SERVICE_FQDN_UMAMI');
|
||||||
|
expect($portUrlKey)->toBe('SERVICE_URL_UMAMI_3000');
|
||||||
|
expect($portFqdnKey)->toBe('SERVICE_FQDN_UMAMI_3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple ports for same service', function () {
|
||||||
|
$templateVariableNames = [
|
||||||
|
'SERVICE_URL_API_3000',
|
||||||
|
'SERVICE_URL_API_8080',
|
||||||
|
];
|
||||||
|
|
||||||
|
$serviceNamesToProcess = [];
|
||||||
|
foreach ($templateVariableNames as $templateVarName) {
|
||||||
|
$parsed = parseServiceEnvironmentVariable($templateVarName);
|
||||||
|
$serviceName = $parsed['service_name'];
|
||||||
|
|
||||||
|
if (! isset($serviceNamesToProcess[$serviceName])) {
|
||||||
|
$serviceNamesToProcess[$serviceName] = [
|
||||||
|
'base' => $serviceName,
|
||||||
|
'ports' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed['has_port'] && $parsed['port']) {
|
||||||
|
$serviceNamesToProcess[$serviceName]['ports'][] = $parsed['port'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have one service with two ports
|
||||||
|
expect($serviceNamesToProcess)->toHaveCount(1);
|
||||||
|
expect($serviceNamesToProcess['api']['ports'])->toHaveCount(2);
|
||||||
|
expect($serviceNamesToProcess['api']['ports'])->toContain('3000');
|
||||||
|
expect($serviceNamesToProcess['api']['ports'])->toContain('8080');
|
||||||
|
|
||||||
|
// Should create 6 variables total:
|
||||||
|
// 1. SERVICE_URL_API (base)
|
||||||
|
// 2. SERVICE_FQDN_API (base)
|
||||||
|
// 3. SERVICE_URL_API_3000
|
||||||
|
// 4. SERVICE_FQDN_API_3000
|
||||||
|
// 5. SERVICE_URL_API_8080
|
||||||
|
// 6. SERVICE_FQDN_API_8080
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects SERVICE_URL variables in map-style environment format', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
trigger:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_TRIGGER_3000: ""
|
||||||
|
SERVICE_FQDN_DB: localhost
|
||||||
|
OTHER_VAR: value
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.trigger');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $key => $value) {
|
||||||
|
if (is_int($key) && is_string($value)) {
|
||||||
|
// List-style
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style
|
||||||
|
$envVarName = str($key);
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toHaveCount(2);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_TRIGGER_3000');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_FQDN_DB');
|
||||||
|
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple map-style SERVICE_URL and SERVICE_FQDN variables', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_APP_3000: ""
|
||||||
|
SERVICE_FQDN_API: api.local
|
||||||
|
SERVICE_URL_WEB: ""
|
||||||
|
OTHER_VAR: value
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $key => $value) {
|
||||||
|
if (is_int($key) && is_string($value)) {
|
||||||
|
// List-style
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style
|
||||||
|
$envVarName = str($key);
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toHaveCount(3);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_FQDN_API');
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_WEB');
|
||||||
|
expect($templateVariableNames)->not->toContain('OTHER_VAR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not detect SERVICE_URL references in map-style values', function () {
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_APP_3000: ""
|
||||||
|
NEXT_PUBLIC_URL: ${SERVICE_URL_APP}
|
||||||
|
API_ENDPOINT: ${SERVICE_URL_API}
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.app');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $key => $value) {
|
||||||
|
if (is_int($key) && is_string($value)) {
|
||||||
|
// List-style
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style
|
||||||
|
$envVarName = str($key);
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only detect the direct declaration, not references in values
|
||||||
|
expect($templateVariableNames)->toHaveCount(1);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_APP_3000');
|
||||||
|
expect($templateVariableNames)->not->toContain('SERVICE_URL_APP');
|
||||||
|
expect($templateVariableNames)->not->toContain('SERVICE_URL_API');
|
||||||
|
expect($templateVariableNames)->not->toContain('NEXT_PUBLIC_URL');
|
||||||
|
expect($templateVariableNames)->not->toContain('API_ENDPOINT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles map-style with abbreviated service names', function () {
|
||||||
|
// Simulating the langfuse.yaml case with map-style
|
||||||
|
$yaml = <<<'YAML'
|
||||||
|
services:
|
||||||
|
langfuse:
|
||||||
|
environment:
|
||||||
|
SERVICE_URL_LANGFUSE_3000: ${SERVICE_URL_LANGFUSE_3000}
|
||||||
|
DATABASE_URL: postgres://...
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$dockerCompose = Yaml::parse($yaml);
|
||||||
|
$serviceConfig = data_get($dockerCompose, 'services.langfuse');
|
||||||
|
$environment = data_get($serviceConfig, 'environment', []);
|
||||||
|
|
||||||
|
$templateVariableNames = [];
|
||||||
|
foreach ($environment as $key => $value) {
|
||||||
|
if (is_int($key) && is_string($value)) {
|
||||||
|
// List-style
|
||||||
|
$envVarName = str($value)->before('=')->trim();
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
} elseif (is_string($key)) {
|
||||||
|
// Map-style
|
||||||
|
$envVarName = str($key);
|
||||||
|
if ($envVarName->startsWith('SERVICE_FQDN_') || $envVarName->startsWith('SERVICE_URL_')) {
|
||||||
|
$templateVariableNames[] = $envVarName->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($templateVariableNames)->toHaveCount(1);
|
||||||
|
expect($templateVariableNames)->toContain('SERVICE_URL_LANGFUSE_3000');
|
||||||
|
expect($templateVariableNames)->not->toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies updateCompose helper has dual-format handling', function () {
|
||||||
|
$servicesFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/services.php');
|
||||||
|
|
||||||
|
// Check that both formats are handled
|
||||||
|
expect($servicesFile)->toContain('is_int($key) && is_string($value)');
|
||||||
|
expect($servicesFile)->toContain('List-style');
|
||||||
|
expect($servicesFile)->toContain('elseif (is_string($key))');
|
||||||
|
expect($servicesFile)->toContain('Map-style');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user