Merge branch 'next' into swetrix-analytics-service
This commit is contained in:
commit
5b2d54b7de
72 changed files with 4044 additions and 953 deletions
74
CHANGELOG.md
74
CHANGELOG.md
|
|
@ -2,7 +2,78 @@ # Changelog
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [unreleased]
|
||||
## [4.0.0-beta.434] - 2025-10-03
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(deployments)* Enhance Docker build argument handling for multiline variables
|
||||
- *(deployments)* Add log copying functionality to clipboard in dev
|
||||
- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deployments)* Enhance builder container management and environment variable handling
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update changelog
|
||||
- Update changelog
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(versions)* Update version numbers for Coolify releases
|
||||
- *(versions)* Bump Coolify stable version to 4.0.0-beta.434
|
||||
|
||||
## [4.0.0-beta.433] - 2025-10-01
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling
|
||||
- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources
|
||||
- *(global-search)* Integrate projects and environments into global search functionality
|
||||
- *(storage)* Consolidate storage management into a single component with enhanced UI
|
||||
- *(deployments)* Add support for Coolify variables in Dockerfile
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
|
||||
- *(ui)* Update docker registry image helper text for clarity
|
||||
- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options
|
||||
- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
|
||||
- *(api)* Correct OpenAPI schema annotations for array items
|
||||
- *(ui)* Improve queued deployment status readability in dark mode
|
||||
- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic
|
||||
- *(git)* Enhance error handling for missing branch information during deployment
|
||||
- *(git)* Trim whitespace from repository, branch, and commit SHA fields
|
||||
- *(deployments)* Order deployments by ID for consistent retrieval
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- *(storage)* Enhance file storage management with new properties and UI improvements
|
||||
- *(core)* Update projects property type and enhance UI styling
|
||||
- *(components)* Adjust SVG icon sizes for consistency across applications and services
|
||||
- *(components)* Auto-focus first input in modal on open
|
||||
- *(styles)* Enhance focus styles for buttons and links
|
||||
- *(components)* Enhance close button accessibility in modal
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(global-search)* Change event listener to window level for global search modal
|
||||
- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management
|
||||
- *(dashboard)* Replace project navigation method with direct link in UI
|
||||
- *(global-search)* Improve event handling and cleanup in global search component
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update changelog
|
||||
- Update changelog
|
||||
- Update changelog
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files
|
||||
|
||||
## [4.0.0-beta.432] - 2025-09-29
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
|
@ -188,6 +259,7 @@ ## [4.0.0-beta.427] - 2025-09-15
|
|||
|
||||
### 🚀 Features
|
||||
|
||||
- Add Ente Photos service template
|
||||
- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
|
||||
- *(ui)* Display current version in settings dropdown and update UI accordingly
|
||||
- *(settings)* Add option to restrict PR deployments to repository members and contributors
|
||||
|
|
|
|||
|
|
@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
if (! $request->docker_registry_image_tag) {
|
||||
$request->offsetSet('docker_registry_image_tag', 'latest');
|
||||
// Process docker image name and tag for SHA256 digests
|
||||
$dockerImageName = $request->docker_registry_image_name;
|
||||
$dockerImageTag = $request->docker_registry_image_tag;
|
||||
|
||||
// Strip 'sha256:' prefix if user provided it in the tag
|
||||
if ($dockerImageTag) {
|
||||
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($dockerImageName) {
|
||||
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
|
||||
}
|
||||
|
||||
// Check if tag is a valid SHA256 hash (64 hex characters)
|
||||
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
|
||||
$dockerImageName .= '@sha256';
|
||||
}
|
||||
|
||||
// Set processed values back to request
|
||||
$request->offsetSet('docker_registry_image_name', $dockerImageName);
|
||||
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
|
||||
|
||||
$application = new Application;
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
|
||||
|
|
|
|||
|
|
@ -2173,7 +2173,7 @@ public function delete_execution_by_uuid(Request $request)
|
|||
properties: [
|
||||
'executions' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Schema(
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string'],
|
||||
|
|
|
|||
|
|
@ -219,9 +219,9 @@ public function create_github_app(Request $request)
|
|||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'repositories' => new OA\Items(
|
||||
'repositories' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Schema(type: 'object')
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
@ -335,9 +335,9 @@ public function load_repositories($github_app_id)
|
|||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'branches' => new OA\Items(
|
||||
'branches' => new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Schema(type: 'object')
|
||||
items: new OA\Items(type: 'object')
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -88,8 +88,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
private bool $is_this_additional_server = false;
|
||||
|
||||
private bool $is_laravel_or_symfony = false;
|
||||
|
||||
private ?ApplicationPreview $preview = null;
|
||||
|
||||
private ?string $git_type = null;
|
||||
|
|
@ -505,7 +503,12 @@ private function deploy_dockerimage_buildpack()
|
|||
} else {
|
||||
$this->dockerImageTag = $this->application->docker_registry_image_tag;
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
|
||||
|
||||
// Check if this is an image hash deployment
|
||||
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
|
||||
$displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
|
||||
|
||||
$this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
|
||||
$this->generate_image_names();
|
||||
$this->prepare_builder_image();
|
||||
$this->generate_compose_file();
|
||||
|
|
@ -773,7 +776,6 @@ private function deploy_nixpacks_buildpack()
|
|||
}
|
||||
}
|
||||
$this->clone_repository();
|
||||
$this->detect_laravel_symfony();
|
||||
$this->cleanup_git();
|
||||
$this->generate_nixpacks_confs();
|
||||
$this->generate_compose_file();
|
||||
|
|
@ -937,7 +939,13 @@ private function generate_image_names()
|
|||
$this->production_image_name = "{$this->application->uuid}:latest";
|
||||
}
|
||||
} elseif ($this->application->build_pack === 'dockerimage') {
|
||||
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
|
||||
// Check if this is an image hash deployment
|
||||
if (str($this->dockerImageTag)->startsWith('sha256-')) {
|
||||
$hash = str($this->dockerImageTag)->after('sha256-');
|
||||
$this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
|
||||
} else {
|
||||
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
|
||||
}
|
||||
} elseif ($this->pull_request_id !== 0) {
|
||||
if ($this->application->docker_registry_image_name) {
|
||||
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
|
||||
|
|
@ -1288,24 +1296,34 @@ private function elixir_finetunes()
|
|||
}
|
||||
}
|
||||
|
||||
private function symfony_finetunes(&$parsed)
|
||||
private function laravel_finetunes()
|
||||
{
|
||||
$installCmds = data_get($parsed, 'phases.install.cmds', []);
|
||||
$variables = data_get($parsed, 'variables', []);
|
||||
if ($this->pull_request_id === 0) {
|
||||
$envType = 'environment_variables';
|
||||
} else {
|
||||
$envType = 'environment_variables_preview';
|
||||
}
|
||||
$nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
|
||||
$nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
|
||||
|
||||
$envCommands = [];
|
||||
foreach (array_keys($variables) as $key) {
|
||||
$envCommands[] = "printf '%s=%s\\n' ".escapeshellarg($key)." \"\${$key}\" >> /app/.env";
|
||||
if (! $nixpacks_php_fallback_path) {
|
||||
$nixpacks_php_fallback_path = new EnvironmentVariable;
|
||||
$nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
|
||||
$nixpacks_php_fallback_path->value = '/index.php';
|
||||
$nixpacks_php_fallback_path->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_fallback_path->save();
|
||||
}
|
||||
if (! $nixpacks_php_root_dir) {
|
||||
$nixpacks_php_root_dir = new EnvironmentVariable;
|
||||
$nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
|
||||
$nixpacks_php_root_dir->value = '/app/public';
|
||||
$nixpacks_php_root_dir->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_root_dir->save();
|
||||
}
|
||||
|
||||
if (! empty($envCommands)) {
|
||||
$createEnvCmd = 'touch /app/.env';
|
||||
|
||||
array_unshift($installCmds, $createEnvCmd);
|
||||
array_splice($installCmds, 1, 0, $envCommands);
|
||||
|
||||
data_set($parsed, 'phases.install.cmds', $installCmds);
|
||||
}
|
||||
return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir];
|
||||
}
|
||||
|
||||
private function rolling_update()
|
||||
|
|
@ -1460,7 +1478,6 @@ private function deploy_pull_request()
|
|||
$this->prepare_builder_image();
|
||||
$this->check_git_if_build_needed();
|
||||
$this->clone_repository();
|
||||
$this->detect_laravel_symfony();
|
||||
$this->cleanup_git();
|
||||
if ($this->application->build_pack === 'nixpacks') {
|
||||
$this->generate_nixpacks_confs();
|
||||
|
|
@ -1520,7 +1537,6 @@ private function prepare_builder_image()
|
|||
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
||||
|
||||
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
||||
|
||||
if ($this->use_build_server) {
|
||||
if ($this->dockerConfigFileExists === 'NOK') {
|
||||
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
|
|
@ -1547,6 +1563,18 @@ private function prepare_builder_image()
|
|||
$this->run_pre_deployment_command();
|
||||
}
|
||||
|
||||
private function restart_builder_container_with_actual_commit()
|
||||
{
|
||||
// Stop and remove the current helper container
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
|
||||
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
|
||||
$this->env_args = null;
|
||||
|
||||
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
|
||||
$this->prepare_builder_image();
|
||||
}
|
||||
|
||||
private function deploy_to_additional_destinations()
|
||||
{
|
||||
if ($this->application->additional_networks->count() === 0) {
|
||||
|
|
@ -1615,6 +1643,8 @@ private function set_coolify_variables()
|
|||
if (isset($this->application->git_branch)) {
|
||||
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
|
||||
}
|
||||
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
||||
$this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
|
||||
}
|
||||
|
||||
private function check_git_if_build_needed()
|
||||
|
|
@ -1682,6 +1712,12 @@ private function check_git_if_build_needed()
|
|||
$this->application_deployment_queue->save();
|
||||
}
|
||||
$this->set_coolify_variables();
|
||||
|
||||
// Restart helper container with actual SOURCE_COMMIT value
|
||||
if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') {
|
||||
$this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.');
|
||||
$this->restart_builder_container_with_actual_commit();
|
||||
}
|
||||
}
|
||||
|
||||
private function clone_repository()
|
||||
|
|
@ -1727,78 +1763,6 @@ private function generate_git_import_commands()
|
|||
return $commands;
|
||||
}
|
||||
|
||||
private function detect_laravel_symfony()
|
||||
{
|
||||
if ($this->application->build_pack !== 'nixpacks') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/composer.json && echo 'exists' || echo 'not-exists'"),
|
||||
'save' => 'composer_json_exists',
|
||||
'hidden' => true,
|
||||
]);
|
||||
|
||||
if ($this->saved_outputs->get('composer_json_exists') == 'exists') {
|
||||
$this->execute_remote_command([
|
||||
executeInDocker($this->deployment_uuid, 'grep -E -q "laravel/framework|symfony/dotenv|symfony/framework-bundle|symfony/flex" '.$this->workdir.'/composer.json 2>/dev/null && echo "true" || echo "false"'),
|
||||
'save' => 'is_laravel_or_symfony',
|
||||
'hidden' => true,
|
||||
]);
|
||||
|
||||
$this->is_laravel_or_symfony = $this->saved_outputs->get('is_laravel_or_symfony') == 'true';
|
||||
|
||||
if ($this->is_laravel_or_symfony) {
|
||||
$this->application_deployment_queue->addLogEntry('Laravel/Symfony framework detected. Setting NIXPACKS PHP variables.');
|
||||
$this->ensure_nixpacks_php_variables();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensure_nixpacks_php_variables()
|
||||
{
|
||||
if ($this->pull_request_id === 0) {
|
||||
$envType = 'environment_variables';
|
||||
} else {
|
||||
$envType = 'environment_variables_preview';
|
||||
}
|
||||
|
||||
$nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
|
||||
$nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
|
||||
|
||||
$created_new = false;
|
||||
if (! $nixpacks_php_fallback_path) {
|
||||
$nixpacks_php_fallback_path = new EnvironmentVariable;
|
||||
$nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
|
||||
$nixpacks_php_fallback_path->value = '/index.php';
|
||||
$nixpacks_php_fallback_path->is_buildtime = true;
|
||||
$nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0;
|
||||
$nixpacks_php_fallback_path->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_fallback_path->save();
|
||||
$this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_FALLBACK_PATH environment variable.');
|
||||
$created_new = true;
|
||||
}
|
||||
if (! $nixpacks_php_root_dir) {
|
||||
$nixpacks_php_root_dir = new EnvironmentVariable;
|
||||
$nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
|
||||
$nixpacks_php_root_dir->value = '/app/public';
|
||||
$nixpacks_php_root_dir->is_buildtime = true;
|
||||
$nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0;
|
||||
$nixpacks_php_root_dir->resourceable_id = $this->application->id;
|
||||
$nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
|
||||
$nixpacks_php_root_dir->save();
|
||||
$this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_ROOT_DIR environment variable.');
|
||||
$created_new = true;
|
||||
}
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
$this->application->load(['nixpacks_environment_variables', 'environment_variables']);
|
||||
} else {
|
||||
$this->application->load(['nixpacks_environment_variables_preview', 'environment_variables_preview']);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanup_git()
|
||||
{
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -1808,51 +1772,30 @@ private function cleanup_git()
|
|||
|
||||
private function generate_nixpacks_confs()
|
||||
{
|
||||
$nixpacks_command = $this->nixpacks_build_cmd();
|
||||
$this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
|
||||
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
|
||||
[executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true],
|
||||
);
|
||||
|
||||
if ($this->saved_outputs->get('nixpacks_type')) {
|
||||
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
|
||||
if (str($this->nixpacks_type)->isEmpty()) {
|
||||
throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
||||
}
|
||||
}
|
||||
$nixpacks_command = $this->nixpacks_build_cmd();
|
||||
$this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
|
||||
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
|
||||
);
|
||||
|
||||
if ($this->saved_outputs->get('nixpacks_plan')) {
|
||||
$this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan');
|
||||
if ($this->nixpacks_plan) {
|
||||
$this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}.");
|
||||
$this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}");
|
||||
$parsed = json_decode($this->nixpacks_plan);
|
||||
$parsed = json_decode($this->nixpacks_plan, true);
|
||||
|
||||
// Do any modifications here
|
||||
// We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
|
||||
$this->generate_env_variables();
|
||||
|
||||
if ($this->is_laravel_or_symfony) {
|
||||
if ($this->pull_request_id === 0) {
|
||||
$envType = 'environment_variables';
|
||||
} else {
|
||||
$envType = 'environment_variables_preview';
|
||||
}
|
||||
$nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
|
||||
$nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
|
||||
|
||||
if ($nixpacks_php_fallback_path) {
|
||||
data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $nixpacks_php_fallback_path->value);
|
||||
}
|
||||
if ($nixpacks_php_root_dir) {
|
||||
data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $nixpacks_php_root_dir->value);
|
||||
}
|
||||
}
|
||||
|
||||
$merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args);
|
||||
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
|
||||
if (count($aptPkgs) === 0) {
|
||||
|
|
@ -1868,23 +1811,23 @@ private function generate_nixpacks_confs()
|
|||
data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs);
|
||||
}
|
||||
data_set($parsed, 'variables', $merged_envs->toArray());
|
||||
|
||||
if ($this->is_laravel_or_symfony) {
|
||||
$this->symfony_finetunes($parsed);
|
||||
$is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false);
|
||||
if ($is_laravel) {
|
||||
$variables = $this->laravel_finetunes();
|
||||
data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value);
|
||||
data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value);
|
||||
}
|
||||
|
||||
if ($this->nixpacks_type === 'elixir') {
|
||||
$this->elixir_finetunes();
|
||||
}
|
||||
|
||||
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
|
||||
$this->nixpacks_plan_json = collect($parsed);
|
||||
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
|
||||
if ($this->nixpacks_type === 'rust') {
|
||||
// temporary: disable healthcheck for rust because the start phase does not have curl/wget
|
||||
$this->application->health_check_enabled = false;
|
||||
$this->application->save();
|
||||
}
|
||||
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
|
||||
$this->nixpacks_plan_json = collect($parsed);
|
||||
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2022,11 +1965,14 @@ private function generate_env_variables()
|
|||
{
|
||||
$this->env_args = collect([]);
|
||||
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
||||
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
$this->env_args->put($key, $value);
|
||||
});
|
||||
|
||||
// For build process, include only environment variables where is_buildtime = true
|
||||
if ($this->pull_request_id === 0) {
|
||||
// Get environment variables that are marked as available during build
|
||||
$envs = $this->application->environment_variables()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true)
|
||||
|
|
@ -2035,24 +1981,9 @@ private function generate_env_variables()
|
|||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
if (str($env->real_value)->startsWith('$')) {
|
||||
$variable_key = str($env->real_value)->after('$');
|
||||
if ($variable_key->startsWith('COOLIFY_')) {
|
||||
$variable = $coolify_envs->get($variable_key->value());
|
||||
if (filled($variable)) {
|
||||
$this->env_args->prepend($variable, $variable_key->value());
|
||||
}
|
||||
} else {
|
||||
$variable = $this->application->environment_variables()->where('key', $variable_key)->first();
|
||||
if ($variable) {
|
||||
$this->env_args->prepend($variable->real_value, $env->key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Get preview environment variables that are marked as available during build
|
||||
$envs = $this->application->environment_variables_preview()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true)
|
||||
|
|
@ -2061,20 +1992,6 @@ private function generate_env_variables()
|
|||
foreach ($envs as $env) {
|
||||
if (! is_null($env->real_value)) {
|
||||
$this->env_args->put($env->key, $env->real_value);
|
||||
if (str($env->real_value)->startsWith('$')) {
|
||||
$variable_key = str($env->real_value)->after('$');
|
||||
if ($variable_key->startsWith('COOLIFY_')) {
|
||||
$variable = $coolify_envs->get($variable_key->value());
|
||||
if (filled($variable)) {
|
||||
$this->env_args->prepend($variable, $variable_key->value());
|
||||
}
|
||||
} else {
|
||||
$variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
|
||||
if ($variable) {
|
||||
$this->env_args->prepend($variable->real_value, $env->key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2697,6 +2614,8 @@ private function build_image()
|
|||
} else {
|
||||
// Dockerfile buildpack
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
// Modify the Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
// Use BuildKit with secrets
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
if ($this->force_rebuild) {
|
||||
|
|
@ -2851,7 +2770,6 @@ private function generate_build_env_variables()
|
|||
$this->generate_env_variables();
|
||||
$variables = collect([])->merge($this->env_args);
|
||||
}
|
||||
|
||||
// Analyze build variables for potential issues
|
||||
if ($variables->isNotEmpty()) {
|
||||
$this->analyzeBuildTimeVariables($variables);
|
||||
|
|
@ -2866,12 +2784,23 @@ private function generate_build_env_variables()
|
|||
$secrets_hash = $this->generate_secrets_hash($variables);
|
||||
}
|
||||
|
||||
$this->build_args = $variables->map(function ($value, $key) {
|
||||
$value = escapeshellarg($value);
|
||||
$env_vars = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()->where('is_buildtime', true)->get()
|
||||
: $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
|
||||
|
||||
return "--build-arg {$key}={$value}";
|
||||
// Map variables to include is_multiline flag
|
||||
$vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) {
|
||||
$env = $env_vars->firstWhere('key', $key);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_multiline' => $env ? $env->is_multiline : false,
|
||||
];
|
||||
});
|
||||
|
||||
$this->build_args = generateDockerBuildArgs($vars_with_metadata);
|
||||
|
||||
if ($secrets_hash) {
|
||||
$this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||
}
|
||||
|
|
@ -2885,23 +2814,37 @@ private function generate_docker_env_flags_for_secrets()
|
|||
return '';
|
||||
}
|
||||
|
||||
$variables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
|
||||
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
|
||||
// Generate env variables if not already done
|
||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||
$this->generate_env_variables();
|
||||
}
|
||||
|
||||
$variables = $this->env_args;
|
||||
|
||||
if ($variables->isEmpty()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$secrets_hash = $this->generate_secrets_hash($variables);
|
||||
$env_flags = $variables
|
||||
->map(function ($env) {
|
||||
$escaped_value = escapeshellarg($env->real_value);
|
||||
|
||||
return "-e {$env->key}={$escaped_value}";
|
||||
})
|
||||
->implode(' ');
|
||||
// Get database env vars to check for multiline flag
|
||||
$env_vars = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()->where('is_buildtime', true)->get()
|
||||
: $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
|
||||
|
||||
// Map to simple array format for the helper function
|
||||
$vars_array = $variables->map(function ($value, $key) use ($env_vars) {
|
||||
$env = $env_vars->firstWhere('key', $key);
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'is_multiline' => $env ? $env->is_multiline : false,
|
||||
];
|
||||
});
|
||||
|
||||
$env_flags = generateDockerEnvFlags($vars_array);
|
||||
$env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
|
||||
|
||||
return $env_flags;
|
||||
|
|
@ -2963,7 +2906,6 @@ private function add_build_env_variables_to_dockerfile()
|
|||
'save' => 'dockerfile',
|
||||
]);
|
||||
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
// Only add environment variables that are available during build
|
||||
$envs = $this->application->environment_variables()
|
||||
|
|
@ -2977,6 +2919,17 @@ private function add_build_env_variables_to_dockerfile()
|
|||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
if ($this->coolify_variables) {
|
||||
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
||||
->filter()
|
||||
->map(function ($var) {
|
||||
return "ARG {$var}";
|
||||
});
|
||||
foreach ($coolify_vars as $arg) {
|
||||
$dockerfile->splice(1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Only add preview environment variables that are available during build
|
||||
$envs = $this->application->environment_variables_preview()
|
||||
|
|
@ -2990,6 +2943,17 @@ private function add_build_env_variables_to_dockerfile()
|
|||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
if ($this->coolify_variables) {
|
||||
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
||||
->filter()
|
||||
->map(function ($var) {
|
||||
return "ARG {$var}";
|
||||
});
|
||||
foreach ($coolify_vars as $arg) {
|
||||
$dockerfile->splice(1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($envs->isNotEmpty()) {
|
||||
|
|
@ -3026,16 +2990,19 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
|
|||
$dockerfile->prepend('# syntax=docker/dockerfile:1');
|
||||
}
|
||||
|
||||
// Get environment variables for secrets
|
||||
$variables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
|
||||
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
|
||||
// Generate env variables if not already done
|
||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||
$this->generate_env_variables();
|
||||
}
|
||||
|
||||
$variables = $this->env_args;
|
||||
if ($variables->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate mount strings for all secrets
|
||||
$mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
|
||||
$mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
|
||||
|
||||
// Add mount for the secrets hash to ensure cache invalidation
|
||||
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
|
||||
|
|
@ -3063,8 +3030,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
|
|||
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3074,15 +3039,13 @@ private function modify_dockerfiles_for_compose($composeFile)
|
|||
return;
|
||||
}
|
||||
|
||||
$variables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true)
|
||||
->get()
|
||||
: $this->application->environment_variables_preview()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true)
|
||||
->get();
|
||||
// Generate env variables if not already done
|
||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||
$this->generate_env_variables();
|
||||
}
|
||||
|
||||
$variables = $this->env_args;
|
||||
|
||||
if ($variables->isEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
|
||||
|
|
@ -3156,8 +3119,8 @@ private function modify_dockerfiles_for_compose($composeFile)
|
|||
$isMultiStage = count($fromIndices) > 1;
|
||||
|
||||
$argsToAdd = collect([]);
|
||||
foreach ($variables as $env) {
|
||||
$argsToAdd->push("ARG {$env->key}");
|
||||
foreach ($variables as $key => $value) {
|
||||
$argsToAdd->push("ARG {$key}");
|
||||
}
|
||||
|
||||
ray($argsToAdd);
|
||||
|
|
@ -3228,19 +3191,22 @@ private function modify_dockerfiles_for_compose($composeFile)
|
|||
|
||||
private function add_build_secrets_to_compose($composeFile)
|
||||
{
|
||||
// Get environment variables for secrets
|
||||
$variables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get()
|
||||
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
|
||||
// Generate env variables if not already done
|
||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||
$this->generate_env_variables();
|
||||
}
|
||||
|
||||
$variables = $this->env_args;
|
||||
|
||||
if ($variables->isEmpty()) {
|
||||
return $composeFile;
|
||||
}
|
||||
|
||||
$secrets = [];
|
||||
foreach ($variables as $env) {
|
||||
$secrets[$env->key] = [
|
||||
'environment' => $env->key,
|
||||
foreach ($variables as $key => $value) {
|
||||
$secrets[$key] = [
|
||||
'environment' => $key,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -3255,9 +3221,9 @@ private function add_build_secrets_to_compose($composeFile)
|
|||
if (! isset($service['build']['secrets'])) {
|
||||
$service['build']['secrets'] = [];
|
||||
}
|
||||
foreach ($variables as $env) {
|
||||
if (! in_array($env->key, $service['build']['secrets'])) {
|
||||
$service['build']['secrets'][] = $env->key;
|
||||
foreach ($variables as $key => $value) {
|
||||
if (! in_array($key, $service['build']['secrets'])) {
|
||||
$service['build']['secrets'][] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3376,7 +3342,6 @@ private function next(string $status)
|
|||
queue_next_deployment($this->application);
|
||||
|
||||
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
|
||||
ray($this->application->team()->id);
|
||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
|
|
|
|||
|
|
@ -2,63 +2,25 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public $projects = [];
|
||||
public Collection $projects;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public Collection $privateKeys;
|
||||
|
||||
public array $deploymentsPerServer = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
$this->servers = Server::ownedByCurrentTeam()->get();
|
||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
||||
$this->loadDeployments();
|
||||
}
|
||||
|
||||
public function cleanupQueue()
|
||||
{
|
||||
try {
|
||||
$this->authorize('cleanupDeploymentQueue', Application::class);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
Artisan::queue('cleanup:deployment-queue', [
|
||||
'--team-id' => currentTeam()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function loadDeployments()
|
||||
{
|
||||
$this->deploymentsPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('server_id', $this->servers->pluck('id'))->get([
|
||||
'id',
|
||||
'application_id',
|
||||
'application_name',
|
||||
'deployment_url',
|
||||
'pull_request_id',
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
])->sortBy('id')->groupBy('server_name')->toArray();
|
||||
}
|
||||
|
||||
public function navigateToProject($projectUuid)
|
||||
{
|
||||
return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
49
app/Livewire/DeploymentsIndicator.php
Normal file
49
app/Livewire/DeploymentsIndicator.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeploymentsIndicator extends Component
|
||||
{
|
||||
public bool $expanded = false;
|
||||
|
||||
#[Computed]
|
||||
public function deployments()
|
||||
{
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
|
||||
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
|
||||
->whereIn('server_id', $servers->pluck('id'))
|
||||
->orderBy('id')
|
||||
->get([
|
||||
'id',
|
||||
'application_id',
|
||||
'application_name',
|
||||
'deployment_url',
|
||||
'pull_request_id',
|
||||
'server_name',
|
||||
'server_id',
|
||||
'status',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function deploymentCount()
|
||||
{
|
||||
return $this->deployments->count();
|
||||
}
|
||||
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = ! $this->expanded;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.deployments-indicator');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
|
|
@ -335,11 +337,81 @@ private function loadSearchableItems()
|
|||
];
|
||||
});
|
||||
|
||||
// Get all projects
|
||||
$projects = Project::ownedByCurrentTeam()
|
||||
->withCount(['environments', 'applications', 'services'])
|
||||
->get()
|
||||
->map(function ($project) {
|
||||
$resourceCount = $project->applications_count + $project->services_count;
|
||||
$resourceSummary = $resourceCount > 0
|
||||
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
|
||||
: 'No resources';
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'type' => 'project',
|
||||
'uuid' => $project->uuid,
|
||||
'description' => $project->description,
|
||||
'link' => $project->navigateTo(),
|
||||
'project' => null,
|
||||
'environment' => null,
|
||||
'resource_count' => $resourceSummary,
|
||||
'environment_count' => $project->environments_count,
|
||||
'search_text' => strtolower($project->name.' '.$project->description.' project'),
|
||||
];
|
||||
});
|
||||
|
||||
// Get all environments
|
||||
$environments = Environment::query()
|
||||
->whereHas('project', function ($query) {
|
||||
$query->where('team_id', auth()->user()->currentTeam()->id);
|
||||
})
|
||||
->with('project')
|
||||
->withCount(['applications', 'services'])
|
||||
->get()
|
||||
->map(function ($environment) {
|
||||
$resourceCount = $environment->applications_count + $environment->services_count;
|
||||
$resourceSummary = $resourceCount > 0
|
||||
? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
|
||||
: 'No resources';
|
||||
|
||||
// Build description with project context
|
||||
$descriptionParts = [];
|
||||
if ($environment->project) {
|
||||
$descriptionParts[] = "Project: {$environment->project->name}";
|
||||
}
|
||||
if ($environment->description) {
|
||||
$descriptionParts[] = $environment->description;
|
||||
}
|
||||
if (empty($descriptionParts)) {
|
||||
$descriptionParts[] = $resourceSummary;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $environment->id,
|
||||
'name' => $environment->name,
|
||||
'type' => 'environment',
|
||||
'uuid' => $environment->uuid,
|
||||
'description' => implode(' • ', $descriptionParts),
|
||||
'link' => route('project.resource.index', [
|
||||
'project_uuid' => $environment->project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
]),
|
||||
'project' => $environment->project->name ?? null,
|
||||
'environment' => null,
|
||||
'resource_count' => $resourceSummary,
|
||||
'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'),
|
||||
];
|
||||
});
|
||||
|
||||
// Merge all collections
|
||||
$items = $items->merge($applications)
|
||||
->merge($services)
|
||||
->merge($databases)
|
||||
->merge($servers);
|
||||
->merge($servers)
|
||||
->merge($projects)
|
||||
->merge($environments);
|
||||
|
||||
return $items->toArray();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,28 @@ public function force_start()
|
|||
}
|
||||
}
|
||||
|
||||
public function copyLogsToClipboard(): string
|
||||
{
|
||||
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! $logs) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$markdown = "# Deployment Logs\n\n";
|
||||
$markdown .= "```\n";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if (isset($log['output'])) {
|
||||
$markdown .= $log['output']."\n";
|
||||
}
|
||||
}
|
||||
|
||||
$markdown .= "```\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
public function cancel()
|
||||
{
|
||||
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ public function generate()
|
|||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$portInt = $url->getPort();
|
||||
$port = $portInt !== null ? ':' . $portInt : '';
|
||||
$port = $portInt !== null ? ':'.$portInt : '';
|
||||
$random = new Cuid2;
|
||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,21 @@ public function mount()
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedGitRepository()
|
||||
{
|
||||
$this->gitRepository = trim($this->gitRepository);
|
||||
}
|
||||
|
||||
public function updatedGitBranch()
|
||||
{
|
||||
$this->gitBranch = trim($this->gitBranch);
|
||||
}
|
||||
|
||||
public function updatedGitCommitSha()
|
||||
{
|
||||
$this->gitCommitSha = trim($this->gitCommitSha);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
|
|
@ -57,6 +72,9 @@ public function syncData(bool $toModel = false)
|
|||
'git_commit_sha' => $this->gitCommitSha,
|
||||
'private_key_id' => $this->privateKeyId,
|
||||
]);
|
||||
// Refresh to get the trimmed values from the model
|
||||
$this->application->refresh();
|
||||
$this->syncData(false);
|
||||
} else {
|
||||
$this->gitRepository = $this->application->git_repository;
|
||||
$this->gitBranch = $this->application->git_branch;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
class DockerImage extends Component
|
||||
{
|
||||
public string $dockerImage = '';
|
||||
public string $imageName = '';
|
||||
|
||||
public string $imageTag = '';
|
||||
|
||||
public string $imageSha256 = '';
|
||||
|
||||
public array $parameters;
|
||||
|
||||
|
|
@ -26,12 +30,41 @@ public function mount()
|
|||
|
||||
public function submit()
|
||||
{
|
||||
// Strip 'sha256:' prefix if user pasted it
|
||||
if ($this->imageSha256) {
|
||||
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
|
||||
}
|
||||
|
||||
// Remove @sha256 from image name if user added it
|
||||
if ($this->imageName) {
|
||||
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'dockerImage' => 'required',
|
||||
'imageName' => ['required', 'string'],
|
||||
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
|
||||
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
|
||||
]);
|
||||
|
||||
// Validate that either tag or sha256 is provided, but not both
|
||||
if ($this->imageTag && $this->imageSha256) {
|
||||
$this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
|
||||
$this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the full Docker image string
|
||||
if ($this->imageSha256) {
|
||||
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
|
||||
} elseif ($this->imageTag) {
|
||||
$dockerImage = $this->imageName.':'.$this->imageTag;
|
||||
} else {
|
||||
$dockerImage = $this->imageName.':latest';
|
||||
}
|
||||
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse($this->dockerImage);
|
||||
$parser->parse($dockerImage);
|
||||
|
||||
$destination_uuid = $this->query['destination'];
|
||||
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
|
||||
|
|
@ -45,6 +78,16 @@ public function submit()
|
|||
|
||||
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
|
||||
|
||||
// Determine the image tag based on whether it's a hash or regular tag
|
||||
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
|
||||
|
||||
// Append @sha256 to image name if using digest and not already present
|
||||
$imageName = $parser->getFullImageNameWithoutTag();
|
||||
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
|
||||
$imageName .= '@sha256';
|
||||
}
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'docker-image-'.new Cuid2,
|
||||
'repository_project_id' => 0,
|
||||
|
|
@ -52,7 +95,7 @@ public function submit()
|
|||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerimage',
|
||||
'ports_exposes' => 80,
|
||||
'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
|
||||
'docker_registry_image_name' => $imageName,
|
||||
'docker_registry_image_tag' => $parser->getTag(),
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
|
|
|
|||
|
|
@ -176,13 +176,16 @@ public function loadBranch()
|
|||
str($this->repository_url)->startsWith('http://')) &&
|
||||
! str($this->repository_url)->endsWith('.git') &&
|
||||
(! str($this->repository_url)->contains('github.com') ||
|
||||
! str($this->repository_url)->contains('git.sr.ht'))
|
||||
! str($this->repository_url)->contains('git.sr.ht')) &&
|
||||
! str($this->repository_url)->contains('tangled')
|
||||
) {
|
||||
|
||||
$this->repository_url = $this->repository_url.'.git';
|
||||
}
|
||||
if (str($this->repository_url)->contains('github.com') && str($this->repository_url)->endsWith('.git')) {
|
||||
$this->repository_url = str($this->repository_url)->beforeLast('.git')->value();
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -190,6 +193,9 @@ public function loadBranch()
|
|||
$this->branchFound = false;
|
||||
$this->getGitSource();
|
||||
$this->getBranch();
|
||||
if (str($this->repository_url)->contains('tangled')) {
|
||||
$this->git_branch = 'master';
|
||||
}
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->rate_limit_remaining == 0) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ class FileStorage extends Component
|
|||
|
||||
public bool $permanently_delete = true;
|
||||
|
||||
public bool $isReadOnly = false;
|
||||
|
||||
protected $rules = [
|
||||
'fileStorage.is_directory' => 'required',
|
||||
'fileStorage.fs_path' => 'required',
|
||||
|
|
@ -52,6 +54,8 @@ public function mount()
|
|||
$this->workdir = null;
|
||||
$this->fs_path = $this->fileStorage->fs_path;
|
||||
}
|
||||
|
||||
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
|
||||
}
|
||||
|
||||
public function convertToDirectory()
|
||||
|
|
|
|||
|
|
@ -14,6 +14,22 @@ class Storage extends Component
|
|||
|
||||
public $fileStorage;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $mount_path = '';
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path = '';
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source = '';
|
||||
|
||||
public string $file_storage_directory_destination = '';
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
|
@ -27,6 +43,18 @@ public function getListeners()
|
|||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
if ($this->resource->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->refreshStorages();
|
||||
}
|
||||
|
||||
|
|
@ -39,30 +67,151 @@ public function refreshStoragesFromEvent()
|
|||
public function refreshStorages()
|
||||
{
|
||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||
$this->dispatch('$refresh');
|
||||
$this->resource->refresh();
|
||||
}
|
||||
|
||||
public function addNewVolume($data)
|
||||
public function getFilesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', false);
|
||||
}
|
||||
|
||||
public function getDirectoriesProperty()
|
||||
{
|
||||
return $this->fileStorage->where('is_directory', true);
|
||||
}
|
||||
|
||||
public function getVolumeCountProperty()
|
||||
{
|
||||
return $this->resource->persistentStorages()->count();
|
||||
}
|
||||
|
||||
public function getFileCountProperty()
|
||||
{
|
||||
return $this->files->count();
|
||||
}
|
||||
|
||||
public function getDirectoryCountProperty()
|
||||
{
|
||||
return $this->directories->count();
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
|
||||
]);
|
||||
|
||||
$name = $this->resource->uuid.'-'.$this->name;
|
||||
|
||||
LocalPersistentVolume::create([
|
||||
'name' => $data['name'],
|
||||
'mount_path' => $data['mount_path'],
|
||||
'host_path' => $data['host_path'],
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => $this->resource->getMorphClass(),
|
||||
]);
|
||||
$this->resource->refresh();
|
||||
$this->dispatch('success', 'Storage added successfully');
|
||||
$this->dispatch('clearAddStorage');
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('success', 'Volume added successfully');
|
||||
$this->dispatch('closeStorageModal', 'volume');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'required|string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'File mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'file');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'required|string',
|
||||
'file_storage_directory_destination' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
\App\Models\LocalFileVolume::create([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Directory mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'directory');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clearForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
$this->file_storage_path = '';
|
||||
$this->file_storage_content = null;
|
||||
$this->file_storage_directory_destination = '';
|
||||
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.storage');
|
||||
|
|
|
|||
|
|
@ -52,13 +52,13 @@ public function toggleHealthcheck()
|
|||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$wasEnabled = $this->resource->health_check_enabled;
|
||||
$this->resource->health_check_enabled = !$this->resource->health_check_enabled;
|
||||
$this->resource->health_check_enabled = ! $this->resource->health_check_enabled;
|
||||
$this->resource->save();
|
||||
|
||||
if ($this->resource->health_check_enabled && !$wasEnabled && $this->resource->isRunning()) {
|
||||
if ($this->resource->health_check_enabled && ! $wasEnabled && $this->resource->isRunning()) {
|
||||
$this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.');
|
||||
} else {
|
||||
$this->dispatch('success', 'Health check ' . ($this->resource->health_check_enabled ? 'enabled' : 'disabled') . '.');
|
||||
$this->dispatch('success', 'Health check '.($this->resource->health_check_enabled ? 'enabled' : 'disabled').'.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalFileVolume;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public $uuid;
|
||||
|
||||
public $parameters;
|
||||
|
||||
public $isSwarm = false;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $mount_path;
|
||||
|
||||
public ?string $host_path = null;
|
||||
|
||||
public string $file_storage_path;
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $file_storage_directory_source;
|
||||
|
||||
public string $file_storage_directory_destination;
|
||||
|
||||
public $rules = [
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
];
|
||||
|
||||
protected $listeners = ['clearAddStorage' => 'clear'];
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mount_path' => 'mount',
|
||||
'host_path' => 'host',
|
||||
'file_storage_path' => 'file storage path',
|
||||
'file_storage_content' => 'file storage content',
|
||||
'file_storage_directory_source' => 'file storage directory source',
|
||||
'file_storage_directory_destination' => 'file storage directory destination',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
} else {
|
||||
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
|
||||
}
|
||||
$this->uuid = $this->resource->uuid;
|
||||
$this->parameters = get_route_parameters();
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$applicationUuid = $this->parameters['application_uuid'];
|
||||
$application = Application::where('uuid', $applicationUuid)->first();
|
||||
if (! $application) {
|
||||
abort(404);
|
||||
}
|
||||
if ($application->destination->server->isSwarm()) {
|
||||
$this->isSwarm = true;
|
||||
$this->rules['host_path'] = 'required|string';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_path' => 'string',
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
|
||||
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $fs_path,
|
||||
'mount_path' => $this->file_storage_path,
|
||||
'content' => $this->file_storage_content,
|
||||
'is_directory' => false,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'file_storage_directory_source' => 'string',
|
||||
'file_storage_directory_destination' => 'string',
|
||||
]);
|
||||
|
||||
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
|
||||
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
|
||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||
|
||||
LocalFileVolume::create(
|
||||
[
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
],
|
||||
);
|
||||
$this->dispatch('refreshStorages');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitPersistentVolume()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
]);
|
||||
$name = $this->uuid.'-'.$this->name;
|
||||
$this->dispatch('addNewVolume', [
|
||||
'name' => $name,
|
||||
'mount_path' => $this->mount_path,
|
||||
'host_path' => $this->host_path,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,11 @@ class Show extends Component
|
|||
'host_path' => 'host',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->isReadOnly = $this->storage->isReadOnlyVolume();
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -39,8 +36,6 @@ public function mount(string $server_uuid)
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
|
|
|
|||
|
|
@ -155,6 +155,15 @@ protected static function booted()
|
|||
if ($application->isDirty('publish_directory')) {
|
||||
$payload['publish_directory'] = str($application->publish_directory)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_repository')) {
|
||||
$payload['git_repository'] = str($application->git_repository)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_branch')) {
|
||||
$payload['git_branch'] = str($application->git_branch)->trim();
|
||||
}
|
||||
if ($application->isDirty('git_commit_sha')) {
|
||||
$payload['git_commit_sha'] = str($application->git_commit_sha)->trim();
|
||||
}
|
||||
if ($application->isDirty('status')) {
|
||||
$payload['last_online_at'] = now();
|
||||
}
|
||||
|
|
@ -730,9 +739,9 @@ public function environment_variables()
|
|||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', false)
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
CASE
|
||||
WHEN is_required = true THEN 1
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
|
|
@ -758,9 +767,9 @@ public function environment_variables_preview()
|
|||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->where('is_preview', true)
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
CASE
|
||||
WHEN is_required = true THEN 1
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
)]
|
||||
class Environment extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Events\FileStorageChanged;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalFileVolume extends BaseModel
|
||||
{
|
||||
|
|
@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
|
|||
{
|
||||
return $query->get()->where('plain_mount_path', $path);
|
||||
}
|
||||
|
||||
// Check if this volume is read-only by parsing the docker-compose content
|
||||
public function isReadOnlyVolume(): bool
|
||||
{
|
||||
try {
|
||||
// Only check for services
|
||||
$service = $this->service;
|
||||
if (! $service || ! method_exists($service, 'service')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actualService = $service->service;
|
||||
if (! $actualService || ! $actualService->docker_compose_raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the docker-compose content
|
||||
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||
if (! isset($compose['services'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the service that this volume belongs to
|
||||
$serviceName = $service->name;
|
||||
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||
|
||||
// Check each volume to find a match
|
||||
foreach ($volumes as $volume) {
|
||||
// Volume can be string like "host:container:ro" or "host:container"
|
||||
if (is_string($volume)) {
|
||||
$parts = explode(':', $volume);
|
||||
|
||||
// Check if this volume matches our fs_path and mount_path
|
||||
if (count($parts) >= 2) {
|
||||
$hostPath = $parts[0];
|
||||
$containerPath = $parts[1];
|
||||
$options = $parts[2] ?? null;
|
||||
|
||||
// Match based on fs_path and mount_path
|
||||
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
|
||||
return $options === 'ro';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
ray($e->getMessage(), 'Error checking read-only volume');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class LocalPersistentVolume extends Model
|
||||
{
|
||||
|
|
@ -48,4 +49,69 @@ protected function hostPath(): Attribute
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this volume is read-only by parsing the docker-compose content
|
||||
public function isReadOnlyVolume(): bool
|
||||
{
|
||||
try {
|
||||
// Get the resource (can be application, service, or database)
|
||||
$resource = $this->resource;
|
||||
if (! $resource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only check for services
|
||||
if (! method_exists($resource, 'service')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actualService = $resource->service;
|
||||
if (! $actualService || ! $actualService->docker_compose_raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the docker-compose content
|
||||
$compose = Yaml::parse($actualService->docker_compose_raw);
|
||||
if (! isset($compose['services'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the service that this volume belongs to
|
||||
$serviceName = $resource->name;
|
||||
if (! isset($compose['services'][$serviceName]['volumes'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$volumes = $compose['services'][$serviceName]['volumes'];
|
||||
|
||||
// Check each volume to find a match
|
||||
foreach ($volumes as $volume) {
|
||||
// Volume can be string like "host:container:ro" or "host:container"
|
||||
if (is_string($volume)) {
|
||||
$parts = explode(':', $volume);
|
||||
|
||||
// Check if this volume matches our mount_path
|
||||
if (count($parts) >= 2) {
|
||||
$containerPath = $parts[1];
|
||||
$options = $parts[2] ?? null;
|
||||
|
||||
// Match based on mount_path
|
||||
// Remove leading slash from mount_path if present for comparison
|
||||
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
||||
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
||||
|
||||
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
||||
return $options === 'ro';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
ray($e->getMessage(), 'Error checking read-only persistent volume');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -24,6 +25,7 @@
|
|||
)]
|
||||
class Project extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache;
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
|
|
@ -547,6 +547,21 @@ public function extraFields()
|
|||
}
|
||||
$fields->put('Grafana', $data->toArray());
|
||||
break;
|
||||
case $image->contains('elasticsearch'):
|
||||
$data = collect([]);
|
||||
$elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first();
|
||||
if ($elastic_password) {
|
||||
$data = $data->merge([
|
||||
'Password (default user: elastic)' => [
|
||||
'key' => data_get($elastic_password, 'key'),
|
||||
'value' => data_get($elastic_password, 'value'),
|
||||
'rules' => 'required',
|
||||
'isPassword' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$fields->put('Elasticsearch', $data->toArray());
|
||||
break;
|
||||
case $image->contains('directus'):
|
||||
$data = collect([]);
|
||||
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
|
||||
|
|
@ -1231,9 +1246,9 @@ public function environment_variables()
|
|||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 1
|
||||
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
|
||||
CASE
|
||||
WHEN is_required = true THEN 1
|
||||
WHEN LOWER(key) LIKE 'service_%' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
LOWER(key) ASC
|
||||
|
|
@ -1263,6 +1278,21 @@ public function saveComposeConfigs()
|
|||
$commands[] = "cd $workdir";
|
||||
$commands[] = 'rm -f .env || true';
|
||||
|
||||
$envs = collect([]);
|
||||
|
||||
// Generate SERVICE_NAME_* environment variables from docker-compose services
|
||||
if ($this->docker_compose) {
|
||||
try {
|
||||
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
foreach ($services as $serviceName => $_) {
|
||||
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
ray($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$envs_from_coolify = $this->environment_variables()->get();
|
||||
$sorted = $envs_from_coolify->sortBy(function ($env) {
|
||||
if (str($env->key)->startsWith('SERVICE_')) {
|
||||
|
|
@ -1274,7 +1304,6 @@ public function saveComposeConfigs()
|
|||
|
||||
return 3;
|
||||
});
|
||||
$envs = collect([]);
|
||||
foreach ($sorted as $env) {
|
||||
$envs->push("{$env->key}={$env->real_value}");
|
||||
}
|
||||
|
|
|
|||
41
app/Rules/DockerImageFormat.php
Normal file
41
app/Rules/DockerImageFormat.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class DockerImageFormat implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
// Check if the value contains ":sha256:" or ":sha" which is incorrect format
|
||||
if (preg_match('/:sha256?:/i', $value)) {
|
||||
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid formats:
|
||||
// 1. image:tag (e.g., nginx:latest)
|
||||
// 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
|
||||
// 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
|
||||
// 4. registry/image@sha256:hash
|
||||
// 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
|
||||
|
||||
$pattern = '/^
|
||||
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port
|
||||
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required)
|
||||
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash
|
||||
$/ix';
|
||||
|
||||
if (! preg_match($pattern, $value)) {
|
||||
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,20 +10,33 @@ class DockerImageParser
|
|||
|
||||
private string $tag = 'latest';
|
||||
|
||||
private bool $isImageHash = false;
|
||||
|
||||
public function parse(string $imageString): self
|
||||
{
|
||||
// First split by : to handle the tag, but be careful with registry ports
|
||||
$lastColon = strrpos($imageString, ':');
|
||||
$hasSlash = str_contains($imageString, '/');
|
||||
|
||||
// If the last colon appears after the last slash, it's a tag
|
||||
// Otherwise it might be a port in the registry URL
|
||||
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
|
||||
$mainPart = substr($imageString, 0, $lastColon);
|
||||
$this->tag = substr($imageString, $lastColon + 1);
|
||||
// Check for @sha256: format first (e.g., nginx@sha256:abc123...)
|
||||
if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) {
|
||||
$mainPart = $matches[1];
|
||||
$this->tag = $matches[2];
|
||||
$this->isImageHash = true;
|
||||
} else {
|
||||
$mainPart = $imageString;
|
||||
$this->tag = 'latest';
|
||||
// Split by : to handle the tag, but be careful with registry ports
|
||||
$lastColon = strrpos($imageString, ':');
|
||||
$hasSlash = str_contains($imageString, '/');
|
||||
|
||||
// If the last colon appears after the last slash, it's a tag
|
||||
// Otherwise it might be a port in the registry URL
|
||||
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
|
||||
$mainPart = substr($imageString, 0, $lastColon);
|
||||
$this->tag = substr($imageString, $lastColon + 1);
|
||||
|
||||
// Check if the tag is a SHA256 hash
|
||||
$this->isImageHash = $this->isSha256Hash($this->tag);
|
||||
} else {
|
||||
$mainPart = $imageString;
|
||||
$this->tag = 'latest';
|
||||
$this->isImageHash = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Split the main part by / to handle registry and image name
|
||||
|
|
@ -41,6 +54,37 @@ public function parse(string $imageString): self
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given string is a SHA256 hash
|
||||
*/
|
||||
private function isSha256Hash(string $hash): bool
|
||||
{
|
||||
// SHA256 hashes are 64 characters long and contain only hexadecimal characters
|
||||
return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current tag is an image hash
|
||||
*/
|
||||
public function isImageHash(): bool
|
||||
{
|
||||
return $this->isImageHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full image name with hash if present
|
||||
*/
|
||||
public function getFullImageNameWithHash(): string
|
||||
{
|
||||
$imageName = $this->getFullImageNameWithoutTag();
|
||||
|
||||
if ($this->isImageHash) {
|
||||
return $imageName.'@sha256:'.$this->tag;
|
||||
}
|
||||
|
||||
return $imageName.':'.$this->tag;
|
||||
}
|
||||
|
||||
public function getFullImageNameWithoutTag(): string
|
||||
{
|
||||
if ($this->registryUrl) {
|
||||
|
|
@ -73,6 +117,10 @@ public function toString(): string
|
|||
}
|
||||
$parts[] = $this->imageName;
|
||||
|
||||
if ($this->isImageHash) {
|
||||
return implode('/', $parts).'@sha256:'.$this->tag;
|
||||
}
|
||||
|
||||
return implode('/', $parts).':'.$this->tag;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,77 +10,119 @@ trait ClearsGlobalSearchCache
|
|||
protected static function bootClearsGlobalSearchCache()
|
||||
{
|
||||
static::saving(function ($model) {
|
||||
// Only clear cache if searchable fields are being changed
|
||||
if ($model->hasSearchableChanges()) {
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
try {
|
||||
// Only clear cache if searchable fields are being changed
|
||||
if ($model->hasSearchableChanges()) {
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently fail cache clearing - don't break the save operation
|
||||
ray('Failed to clear global search cache on saving: '.$e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
static::created(function ($model) {
|
||||
// Always clear cache when model is created
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
try {
|
||||
// Always clear cache when model is created
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently fail cache clearing - don't break the create operation
|
||||
ray('Failed to clear global search cache on creation: '.$e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($model) {
|
||||
// Always clear cache when model is deleted
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
try {
|
||||
// Always clear cache when model is deleted
|
||||
$teamId = $model->getTeamIdForCache();
|
||||
if (filled($teamId)) {
|
||||
GlobalSearch::clearTeamCache($teamId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently fail cache clearing - don't break the delete operation
|
||||
ray('Failed to clear global search cache on deletion: '.$e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function hasSearchableChanges(): bool
|
||||
{
|
||||
// Define searchable fields based on model type
|
||||
$searchableFields = ['name', 'description'];
|
||||
try {
|
||||
// Define searchable fields based on model type
|
||||
$searchableFields = ['name', 'description'];
|
||||
|
||||
// Add model-specific searchable fields
|
||||
if ($this instanceof \App\Models\Application) {
|
||||
$searchableFields[] = 'fqdn';
|
||||
$searchableFields[] = 'docker_compose_domains';
|
||||
} elseif ($this instanceof \App\Models\Server) {
|
||||
$searchableFields[] = 'ip';
|
||||
} elseif ($this instanceof \App\Models\Service) {
|
||||
// Services don't have direct fqdn, but name and description are covered
|
||||
}
|
||||
// Database models only have name and description as searchable
|
||||
|
||||
// Check if any searchable field is dirty
|
||||
foreach ($searchableFields as $field) {
|
||||
if ($this->isDirty($field)) {
|
||||
return true;
|
||||
// Add model-specific searchable fields
|
||||
if ($this instanceof \App\Models\Application) {
|
||||
$searchableFields[] = 'fqdn';
|
||||
$searchableFields[] = 'docker_compose_domains';
|
||||
} elseif ($this instanceof \App\Models\Server) {
|
||||
$searchableFields[] = 'ip';
|
||||
} elseif ($this instanceof \App\Models\Service) {
|
||||
// Services don't have direct fqdn, but name and description are covered
|
||||
} elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) {
|
||||
// Projects and environments only have name and description as searchable
|
||||
}
|
||||
}
|
||||
// Database models only have name and description as searchable
|
||||
|
||||
return false;
|
||||
// Check if any searchable field is dirty
|
||||
foreach ($searchableFields as $field) {
|
||||
// Check if attribute exists before checking if dirty
|
||||
if (array_key_exists($field, $this->getAttributes()) && $this->isDirty($field)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
// If checking changes fails, assume changes exist to be safe
|
||||
ray('Failed to check searchable changes: '.$e->getMessage());
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getTeamIdForCache()
|
||||
{
|
||||
// For database models, team is accessed through environment.project.team
|
||||
if (method_exists($this, 'team')) {
|
||||
if ($this instanceof \App\Models\Server) {
|
||||
$team = $this->team;
|
||||
} else {
|
||||
$team = $this->team();
|
||||
try {
|
||||
// For Project models (has direct team_id)
|
||||
if ($this instanceof \App\Models\Project) {
|
||||
return $this->team_id ?? null;
|
||||
}
|
||||
if (filled($team)) {
|
||||
return is_object($team) ? $team->id : null;
|
||||
|
||||
// For Environment models (get team_id through project)
|
||||
if ($this instanceof \App\Models\Environment) {
|
||||
return $this->project?->team_id;
|
||||
}
|
||||
}
|
||||
|
||||
// For models with direct team_id property
|
||||
if (property_exists($this, 'team_id') || isset($this->team_id)) {
|
||||
return $this->team_id;
|
||||
}
|
||||
// For database models, team is accessed through environment.project.team
|
||||
if (method_exists($this, 'team')) {
|
||||
if ($this instanceof \App\Models\Server) {
|
||||
$team = $this->team;
|
||||
} else {
|
||||
$team = $this->team();
|
||||
}
|
||||
if (filled($team)) {
|
||||
return is_object($team) ? $team->id : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// For models with direct team_id property
|
||||
if (property_exists($this, 'team_id') || isset($this->team_id)) {
|
||||
return $this->team_id ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
// If we can't determine team ID, return null
|
||||
ray('Failed to get team ID for cache: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,4 +269,4 @@ private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, str
|
|||
|
||||
$this->application_deployment_queue->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1119,3 +1119,64 @@ function escapeDollarSign($value)
|
|||
|
||||
return str_replace($search, $replace, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Docker build arguments from environment variables collection
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings
|
||||
*/
|
||||
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
|
||||
{
|
||||
$variables = collect($variables);
|
||||
|
||||
return $variables->map(function ($var) {
|
||||
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
||||
$value = is_array($var) ? data_get($var, 'value') : $var->value;
|
||||
$isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
|
||||
|
||||
if ($isMultiline) {
|
||||
// For multiline variables, strip surrounding quotes and escape for bash
|
||||
$raw_value = trim($value, "'");
|
||||
$escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
|
||||
|
||||
return "--build-arg {$key}=\"{$escaped_value}\"";
|
||||
}
|
||||
|
||||
// For regular variables, use escapeshellarg for security
|
||||
$value = escapeshellarg($value);
|
||||
|
||||
return "--build-arg {$key}={$value}";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Docker environment flags from environment variables collection
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
|
||||
* @return string Space-separated environment flags
|
||||
*/
|
||||
function generateDockerEnvFlags($variables): string
|
||||
{
|
||||
$variables = collect($variables);
|
||||
|
||||
return $variables
|
||||
->map(function ($var) {
|
||||
$key = is_array($var) ? data_get($var, 'key') : $var->key;
|
||||
$value = is_array($var) ? data_get($var, 'value') : $var->value;
|
||||
$isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
|
||||
|
||||
if ($isMultiline) {
|
||||
// For multiline variables, strip surrounding quotes and escape for bash
|
||||
$raw_value = trim($value, "'");
|
||||
$escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
|
||||
|
||||
return "-e {$key}=\"{$escaped_value}\"";
|
||||
}
|
||||
|
||||
$escaped_value = escapeshellarg($value);
|
||||
|
||||
return "-e {$key}={$escaped_value}";
|
||||
})
|
||||
->implode(' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1172,6 +1172,9 @@ function serviceParser(Service $resource): Collection
|
|||
|
||||
$parsedServices = collect([]);
|
||||
|
||||
// Generate SERVICE_NAME variables for docker compose services
|
||||
$serviceNameEnvironments = generateDockerComposeServiceName($services);
|
||||
|
||||
$allMagicEnvironments = collect([]);
|
||||
// Presave services
|
||||
foreach ($services as $serviceName => $service) {
|
||||
|
|
@ -1988,7 +1991,7 @@ function serviceParser(Service $resource): Collection
|
|||
$payload['volumes'] = $volumesParsed;
|
||||
}
|
||||
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
|
||||
$payload['environment'] = $environment->merge($coolifyEnvironments);
|
||||
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
|
||||
}
|
||||
if ($logging) {
|
||||
$payload['logging'] = $logging;
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function get_socialite_provider(string $provider)
|
|||
$config
|
||||
);
|
||||
|
||||
if ($provider == 'gitlab' && !empty($oauth_setting->base_url)) {
|
||||
if ($provider == 'gitlab' && ! empty($oauth_setting->base_url)) {
|
||||
$socialite->setHost($oauth_setting->base_url);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.433',
|
||||
'version' => '4.0.0-beta.435',
|
||||
'helper_version' => '1.0.11',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class TeamFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->company() . ' Team',
|
||||
'name' => $this->faker->company().' Team',
|
||||
'description' => $this->faker->sentence(),
|
||||
'personal_team' => false,
|
||||
'show_boarding' => false,
|
||||
|
|
@ -34,7 +34,7 @@ public function personal(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'personal_team' => true,
|
||||
'name' => $this->faker->firstName() . "'s Team",
|
||||
'name' => $this->faker->firstName()."'s Team",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
887
openapi.json
887
openapi.json
|
|
@ -3309,6 +3309,55 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/backups": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Get",
|
||||
"description": "Get backups details by database UUID.",
|
||||
"operationId": "get-database-backups-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all backups for a database",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "Content is very complex. Will be implemented later."
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -3658,6 +3707,200 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Delete backup configuration",
|
||||
"description": "Deletes a backup configuration and all its executions.",
|
||||
"operationId": "delete-backup-configuration-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheduled_backup_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the backup configuration to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_s3",
|
||||
"in": "query",
|
||||
"description": "Whether to delete all backup files from S3",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Backup configuration deleted.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "string",
|
||||
"example": "Backup configuration and all executions deleted."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Backup configuration not found.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "string",
|
||||
"example": "Backup configuration not found."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Update",
|
||||
"description": "Update a specific backup configuration for a given database, identified by its UUID and the backup ID",
|
||||
"operationId": "update-database-backup",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheduled_backup_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the backup configuration.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Database backup configuration data",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"save_s3": {
|
||||
"type": "boolean",
|
||||
"description": "Whether data is saved in s3 or not"
|
||||
},
|
||||
"s3_storage_uuid": {
|
||||
"type": "string",
|
||||
"description": "S3 storage UUID"
|
||||
},
|
||||
"backup_now": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to take a backup now or not"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the backup is enabled or not"
|
||||
},
|
||||
"databases_to_backup": {
|
||||
"type": "string",
|
||||
"description": "Comma separated list of databases to backup"
|
||||
},
|
||||
"dump_all": {
|
||||
"type": "boolean",
|
||||
"description": "Whether all databases are dumped or not"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "Frequency of the backup"
|
||||
},
|
||||
"database_backup_retention_amount_locally": {
|
||||
"type": "integer",
|
||||
"description": "Retention amount of the backup locally"
|
||||
},
|
||||
"database_backup_retention_days_locally": {
|
||||
"type": "integer",
|
||||
"description": "Retention days of the backup locally"
|
||||
},
|
||||
"database_backup_retention_max_storage_locally": {
|
||||
"type": "integer",
|
||||
"description": "Max storage of the backup locally"
|
||||
},
|
||||
"database_backup_retention_amount_s3": {
|
||||
"type": "integer",
|
||||
"description": "Retention amount of the backup in s3"
|
||||
},
|
||||
"database_backup_retention_days_s3": {
|
||||
"type": "integer",
|
||||
"description": "Retention days of the backup in s3"
|
||||
},
|
||||
"database_backup_retention_max_storage_s3": {
|
||||
"type": "integer",
|
||||
"description": "Max storage of the backup in S3"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Database backup configuration updated"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/postgresql": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -4694,6 +4937,175 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "Delete backup execution",
|
||||
"description": "Deletes a specific backup execution.",
|
||||
"operationId": "delete-backup-execution-by-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheduled_backup_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the backup configuration",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "execution_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the backup execution to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_s3",
|
||||
"in": "query",
|
||||
"description": "Whether to delete the backup from S3",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Backup execution deleted.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "string",
|
||||
"example": "Backup execution deleted."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Backup execution not found.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "string",
|
||||
"example": "Backup execution not found."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Databases"
|
||||
],
|
||||
"summary": "List backup executions",
|
||||
"description": "Get all executions for a specific backup configuration.",
|
||||
"operationId": "list-backup-executions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the database",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scheduled_backup_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the backup configuration",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of backup executions",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Backup configuration not found."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/databases\/{uuid}\/start": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -5095,6 +5507,477 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/github-apps": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"GitHub Apps"
|
||||
],
|
||||
"summary": "Create GitHub App",
|
||||
"description": "Create a new GitHub app.",
|
||||
"operationId": "create-github-app",
|
||||
"requestBody": {
|
||||
"description": "GitHub app creation payload.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"name",
|
||||
"api_url",
|
||||
"html_url",
|
||||
"app_id",
|
||||
"installation_id",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"private_key_uuid"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the GitHub app."
|
||||
},
|
||||
"organization": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Organization to associate the app with."
|
||||
},
|
||||
"api_url": {
|
||||
"type": "string",
|
||||
"description": "API URL for the GitHub app (e.g., https:\/\/api.github.com)."
|
||||
},
|
||||
"html_url": {
|
||||
"type": "string",
|
||||
"description": "HTML URL for the GitHub app (e.g., https:\/\/github.com)."
|
||||
},
|
||||
"custom_user": {
|
||||
"type": "string",
|
||||
"description": "Custom user for SSH access (default: git)."
|
||||
},
|
||||
"custom_port": {
|
||||
"type": "integer",
|
||||
"description": "Custom port for SSH access (default: 22)."
|
||||
},
|
||||
"app_id": {
|
||||
"type": "integer",
|
||||
"description": "GitHub App ID from GitHub."
|
||||
},
|
||||
"installation_id": {
|
||||
"type": "integer",
|
||||
"description": "GitHub Installation ID."
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "GitHub OAuth App Client ID."
|
||||
},
|
||||
"client_secret": {
|
||||
"type": "string",
|
||||
"description": "GitHub OAuth App Client Secret."
|
||||
},
|
||||
"webhook_secret": {
|
||||
"type": "string",
|
||||
"description": "Webhook secret for GitHub webhooks."
|
||||
},
|
||||
"private_key_uuid": {
|
||||
"type": "string",
|
||||
"description": "UUID of an existing private key for GitHub App authentication."
|
||||
},
|
||||
"is_system_wide": {
|
||||
"type": "boolean",
|
||||
"description": "Is this app system-wide (cloud only)."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "GitHub app created successfully.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"organization": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"api_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"html_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"custom_user": {
|
||||
"type": "string"
|
||||
},
|
||||
"custom_port": {
|
||||
"type": "integer"
|
||||
},
|
||||
"app_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"installation_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_key_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"is_system_wide": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"team_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/github-apps\/{github_app_id}\/repositories": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"GitHub Apps"
|
||||
],
|
||||
"summary": "Load Repositories for a GitHub App",
|
||||
"description": "Fetch repositories from GitHub for a given GitHub app.",
|
||||
"operationId": "load-repositories",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "github_app_id",
|
||||
"in": "path",
|
||||
"description": "GitHub App ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Repositories loaded successfully.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/github-apps\/{github_app_id}\/repositories\/{owner}\/{repo}\/branches": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"GitHub Apps"
|
||||
],
|
||||
"summary": "Load Branches for a GitHub Repository",
|
||||
"description": "Fetch branches from GitHub for a given repository.",
|
||||
"operationId": "load-branches",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "github_app_id",
|
||||
"in": "path",
|
||||
"description": "GitHub App ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "owner",
|
||||
"in": "path",
|
||||
"description": "Repository owner",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repo",
|
||||
"in": "path",
|
||||
"description": "Repository name",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Branches loaded successfully.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#\/components\/responses\/400"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/github-apps\/{github_app_id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"GitHub Apps"
|
||||
],
|
||||
"summary": "Delete GitHub App",
|
||||
"description": "Delete a GitHub app if it's not being used by any applications.",
|
||||
"operationId": "deleteGithubApp",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "github_app_id",
|
||||
"in": "path",
|
||||
"description": "GitHub App ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GitHub app deleted successfully",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "GitHub app deleted successfully"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "GitHub app not found"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict - GitHub app is in use",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "This GitHub app is being used by 5 application(s). Please delete all applications first."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"GitHub Apps"
|
||||
],
|
||||
"summary": "Update GitHub App",
|
||||
"description": "Update an existing GitHub app.",
|
||||
"operationId": "updateGithubApp",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "github_app_id",
|
||||
"in": "path",
|
||||
"description": "GitHub App ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "GitHub App name"
|
||||
},
|
||||
"organization": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "GitHub organization"
|
||||
},
|
||||
"api_url": {
|
||||
"type": "string",
|
||||
"description": "GitHub API URL"
|
||||
},
|
||||
"html_url": {
|
||||
"type": "string",
|
||||
"description": "GitHub HTML URL"
|
||||
},
|
||||
"custom_user": {
|
||||
"type": "string",
|
||||
"description": "Custom user for SSH"
|
||||
},
|
||||
"custom_port": {
|
||||
"type": "integer",
|
||||
"description": "Custom port for SSH"
|
||||
},
|
||||
"app_id": {
|
||||
"type": "integer",
|
||||
"description": "GitHub App ID"
|
||||
},
|
||||
"installation_id": {
|
||||
"type": "integer",
|
||||
"description": "GitHub Installation ID"
|
||||
},
|
||||
"client_id": {
|
||||
"type": "string",
|
||||
"description": "GitHub Client ID"
|
||||
},
|
||||
"client_secret": {
|
||||
"type": "string",
|
||||
"description": "GitHub Client Secret"
|
||||
},
|
||||
"webhook_secret": {
|
||||
"type": "string",
|
||||
"description": "GitHub Webhook Secret"
|
||||
},
|
||||
"private_key_uuid": {
|
||||
"type": "string",
|
||||
"description": "Private key UUID"
|
||||
},
|
||||
"is_system_wide": {
|
||||
"type": "boolean",
|
||||
"description": "Is system wide (non-cloud instances only)"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "GitHub app updated successfully",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "GitHub app updated successfully"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Updated GitHub app data"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "GitHub app not found"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/version": {
|
||||
"get": {
|
||||
"summary": "Version",
|
||||
|
|
@ -8890,6 +9773,10 @@
|
|||
"name": "Deployments",
|
||||
"description": "Deployments"
|
||||
},
|
||||
{
|
||||
"name": "GitHub Apps",
|
||||
"description": "GitHub Apps"
|
||||
},
|
||||
{
|
||||
"name": "Projects",
|
||||
"description": "Projects"
|
||||
|
|
|
|||
559
openapi.yaml
559
openapi.yaml
|
|
@ -2097,6 +2097,39 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/backups':
|
||||
get:
|
||||
tags:
|
||||
- Databases
|
||||
summary: Get
|
||||
description: 'Get backups details by database UUID.'
|
||||
operationId: get-database-backups-by-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all backups for a database'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
example: 'Content is very complex. Will be implemented later.'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}':
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -2347,6 +2380,139 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/backups/{scheduled_backup_uuid}':
|
||||
delete:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Delete backup configuration'
|
||||
description: 'Deletes a backup configuration and all its executions.'
|
||||
operationId: delete-backup-configuration-by-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: scheduled_backup_uuid
|
||||
in: path
|
||||
description: 'UUID of the backup configuration to delete'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
-
|
||||
name: delete_s3
|
||||
in: query
|
||||
description: 'Whether to delete all backup files from S3'
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: 'Backup configuration deleted.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: string, example: 'Backup configuration and all executions deleted.' }
|
||||
type: object
|
||||
'404':
|
||||
description: 'Backup configuration not found.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: string, example: 'Backup configuration not found.' }
|
||||
type: object
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- Databases
|
||||
summary: Update
|
||||
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID'
|
||||
operationId: update-database-backup
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
-
|
||||
name: scheduled_backup_uuid
|
||||
in: path
|
||||
description: 'UUID of the backup configuration.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
description: 'Database backup configuration data'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
save_s3:
|
||||
type: boolean
|
||||
description: 'Whether data is saved in s3 or not'
|
||||
s3_storage_uuid:
|
||||
type: string
|
||||
description: 'S3 storage UUID'
|
||||
backup_now:
|
||||
type: boolean
|
||||
description: 'Whether to take a backup now or not'
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'Whether the backup is enabled or not'
|
||||
databases_to_backup:
|
||||
type: string
|
||||
description: 'Comma separated list of databases to backup'
|
||||
dump_all:
|
||||
type: boolean
|
||||
description: 'Whether all databases are dumped or not'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'Frequency of the backup'
|
||||
database_backup_retention_amount_locally:
|
||||
type: integer
|
||||
description: 'Retention amount of the backup locally'
|
||||
database_backup_retention_days_locally:
|
||||
type: integer
|
||||
description: 'Retention days of the backup locally'
|
||||
database_backup_retention_max_storage_locally:
|
||||
type: integer
|
||||
description: 'Max storage of the backup locally'
|
||||
database_backup_retention_amount_s3:
|
||||
type: integer
|
||||
description: 'Retention amount of the backup in s3'
|
||||
database_backup_retention_days_s3:
|
||||
type: integer
|
||||
description: 'Retention days of the backup in s3'
|
||||
database_backup_retention_max_storage_s3:
|
||||
type: integer
|
||||
description: 'Max storage of the backup in S3'
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Database backup configuration updated'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/databases/postgresql:
|
||||
post:
|
||||
tags:
|
||||
|
|
@ -3094,6 +3260,102 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}':
|
||||
delete:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'Delete backup execution'
|
||||
description: 'Deletes a specific backup execution.'
|
||||
operationId: delete-backup-execution-by-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: scheduled_backup_uuid
|
||||
in: path
|
||||
description: 'UUID of the backup configuration'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
-
|
||||
name: execution_uuid
|
||||
in: path
|
||||
description: 'UUID of the backup execution to delete'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
-
|
||||
name: delete_s3
|
||||
in: query
|
||||
description: 'Whether to delete the backup from S3'
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
'200':
|
||||
description: 'Backup execution deleted.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: string, example: 'Backup execution deleted.' }
|
||||
type: object
|
||||
'404':
|
||||
description: 'Backup execution not found.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: string, example: 'Backup execution not found.' }
|
||||
type: object
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/backups/{scheduled_backup_uuid}/executions':
|
||||
get:
|
||||
tags:
|
||||
- Databases
|
||||
summary: 'List backup executions'
|
||||
description: 'Get all executions for a specific backup configuration.'
|
||||
operationId: list-backup-executions
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the database'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: scheduled_backup_uuid
|
||||
in: path
|
||||
description: 'UUID of the backup configuration'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: 'List of backup executions'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: array, items: { properties: { uuid: { type: string }, filename: { type: string }, size: { type: integer }, created_at: { type: string }, message: { type: string }, status: { type: string } }, type: object } }
|
||||
type: object
|
||||
'404':
|
||||
description: 'Backup configuration not found.'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/databases/{uuid}/start':
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -3348,6 +3610,300 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/github-apps:
|
||||
post:
|
||||
tags:
|
||||
- 'GitHub Apps'
|
||||
summary: 'Create GitHub App'
|
||||
description: 'Create a new GitHub app.'
|
||||
operationId: create-github-app
|
||||
requestBody:
|
||||
description: 'GitHub app creation payload.'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- name
|
||||
- api_url
|
||||
- html_url
|
||||
- app_id
|
||||
- installation_id
|
||||
- client_id
|
||||
- client_secret
|
||||
- private_key_uuid
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'Name of the GitHub app.'
|
||||
organization:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Organization to associate the app with.'
|
||||
api_url:
|
||||
type: string
|
||||
description: 'API URL for the GitHub app (e.g., https://api.github.com).'
|
||||
html_url:
|
||||
type: string
|
||||
description: 'HTML URL for the GitHub app (e.g., https://github.com).'
|
||||
custom_user:
|
||||
type: string
|
||||
description: 'Custom user for SSH access (default: git).'
|
||||
custom_port:
|
||||
type: integer
|
||||
description: 'Custom port for SSH access (default: 22).'
|
||||
app_id:
|
||||
type: integer
|
||||
description: 'GitHub App ID from GitHub.'
|
||||
installation_id:
|
||||
type: integer
|
||||
description: 'GitHub Installation ID.'
|
||||
client_id:
|
||||
type: string
|
||||
description: 'GitHub OAuth App Client ID.'
|
||||
client_secret:
|
||||
type: string
|
||||
description: 'GitHub OAuth App Client Secret.'
|
||||
webhook_secret:
|
||||
type: string
|
||||
description: 'Webhook secret for GitHub webhooks.'
|
||||
private_key_uuid:
|
||||
type: string
|
||||
description: 'UUID of an existing private key for GitHub App authentication.'
|
||||
is_system_wide:
|
||||
type: boolean
|
||||
description: 'Is this app system-wide (cloud only).'
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'GitHub app created successfully.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
id: { type: integer }
|
||||
uuid: { type: string }
|
||||
name: { type: string }
|
||||
organization: { type: string, nullable: true }
|
||||
api_url: { type: string }
|
||||
html_url: { type: string }
|
||||
custom_user: { type: string }
|
||||
custom_port: { type: integer }
|
||||
app_id: { type: integer }
|
||||
installation_id: { type: integer }
|
||||
client_id: { type: string }
|
||||
private_key_id: { type: integer }
|
||||
is_system_wide: { type: boolean }
|
||||
team_id: { type: integer }
|
||||
type: object
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/github-apps/{github_app_id}/repositories':
|
||||
get:
|
||||
tags:
|
||||
- 'GitHub Apps'
|
||||
summary: 'Load Repositories for a GitHub App'
|
||||
description: 'Fetch repositories from GitHub for a given GitHub app.'
|
||||
operationId: load-repositories
|
||||
parameters:
|
||||
-
|
||||
name: github_app_id
|
||||
in: path
|
||||
description: 'GitHub App ID'
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 'Repositories loaded successfully.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: array, items: { type: object } }
|
||||
type: object
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches':
|
||||
get:
|
||||
tags:
|
||||
- 'GitHub Apps'
|
||||
summary: 'Load Branches for a GitHub Repository'
|
||||
description: 'Fetch branches from GitHub for a given repository.'
|
||||
operationId: load-branches
|
||||
parameters:
|
||||
-
|
||||
name: github_app_id
|
||||
in: path
|
||||
description: 'GitHub App ID'
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
-
|
||||
name: owner
|
||||
in: path
|
||||
description: 'Repository owner'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: repo
|
||||
in: path
|
||||
description: 'Repository name'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Branches loaded successfully.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
'': { type: array, items: { type: object } }
|
||||
type: object
|
||||
'400':
|
||||
$ref: '#/components/responses/400'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/github-apps/{github_app_id}':
|
||||
delete:
|
||||
tags:
|
||||
- 'GitHub Apps'
|
||||
summary: 'Delete GitHub App'
|
||||
description: "Delete a GitHub app if it's not being used by any applications."
|
||||
operationId: deleteGithubApp
|
||||
parameters:
|
||||
-
|
||||
name: github_app_id
|
||||
in: path
|
||||
description: 'GitHub App ID'
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: 'GitHub app deleted successfully'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'GitHub app deleted successfully' }
|
||||
type: object
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'404':
|
||||
description: 'GitHub app not found'
|
||||
'409':
|
||||
description: 'Conflict - GitHub app is in use'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'This GitHub app is being used by 5 application(s). Please delete all applications first.' }
|
||||
type: object
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- 'GitHub Apps'
|
||||
summary: 'Update GitHub App'
|
||||
description: 'Update an existing GitHub app.'
|
||||
operationId: updateGithubApp
|
||||
parameters:
|
||||
-
|
||||
name: github_app_id
|
||||
in: path
|
||||
description: 'GitHub App ID'
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'GitHub App name'
|
||||
organization:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'GitHub organization'
|
||||
api_url:
|
||||
type: string
|
||||
description: 'GitHub API URL'
|
||||
html_url:
|
||||
type: string
|
||||
description: 'GitHub HTML URL'
|
||||
custom_user:
|
||||
type: string
|
||||
description: 'Custom user for SSH'
|
||||
custom_port:
|
||||
type: integer
|
||||
description: 'Custom port for SSH'
|
||||
app_id:
|
||||
type: integer
|
||||
description: 'GitHub App ID'
|
||||
installation_id:
|
||||
type: integer
|
||||
description: 'GitHub Installation ID'
|
||||
client_id:
|
||||
type: string
|
||||
description: 'GitHub Client ID'
|
||||
client_secret:
|
||||
type: string
|
||||
description: 'GitHub Client Secret'
|
||||
webhook_secret:
|
||||
type: string
|
||||
description: 'GitHub Webhook Secret'
|
||||
private_key_uuid:
|
||||
type: string
|
||||
description: 'Private key UUID'
|
||||
is_system_wide:
|
||||
type: boolean
|
||||
description: 'Is system wide (non-cloud instances only)'
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'GitHub app updated successfully'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'GitHub app updated successfully' }
|
||||
data: { type: object, description: 'Updated GitHub app data' }
|
||||
type: object
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'404':
|
||||
description: 'GitHub app not found'
|
||||
'422':
|
||||
description: 'Validation error'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/version:
|
||||
get:
|
||||
summary: Version
|
||||
|
|
@ -5781,6 +6337,9 @@ tags:
|
|||
-
|
||||
name: Deployments
|
||||
description: Deployments
|
||||
-
|
||||
name: 'GitHub Apps'
|
||||
description: 'GitHub Apps'
|
||||
-
|
||||
name: Projects
|
||||
description: Projects
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.433"
|
||||
"version": "4.0.0-beta.435"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.434"
|
||||
"version": "4.0.0-beta.436"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
BIN
public/ente-photos-icon-green.png
Normal file
BIN
public/ente-photos-icon-green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
15
public/svgs/ente-photos.svg
Normal file
15
public/svgs/ente-photos.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Official Ente Photos icon based on the official PNG -->
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00D4AA;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#00A693;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Main circular background matching official Ente green -->
|
||||
<circle cx="128" cy="128" r="120" fill="url(#gradient)"/>
|
||||
|
||||
<!-- Official Ente "e" letterform based on the PNG icon -->
|
||||
<path d="M75 128 C75 90, 95 70, 128 70 C161 70, 181 90, 181 118 L85 118 C85 148, 105 168, 128 168 C146 168, 160 158, 170 144 L185 160 C172 178, 152 190, 128 190 C95 190, 75 170, 75 128 Z M85 102 L171 102 C167 84, 149 82, 128 82 C107 82, 89 84, 85 102 Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 872 B |
BIN
public/svgs/ente.png
Normal file
BIN
public/svgs/ente.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
|
|
@ -20,8 +20,11 @@ @theme {
|
|||
--color-warning: #fcd452;
|
||||
--color-success: #16a34a;
|
||||
--color-error: #dc2626;
|
||||
--color-coollabs-50: #f5f0ff;
|
||||
--color-coollabs: #6b16ed;
|
||||
--color-coollabs-100: #7317ff;
|
||||
--color-coollabs-200: #5a12c7;
|
||||
--color-coollabs-300: #4a0fa3;
|
||||
--color-coolgray-100: #181818;
|
||||
--color-coolgray-200: #202020;
|
||||
--color-coolgray-300: #242424;
|
||||
|
|
@ -91,11 +94,11 @@ option {
|
|||
}
|
||||
|
||||
button[isError]:not(:disabled) {
|
||||
@apply text-white bg-red-600 hover:bg-red-700;
|
||||
@apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white;
|
||||
}
|
||||
|
||||
button[isHighlighted]:not(:disabled) {
|
||||
@apply text-white bg-coollabs hover:bg-coollabs-100;
|
||||
@apply text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
|
@ -118,6 +121,11 @@ a {
|
|||
@apply hover:text-black dark:hover:text-white;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
@apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100;
|
||||
}
|
||||
|
||||
label {
|
||||
@apply dark:text-neutral-400;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ @utility select {
|
|||
}
|
||||
|
||||
@utility button {
|
||||
@apply flex gap-2 justify-center items-center px-2 py-1 text-sm text-black normal-case rounded-sm border outline-0 cursor-pointer bg-neutral-200/50 border-neutral-300 hover:bg-neutral-300 dark:bg-coolgray-200 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-500 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300;
|
||||
@apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100;
|
||||
}
|
||||
|
||||
@utility alert-success {
|
||||
|
|
@ -83,11 +83,11 @@ @utility add-tag {
|
|||
}
|
||||
|
||||
@utility dropdown-item {
|
||||
@apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50;
|
||||
@apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
|
||||
}
|
||||
|
||||
@utility dropdown-item-no-padding {
|
||||
@apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50;
|
||||
@apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
|
||||
}
|
||||
|
||||
@utility badge {
|
||||
|
|
@ -155,15 +155,15 @@ @utility kbd-custom {
|
|||
}
|
||||
|
||||
@utility box {
|
||||
@apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline;
|
||||
@apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm;
|
||||
}
|
||||
|
||||
@utility box-boarding {
|
||||
@apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black;
|
||||
@apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black rounded-sm;
|
||||
}
|
||||
|
||||
@utility box-without-bg {
|
||||
@apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-black;
|
||||
@apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-coolgray-300 rounded-sm;
|
||||
}
|
||||
|
||||
@utility box-without-bg-without-border {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
@else
|
||||
<div class="dropdown-item" wire:click='deploy(true)'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ class="inline-flex items-center justify-start pr-8 transition-colors focus:outli
|
|||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
|
||||
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
|
||||
class="absolute top-0 z-50 mt-6 min-w-max" x-cloak>
|
||||
<div class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-black border-neutral-300">
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class="relative w-auto h-auto" wire:ignore>
|
|||
@endif
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen"
|
||||
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
|
||||
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"
|
||||
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
|
||||
|
|
@ -45,7 +46,7 @@ class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w
|
|||
<div class="flex items-center justify-between pb-3">
|
||||
<h3 class="text-2xl font-bold">{{ $title }}</h3>
|
||||
<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">
|
||||
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" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@
|
|||
<div class="dropdown-item" @click="$wire.dispatch('forceDeployEvent')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-linecap="round" stroke-linejoin="round" data-darkreader-inline-stroke=""
|
||||
style="--darkreader-inline-stroke: currentColor;" class="w-6 h-6" stroke-width="2">
|
||||
style="--darkreader-inline-stroke: currentColor;" class="w-4 h-4" stroke-width="2">
|
||||
<path d="M7 7l5 5l-5 5"></path>
|
||||
<path d="M13 7l5 5l-5 5"></path>
|
||||
</svg>
|
||||
Force Deploy
|
||||
</div>
|
||||
<div class="dropdown-item" wire:click='stop(true)''>
|
||||
<svg class="w-6 h-6" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="w-4 h-4" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M26 20h-6v-2h6zm4 8h-6v-2h6zm-2-4h-6v-2h6z" />
|
||||
<path fill="currentColor"
|
||||
d="M17.003 20a4.895 4.895 0 0 0-2.404-4.173L22 3l-1.73-1l-7.577 13.126a5.699 5.699 0 0 0-5.243 1.503C3.706 20.24 3.996 28.682 4.01 29.04a1 1 0 0 0 1 .96h14.991a1 1 0 0 0 .6-1.8c-3.54-2.656-3.598-8.146-3.598-8.2Zm-5.073-3.003A3.11 3.11 0 0 1 15.004 20c0 .038.002.208.017.469l-5.9-2.624a3.8 3.8 0 0 1 2.809-.848ZM15.45 28A5.2 5.2 0 0 1 14 25h-2a6.5 6.5 0 0 0 .968 3h-2.223A16.617 16.617 0 0 1 10 24H8a17.342 17.342 0 0 0 .665 4H6c.031-1.836.29-5.892 1.803-8.553l7.533 3.35A13.025 13.025 0 0 0 17.596 28Z" />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
</x-slot>
|
||||
@foreach ($links as $link)
|
||||
<a class="dropdown-item" target="_blank" href="{{ $link }}">
|
||||
<x-external-link class="size-4" />{{ $link }}
|
||||
<x-external-link class="size-3.5" />{{ $link }}
|
||||
</a>
|
||||
@endforeach
|
||||
</x-dropdown>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<!-- Global search component - included once to prevent keyboard shortcut duplication -->
|
||||
<livewire:global-search />
|
||||
@auth
|
||||
<livewire:deployments-indicator />
|
||||
<div x-data="{
|
||||
open: false,
|
||||
init() {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@
|
|||
@if ($projects->count() > 0)
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
@foreach ($projects as $project)
|
||||
<div class="gap-2 border cursor-pointer box group"
|
||||
wire:click="navigateToProject('{{ $project->uuid }}')">
|
||||
<div class="relative gap-2 cursor-pointer box group">
|
||||
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
|
||||
<div class="flex flex-1 mx-6">
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title">{{ $project->name }}</div>
|
||||
|
|
@ -28,20 +28,20 @@
|
|||
{{ $project->description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2 text-xs font-bold">
|
||||
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold">
|
||||
@if ($project->environments->first())
|
||||
@can('createAnyResource')
|
||||
<a class="hover:underline" wire:click.stop
|
||||
<a class="hover:underline"
|
||||
href="{{ route('project.resource.create', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $project->environments->first()->uuid,
|
||||
]) }}">
|
||||
<span class="p-2 font-bold">+ Add Resource</span>
|
||||
+ Add Resource
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
@can('update', $project)
|
||||
<a class="hover:underline" wire:click.stop
|
||||
<a class="hover:underline"
|
||||
href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}">
|
||||
Settings
|
||||
</a>
|
||||
|
|
@ -125,52 +125,4 @@
|
|||
@endif
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@if ($servers->count() > 0 && $projects->count() > 0)
|
||||
<section>
|
||||
<div class="flex items-start gap-2">
|
||||
<h3 class="pb-2">Deployments</h3>
|
||||
@if (count($deploymentsPerServer) > 0)
|
||||
<x-loading />
|
||||
@endif
|
||||
@can('cleanupDeploymentQueue', Application::class)
|
||||
<x-modal-confirmation title="Confirm Cleanup Queues?" buttonTitle="Cleanup Queues" isErrorButton
|
||||
submitAction="cleanupQueue" :actions="['All running Deployment Queues will be cleaned up.']" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Cleanup Deployment Queues" :dispatchEvent="true"
|
||||
dispatchEventType="success" dispatchEventMessage="Deployment Queues cleanup started." />
|
||||
@endcan
|
||||
</div>
|
||||
<div wire:poll.3000ms="loadDeployments" class="grid grid-cols-1">
|
||||
@forelse ($deploymentsPerServer as $serverName => $deployments)
|
||||
<h4 class="pb-2">{{ $serverName }}</h4>
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
@foreach ($deployments as $deployment)
|
||||
<a href="{{ data_get($deployment, 'deployment_url') }}" @class([
|
||||
'gap-2 cursor-pointer box group border-l-2 border-dotted',
|
||||
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
|
||||
'border-yellow-500' => data_get($deployment, 'status') === 'in_progress',
|
||||
])>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
{{ data_get($deployment, 'application_name') }}
|
||||
</div>
|
||||
@if (data_get($deployment, 'pull_request_id') !== 0)
|
||||
<div class="box-description">
|
||||
PR #{{ data_get($deployment, 'pull_request_id') }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="box-description">
|
||||
{{ str(data_get($deployment, 'status'))->headline() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@empty
|
||||
<div>No deployments running.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
92
resources/views/livewire/deployments-indicator.blade.php
Normal file
92
resources/views/livewire/deployments-indicator.blade.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<div wire:poll.3000ms x-data="{
|
||||
expanded: @entangle('expanded')
|
||||
}" class="fixed bottom-0 z-50 mb-4 left-0 lg:left-56 ml-4">
|
||||
@if ($this->deploymentCount > 0)
|
||||
<div class="relative">
|
||||
<!-- Indicator Button -->
|
||||
<button @click="expanded = !expanded"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all duration-200 dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 hover:shadow-xl">
|
||||
<!-- Animated spinner -->
|
||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" 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"
|
||||
stroke-width="4"></circle>
|
||||
<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">
|
||||
</path>
|
||||
</svg>
|
||||
|
||||
<!-- Deployment count -->
|
||||
<span class="text-sm font-medium dark:text-neutral-200 text-gray-800">
|
||||
{{ $this->deploymentCount }} {{ Str::plural('deployment', $this->deploymentCount) }}
|
||||
</span>
|
||||
|
||||
<!-- Expand/collapse icon -->
|
||||
<svg class="w-4 h-4 transition-transform duration-200 dark:text-neutral-400 text-gray-600"
|
||||
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expanded deployment list -->
|
||||
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-2"
|
||||
x-cloak
|
||||
class="absolute bottom-full mb-2 w-80 max-h-96 overflow-y-auto rounded-lg shadow-xl dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200">
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
@foreach ($this->deployments as $deployment)
|
||||
<a href="{{ $deployment->deployment_url }}"
|
||||
class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 transition-all duration-200 hover:ring-2 hover:ring-coollabs dark:hover:ring-warning cursor-pointer">
|
||||
<!-- Status indicator -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
@if ($deployment->status === 'in_progress')
|
||||
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin"
|
||||
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" stroke-width="4"></circle>
|
||||
<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">
|
||||
</path>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Deployment info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium dark:text-neutral-200 text-gray-900 truncate">
|
||||
{{ $deployment->application_name }}
|
||||
</div>
|
||||
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
|
||||
{{ $deployment->server_name }}
|
||||
</p>
|
||||
@if ($deployment->pull_request_id)
|
||||
<p class="text-xs dark:text-neutral-400 text-gray-600">
|
||||
PR #{{ $deployment->pull_request_id }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-xs mt-1 capitalize"
|
||||
:class="{
|
||||
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
|
||||
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
|
||||
}">
|
||||
{{ str_replace('_', ' ', $deployment->status) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -31,21 +31,15 @@
|
|||
}
|
||||
},
|
||||
init() {
|
||||
// Listen for custom event from navbar search button at window level
|
||||
window.addEventListener('open-global-search', () => {
|
||||
this.openModal();
|
||||
});
|
||||
|
||||
// Listen for / key press globally
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Create named handlers for proper cleanup
|
||||
const openGlobalSearchHandler = () => this.openModal();
|
||||
const slashKeyHandler = (e) => {
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName) && !this.modalOpen) {
|
||||
e.preventDefault();
|
||||
this.openModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for Cmd+K or Ctrl+K globally
|
||||
document.addEventListener('keydown', (e) => {
|
||||
};
|
||||
const cmdKHandler = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (this.modalOpen) {
|
||||
|
|
@ -54,17 +48,13 @@
|
|||
this.openModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
};
|
||||
const escapeKeyHandler = (e) => {
|
||||
if (e.key === 'Escape' && this.modalOpen) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for arrow keys when modal is open
|
||||
document.addEventListener('keydown', (e) => {
|
||||
};
|
||||
const arrowKeyHandler = (e) => {
|
||||
if (!this.modalOpen) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
|
@ -73,6 +63,22 @@
|
|||
e.preventDefault();
|
||||
this.navigateResults('up');
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener('open-global-search', openGlobalSearchHandler);
|
||||
document.addEventListener('keydown', slashKeyHandler);
|
||||
document.addEventListener('keydown', cmdKHandler);
|
||||
document.addEventListener('keydown', escapeKeyHandler);
|
||||
document.addEventListener('keydown', arrowKeyHandler);
|
||||
|
||||
// Cleanup on component destroy
|
||||
this.$el.addEventListener('alpine:destroy', () => {
|
||||
window.removeEventListener('open-global-search', openGlobalSearchHandler);
|
||||
document.removeEventListener('keydown', slashKeyHandler);
|
||||
document.removeEventListener('keydown', cmdKHandler);
|
||||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
document.removeEventListener('keydown', arrowKeyHandler);
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
|
@ -80,41 +86,42 @@
|
|||
<!-- Modal overlay -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-cloak
|
||||
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs">
|
||||
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[20vh]">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
|
||||
</div>
|
||||
<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-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-[48rem] bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300"
|
||||
<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-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-end="opacity-0 -translate-y-4 scale-95" class="relative w-full max-w-2xl mx-4"
|
||||
@click.stop>
|
||||
|
||||
<div class="flex justify-between items-center pb-3">
|
||||
<h3 class="pr-8 text-2xl font-bold">Search</h3>
|
||||
<button @click="closeModal()"
|
||||
class="flex absolute top-2 right-2 justify-center items-center w-8 h-8 rounded-full dark:text-white hover:bg-coolgray-300">
|
||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
<!-- Search input (always visible) -->
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" wire:model.live.debounce.500ms="searchQuery"
|
||||
placeholder="Search for resources, servers, projects, and environments" x-ref="searchInput"
|
||||
x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })"
|
||||
class="w-full pl-12 pr-12 py-4 text-base bg-white dark:bg-coolgray-100 border-none rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500" />
|
||||
<button @click="closeModal()"
|
||||
class="absolute inset-y-0 right-2 flex items-center justify-center px-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 rounded">
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative w-auto">
|
||||
<input type="text" wire:model.live.debounce.500ms="searchQuery"
|
||||
placeholder="Type to search for applications, services, databases, and servers..."
|
||||
x-ref="searchInput" x-init="$watch('modalOpen', value => { if (value) setTimeout(() => $refs.searchInput.focus(), 100) })" class="w-full input mb-4" />
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="relative min-h-[330px] max-h-[400px] overflow-y-auto scrollbar">
|
||||
<!-- Search results (with background) -->
|
||||
@if (strlen($searchQuery) >= 1)
|
||||
<div
|
||||
class="mt-2 bg-white dark:bg-coolgray-100 rounded-lg shadow-xl ring-1 ring-neutral-200 dark:ring-coolgray-300 overflow-hidden">
|
||||
<!-- Loading indicator -->
|
||||
<div wire:loading.flex wire:target="searchQuery"
|
||||
class="min-h-[330px] items-center justify-center">
|
||||
class="min-h-[200px] items-center justify-center p-8">
|
||||
<div class="text-center">
|
||||
<svg class="animate-spin mx-auto h-8 w-8 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
|
|
@ -131,59 +138,52 @@ class="min-h-[330px] items-center justify-center">
|
|||
</div>
|
||||
|
||||
<!-- Results content - hidden while loading -->
|
||||
<div wire:loading.remove wire:target="searchQuery">
|
||||
<div wire:loading.remove wire:target="searchQuery"
|
||||
class="max-h-[60vh] overflow-y-auto scrollbar">
|
||||
@if (strlen($searchQuery) >= 2 && count($searchResults) > 0)
|
||||
<div class="space-y-1 my-4 pb-4">
|
||||
<div class="py-2">
|
||||
@foreach ($searchResults as $index => $result)
|
||||
<a href="{{ $result['link'] ?? '#' }}"
|
||||
class="search-result-item block p-3 mx-1 hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:ring-1 focus:ring-coollabs focus:bg-neutral-100 dark:focus:bg-coolgray-200 ">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-neutral-900 dark:text-white">
|
||||
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-yellow-50 dark:focus:bg-yellow-900/20 border-transparent hover:border-coollabs focus:border-yellow-500 dark:focus:border-yellow-400">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
{{ $result['name'] }}
|
||||
</span>
|
||||
@if ($result['type'] === 'server')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
Server
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!empty($result['project']) && !empty($result['environment']))
|
||||
<span
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{{ $result['project'] }} / {{ $result['environment'] }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($result['type'] === 'application')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full bg-neutral-100 dark:bg-coolgray-300 text-neutral-700 dark:text-neutral-300 shrink-0">
|
||||
@if ($result['type'] === 'application')
|
||||
Application
|
||||
</span>
|
||||
@elseif ($result['type'] === 'service')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
@elseif ($result['type'] === 'service')
|
||||
Service
|
||||
</span>
|
||||
@elseif ($result['type'] === 'database')
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded bg-coolgray-100 text-white">
|
||||
@elseif ($result['type'] === 'database')
|
||||
{{ ucfirst($result['subtype'] ?? 'Database') }}
|
||||
</span>
|
||||
@endif
|
||||
@elseif ($result['type'] === 'server')
|
||||
Server
|
||||
@elseif ($result['type'] === 'project')
|
||||
Project
|
||||
@elseif ($result['type'] === 'environment')
|
||||
Environment
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@if (!empty($result['description']))
|
||||
@if (!empty($result['project']) && !empty($result['environment']))
|
||||
<div
|
||||
class="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{{ Str::limit($result['description'], 100) }}
|
||||
class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{{ $result['project'] }} / {{ $result['environment'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($result['description']))
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ Str::limit($result['description'], 80) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 ml-2 h-4 w-4 text-neutral-400" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
|
|
@ -192,41 +192,29 @@ class="shrink-0 ml-2 h-4 w-4 text-neutral-400" fill="none"
|
|||
@endforeach
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0)
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="flex items-center justify-center py-12 px-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
No results found for "<strong>{{ $searchQuery }}</strong>"
|
||||
<p class="mt-4 text-sm font-medium text-neutral-900 dark:text-white">
|
||||
No results found
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Try different keywords or check the spelling
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) > 0 && strlen($searchQuery) < 2)
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="flex items-center justify-center py-12 px-4">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center justify-center min-h-[330px]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Start typing to search
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-500 mt-2">
|
||||
Search for applications, services, databases, and servers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
@if (!$useInstanceEmailSettings)
|
||||
<div class="flex flex-col gap-4">
|
||||
<form wire:submit='submitSmtp'
|
||||
class="p-4 border dark:border-coolgray-300 border-neutral-200 flex flex-col gap-2">
|
||||
class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>SMTP Server</h3>
|
||||
<x-forms.button canGate="update" :canResource="$settings" type="submit">
|
||||
|
|
@ -89,7 +89,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 flex flex-col gap-
|
|||
</div>
|
||||
</form>
|
||||
<form wire:submit='submitResend'
|
||||
class="p-4 border dark:border-coolgray-300 border-neutral-200 flex flex-col gap-2">
|
||||
class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>Resend</h3>
|
||||
<x-forms.button canGate="update" :canResource="$settings" type="submit">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
@else
|
||||
<x-forms.button wire:click.prevent="show_debug">Show Debug Logs</x-forms.button>
|
||||
@endif
|
||||
@if (isDev())
|
||||
<x-forms.button x-on:click="$wire.copyLogsToClipboard().then(text => navigator.clipboard.writeText(text))">Copy Logs</x-forms.button>
|
||||
@endif
|
||||
@if (data_get($application_deployment_queue, 'status') === 'queued')
|
||||
<x-forms.button wire:click.prevent="force_start">Force Start</x-forms.button>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -166,12 +166,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
@if ($application->destination->server->isSwarm())
|
||||
<x-forms.input required id="application.docker_registry_image_name" label="Docker Image"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
|
||||
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
|
||||
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input id="application.docker_registry_image_name" label="Docker Image"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
|
||||
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
|
||||
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
|
||||
x-bind:disabled="!canUpdate" />
|
||||
@endif
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<div class="flex flex-wrap">
|
||||
@forelse ($images as $image)
|
||||
<div class="w-2/4 p-2">
|
||||
<div class="bg-white border rounded-sm dark:border-black dark:bg-coolgray-100 border-neutral-200">
|
||||
<div class="bg-white border rounded-sm dark:border-coolgray-300 dark:bg-coolgray-100 border-neutral-200">
|
||||
<div class="p-2">
|
||||
<div class="">
|
||||
@if (data_get($image, 'is_current'))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">All your projects are here.</div>
|
||||
<div x-data="searchComponent()">
|
||||
<div x-data="searchComponent()" class="-mt-1">
|
||||
<x-forms.input placeholder="Search for name, description..." x-model="search" id="null" />
|
||||
<div class="grid grid-cols-2 gap-4 pt-4">
|
||||
<template x-if="filteredProjects.length === 0">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div>
|
||||
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
|
||||
<h1>Create a new Application</h1>
|
||||
<div class="pb-4">You can deploy an existing Docker Image from any Registry.</div>
|
||||
<form wire:submit="submit">
|
||||
|
|
@ -6,6 +6,24 @@
|
|||
<h2>Docker Image</h2>
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
</div>
|
||||
<x-forms.input rows="20" id="dockerImage" placeholder="nginx:latest"></x-forms.textarea>
|
||||
<div class="space-y-4">
|
||||
<x-forms.input id="imageName" label="Image Name" placeholder="nginx or ghcr.io/user/app"
|
||||
helper="Enter the Docker image name with optional registry. Examples: nginx, ghcr.io/user/app, localhost:5000/myapp"
|
||||
required autofocus />
|
||||
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest"
|
||||
helper="Enter a tag like 'latest' or 'v1.2.3'. Leave empty if using SHA256." />
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 hidden md:flex items-center justify-center z-10">
|
||||
<div
|
||||
class="px-2 py-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-300 rounded text-xs font-bold text-neutral-500 dark:text-neutral-400">
|
||||
OR
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.input id="imageSha256" label="SHA256 Digest (optional)"
|
||||
placeholder="59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0"
|
||||
helper="Enter only the 64-character hex digest (without 'sha256:' prefix)" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,73 +1,101 @@
|
|||
<div class="">
|
||||
<div class="flex flex-col justify-center pb-4 text-sm select-text">
|
||||
<div class="flex gap-2 md:flex-row flex-col pt-4">
|
||||
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
|
||||
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
@can('update', $resource)
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
@if ($fileStorage->is_directory)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
||||
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
||||
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
|
||||
'The selected directory and all its contents will be permanently deleted from the container.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
This directory is mounted as read-only and cannot be modified from the UI.
|
||||
@else
|
||||
@if (!$fileStorage->is_binary)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to directory" />
|
||||
@endif
|
||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from server</x-forms.button>
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
This file is mounted as read-only and cannot be modified from the UI.
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
@if (!$fileStorage->is_directory)
|
||||
@can('update', $resource)
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
|
||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||
@endif
|
||||
<div class="flex flex-col justify-center text-sm select-text">
|
||||
<div class="flex gap-2 md:flex-row flex-col">
|
||||
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
|
||||
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
@if (!$isReadOnly)
|
||||
@can('update', $resource)
|
||||
<div class="flex gap-2">
|
||||
@if ($fileStorage->is_directory)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
|
||||
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
|
||||
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
|
||||
'The selected directory and all its contents will be permanently deleted from the container.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@else
|
||||
@if (!$fileStorage->is_binary)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||
]"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" :confirmWithPassword="false"
|
||||
step2ButtonText="Convert to directory" />
|
||||
@endif
|
||||
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
|
||||
server</x-forms.button>
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
|
||||
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
|
||||
confirmationText="{{ $fs_path }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
rows="20" id="fileStorage.content"
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||
@endcan
|
||||
@if (!$fileStorage->is_directory)
|
||||
@can('update', $resource)
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
|
||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||
</div>
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
rows="20" id="fileStorage.content"
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||
</div>
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
||||
@endcan
|
||||
@endif
|
||||
@else
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||
</div>
|
||||
{{-- Read-only view --}}
|
||||
@if (!$fileStorage->is_directory)
|
||||
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
|
||||
<div class="w-96">
|
||||
<x-forms.checkbox disabled label="Is this based on the Git repository?"
|
||||
id="fileStorage.is_based_on_git"></x-forms.checkbox>
|
||||
</div>
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
||||
@endif
|
||||
<x-forms.textarea
|
||||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
|
||||
@endcan
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
@if (
|
||||
$resource->getMorphClass() == 'App\Models\Application' ||
|
||||
$resource->getMorphClass() == 'App\Models\StandalonePostgresql' ||
|
||||
|
|
@ -9,55 +9,451 @@
|
|||
$resource->getMorphClass() == 'App\Models\StandaloneClickhouse' ||
|
||||
$resource->getMorphClass() == 'App\Models\StandaloneMongodb' ||
|
||||
$resource->getMorphClass() == 'App\Models\StandaloneMysql')
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Storages</h2>
|
||||
<x-helper
|
||||
helper="For Preview Deployments, storage has a <span class='text-helper'>-pr-#PRNumber</span> in their
|
||||
volume
|
||||
name, example: <span class='text-helper'>-pr-1</span>" />
|
||||
@if ($resource?->build_pack !== 'dockercompose')
|
||||
@can('update', $resource)
|
||||
<x-modal-input :closeOutside="false" buttonTitle="+ Add" title="New Persistent Storage" minWidth="64rem">
|
||||
<livewire:project.shared.storages.add :resource="$resource" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
@endif
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Storages</h2>
|
||||
<x-helper
|
||||
helper="For Preview Deployments, storage has a <span class='text-helper'>-pr-#PRNumber</span> in their
|
||||
volume
|
||||
name, example: <span class='text-helper'>-pr-1</span>" />
|
||||
@if ($resource?->build_pack !== 'dockercompose')
|
||||
@can('update', $resource)
|
||||
<div x-data="{
|
||||
dropdownOpen: false,
|
||||
volumeModalOpen: false,
|
||||
fileModalOpen: false,
|
||||
directoryModalOpen: false
|
||||
}"
|
||||
@close-storage-modal.window="
|
||||
if ($event.detail === 'volume') volumeModalOpen = false;
|
||||
if ($event.detail === 'file') fileModalOpen = false;
|
||||
if ($event.detail === 'directory') directoryModalOpen = false;
|
||||
">
|
||||
<div class="relative" @click.outside="dropdownOpen = false">
|
||||
<x-forms.button @click="dropdownOpen = !dropdownOpen">
|
||||
+ Add
|
||||
<svg class="w-4 h-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</x-forms.button>
|
||||
|
||||
<div x-show="dropdownOpen" @click.away="dropdownOpen=false"
|
||||
x-transition:enter="ease-out duration-200" x-transition:enter-start="-translate-y-2"
|
||||
x-transition:enter-end="translate-y-0" class="absolute top-0 z-50 mt-10 min-w-max"
|
||||
x-cloak>
|
||||
<div
|
||||
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
<a class="dropdown-item" @click="volumeModalOpen = true; dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Volume Mount
|
||||
</a>
|
||||
<a class="dropdown-item" @click="fileModalOpen = true; dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
File Mount
|
||||
</a>
|
||||
<a class="dropdown-item"
|
||||
@click="directoryModalOpen = true; dropdownOpen = false">
|
||||
<svg class="size-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Directory Mount
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Volume Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="volumeModalOpen" @keydown.window.escape="volumeModalOpen=false"
|
||||
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="volumeModalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="volumeModalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="volumeModalOpen" x-trap.inert.noscroll="volumeModalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
|
||||
<div class="flex items-center justify-between pb-3">
|
||||
<h3 class="text-2xl font-bold">Add Volume Mount</h3>
|
||||
<button @click="volumeModalOpen=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">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center justify-center w-auto"
|
||||
x-init="$watch('volumeModalOpen', value => {
|
||||
if (value) {
|
||||
$nextTick(() => {
|
||||
const input = $el.querySelector('input');
|
||||
input?.focus();
|
||||
})
|
||||
}
|
||||
})">
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||
wire:submit='submitPersistentVolume'>
|
||||
<div class="flex flex-col">
|
||||
<div>Docker Volumes mounted to the container.</div>
|
||||
</div>
|
||||
@if ($isSwarm)
|
||||
<div class="text-warning">Swarm Mode detected: You need to set a shared
|
||||
volume
|
||||
(EFS/NFS/etc) on all the worker nodes if you would like to use a
|
||||
persistent
|
||||
volumes.</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
|
||||
id="name" label="Name" required helper="Volume name." />
|
||||
@if ($isSwarm)
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/root" id="host_path" label="Source Path" required
|
||||
helper="Directory on the host system." />
|
||||
@else
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/root" id="host_path" label="Source Path"
|
||||
helper="Directory on the host system." />
|
||||
@endif
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/tmp/root" id="mount_path" label="Destination Path"
|
||||
required helper="Directory inside the container." />
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- File Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="fileModalOpen" @keydown.window.escape="fileModalOpen=false"
|
||||
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="fileModalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="fileModalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="fileModalOpen" x-trap.inert.noscroll="fileModalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
|
||||
<div class="flex items-center justify-between pb-3">
|
||||
<h3 class="text-2xl font-bold">Add File Mount</h3>
|
||||
<button @click="fileModalOpen=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">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center justify-center w-auto"
|
||||
x-init="$watch('fileModalOpen', value => {
|
||||
if (value) {
|
||||
$nextTick(() => {
|
||||
const input = $el.querySelector('input');
|
||||
input?.focus();
|
||||
})
|
||||
}
|
||||
})">
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||
wire:submit='submitFileStorage'>
|
||||
<div class="flex flex-col">
|
||||
<div>Actual file mounted from the host system to the container.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
|
||||
label="Destination Path" required
|
||||
helper="File location inside the container" />
|
||||
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
|
||||
id="file_storage_content"></x-forms.textarea>
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Directory Modal --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="directoryModalOpen" @keydown.window.escape="directoryModalOpen=false"
|
||||
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="directoryModalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="directoryModalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="directoryModalOpen" x-trap.inert.noscroll="directoryModalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
|
||||
<div class="flex items-center justify-between pb-3">
|
||||
<h3 class="text-2xl font-bold">Add Directory Mount</h3>
|
||||
<button @click="directoryModalOpen=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">
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex items-center justify-center w-auto"
|
||||
x-init="$watch('directoryModalOpen', value => {
|
||||
if (value) {
|
||||
$nextTick(() => {
|
||||
const input = $el.querySelector('input');
|
||||
input?.focus();
|
||||
})
|
||||
}
|
||||
})">
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm"
|
||||
wire:submit='submitFileStorageDirectory'>
|
||||
<div class="flex flex-col">
|
||||
<div>Directory mounted from the host system to the container.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
|
||||
id="file_storage_directory_source" label="Source Directory"
|
||||
required helper="Directory on the host system." />
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/etc/nginx" id="file_storage_directory_destination"
|
||||
label="Destination Directory" required
|
||||
helper="Directory inside the container." />
|
||||
<x-forms.button canGate="update" :canResource="$resource" type="submit">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
<div>Persistent storage to preserve data between deployments.</div>
|
||||
</div>
|
||||
<div class="pb-4">Persistent storage to preserve data between deployments.</div>
|
||||
@if ($resource?->build_pack === 'dockercompose')
|
||||
<span class="dark:text-warning text-coollabs">Please modify storage layout in your Docker Compose
|
||||
file or reload the compose file to reread the storage layout.</span>
|
||||
<div class="dark:text-warning text-coollabs">Please modify storage layout in your Docker Compose
|
||||
file or reload the compose file to reread the storage layout.</div>
|
||||
@else
|
||||
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||
<div class="pt-4">No storage found.</div>
|
||||
<div>No storage found.</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($resource->persistentStorages()->get()->count() > 0)
|
||||
<h3 class="pt-4">Volumes</h3>
|
||||
<livewire:project.shared.storages.all :resource="$resource" />
|
||||
@endif
|
||||
@if ($fileStorage->count() > 0)
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($fileStorage as $fs)
|
||||
<livewire:project.service.file-storage :fileStorage="$fs" wire:key="resource-{{ $fs->uuid }}" />
|
||||
@endforeach
|
||||
@php
|
||||
$hasVolumes = $this->volumeCount > 0;
|
||||
$hasFiles = $this->fileCount > 0;
|
||||
$hasDirectories = $this->directoryCount > 0;
|
||||
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
|
||||
@endphp
|
||||
|
||||
@if ($hasVolumes || $hasFiles || $hasDirectories)
|
||||
<div x-data="{
|
||||
activeTab: '{{ $defaultTab }}'
|
||||
}">
|
||||
{{-- Tabs Navigation --}}
|
||||
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
|
||||
<button @click="activeTab = 'volumes'"
|
||||
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasVolumes) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Volumes ({{ $this->volumeCount }})
|
||||
</button>
|
||||
<button @click="activeTab = 'files'"
|
||||
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasFiles) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Files ({{ $this->fileCount }})
|
||||
</button>
|
||||
<button @click="activeTab = 'directories'"
|
||||
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasDirectories) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Directories ({{ $this->directoryCount }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tab Content --}}
|
||||
<div class="pt-4">
|
||||
{{-- Volumes Tab --}}
|
||||
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
|
||||
@if ($hasVolumes)
|
||||
<livewire:project.shared.storages.all :resource="$resource" />
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No volumes configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Files Tab --}}
|
||||
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
|
||||
@if ($hasFiles)
|
||||
@foreach ($this->files as $fs)
|
||||
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||
wire:key="file-{{ $fs->id }}" />
|
||||
@endforeach
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No file mounts configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Directories Tab --}}
|
||||
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
|
||||
@if ($hasDirectories)
|
||||
@foreach ($this->directories as $fs)
|
||||
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||
wire:key="directory-{{ $fs->id }}" />
|
||||
@endforeach
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No directory mounts configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
@if ($resource->persistentStorages()->get()->count() > 0)
|
||||
<h3 class="pt-4">{{ Str::headline($resource->name) }} </h3>
|
||||
@endif
|
||||
@if ($resource->persistentStorages()->get()->count() > 0)
|
||||
<livewire:project.shared.storages.all :resource="$resource" />
|
||||
@endif
|
||||
@if ($fileStorage->count() > 0)
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
@foreach ($fileStorage->sort() as $fileStorage)
|
||||
<livewire:project.service.file-storage :fileStorage="$fileStorage"
|
||||
wire:key="resource-{{ $fileStorage->uuid }}" />
|
||||
@endforeach
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>{{ Str::headline($resource->name) }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
|
||||
<div>No storage found.</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$hasVolumes = $this->volumeCount > 0;
|
||||
$hasFiles = $this->fileCount > 0;
|
||||
$hasDirectories = $this->directoryCount > 0;
|
||||
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
|
||||
@endphp
|
||||
|
||||
@if ($hasVolumes || $hasFiles || $hasDirectories)
|
||||
<div x-data="{
|
||||
activeTab: '{{ $defaultTab }}'
|
||||
}">
|
||||
{{-- Tabs Navigation --}}
|
||||
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
|
||||
<button @click="activeTab = 'volumes'"
|
||||
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasVolumes) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Volumes ({{ $this->volumeCount }})
|
||||
</button>
|
||||
<button @click="activeTab = 'files'"
|
||||
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasFiles) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Files ({{ $this->fileCount }})
|
||||
</button>
|
||||
<button @click="activeTab = 'directories'"
|
||||
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
|
||||
'border-b-2 border-transparent'"
|
||||
@if (!$hasDirectories) disabled @endif
|
||||
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
|
||||
Directories ({{ $this->directoryCount }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Tab Content --}}
|
||||
<div class="pt-4">
|
||||
{{-- Volumes Tab --}}
|
||||
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
|
||||
@if ($hasVolumes)
|
||||
<livewire:project.shared.storages.all :resource="$resource" />
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No volumes configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Files Tab --}}
|
||||
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
|
||||
@if ($hasFiles)
|
||||
@foreach ($this->files as $fs)
|
||||
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||
wire:key="file-{{ $fs->id }}" />
|
||||
@endforeach
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No file mounts configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Directories Tab --}}
|
||||
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
|
||||
@if ($hasDirectories)
|
||||
@foreach ($this->directories as $fs)
|
||||
<livewire:project.service.file-storage :fileStorage="$fs"
|
||||
wire:key="directory-{{ $fs->id }}" />
|
||||
@endforeach
|
||||
@else
|
||||
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
|
||||
No directory mounts configured.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div class="flex flex-col gap-2">
|
||||
<h3>Primary Server</h3>
|
||||
<div
|
||||
class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-black">
|
||||
class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-coolgray-300">
|
||||
@if (str($resource->realStatus())->startsWith('running'))
|
||||
<div title="{{ $resource->realStatus() }}" class="absolute bg-success -top-1 -left-1 badge ">
|
||||
</div>
|
||||
|
|
@ -36,7 +36,7 @@ class="relative flex flex-col bg-white border cursor-default dark:text-white box
|
|||
@foreach ($resource->additional_networks as $destination)
|
||||
<div class="flex flex-col gap-2" wire:key="destination-{{ $destination->id }}">
|
||||
<div
|
||||
class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-black">
|
||||
class="relative flex flex-col bg-white border cursor-default dark:text-white box-without-bg dark:bg-coolgray-100 dark:border-coolgray-300">
|
||||
@if (str(data_get($destination, 'pivot.status'))->startsWith('running'))
|
||||
<div title="{{ data_get($destination, 'pivot.status') }}"
|
||||
class="absolute bg-success -top-1 -left-1 badge "></div>
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
<div class="flex flex-col w-full gap-2 max-h-[80vh] overflow-y-auto scrollbar">
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm " wire:submit='submitPersistentVolume'>
|
||||
<div class="flex flex-col">
|
||||
<h3>Volume Mount</h3>
|
||||
<div>Docker Volumes mounted to the container.</div>
|
||||
</div>
|
||||
@if ($isSwarm)
|
||||
<h5>Swarm Mode detected: You need to set a shared volume (EFS/NFS/etc) on all the worker nodes if you
|
||||
would
|
||||
like to use a persistent volumes.</h5>
|
||||
@endif
|
||||
<div class="flex flex-col gap-2 px-2">
|
||||
<x-forms.input placeholder="pv-name" id="name" label="Name" required helper="Volume name." />
|
||||
@if ($isSwarm)
|
||||
<x-forms.input placeholder="/root" id="host_path" label="Source Path" required
|
||||
helper="Directory on the host system." />
|
||||
@else
|
||||
<x-forms.input placeholder="/root" id="host_path" label="Source Path"
|
||||
helper="Directory on the host system." />
|
||||
@endif
|
||||
<x-forms.input placeholder="/tmp/root" id="mount_path" label="Destination Path" required
|
||||
helper="Directory inside the container." />
|
||||
<x-forms.button type="submit" @click="modalOpen=false">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm py-4" wire:submit='submitFileStorage'>
|
||||
<div class="flex flex-col">
|
||||
<h3>File Mount</h3>
|
||||
<div>Actual file mounted from the host system to the container.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-2">
|
||||
<x-forms.input placeholder="/etc/nginx/nginx.conf" id="file_storage_path" label="Destination Path" required
|
||||
helper="File location inside the container" />
|
||||
<x-forms.textarea label="Content" id="file_storage_content"></x-forms.textarea>
|
||||
<x-forms.button type="submit" @click="modalOpen=false">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorageDirectory'>
|
||||
<div class="flex flex-col">
|
||||
<h3>Directory Mount</h3>
|
||||
<div>Directory mounted from the host system to the container.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-2">
|
||||
<x-forms.input placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
|
||||
id="file_storage_directory_source" label="Source Directory" required
|
||||
helper="Directory on the host system." />
|
||||
<x-forms.input placeholder="/etc/nginx" id="file_storage_directory_destination"
|
||||
label="Destination Directory" required helper="Directory inside the container." />
|
||||
<x-forms.button type="submit" @click="modalOpen=false">
|
||||
Add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
<div>
|
||||
<form wire:submit='submit' class="flex flex-col gap-2 xl:items-end xl:flex-row">
|
||||
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
This volume is mounted as read-only and cannot be modified from the UI.
|
||||
</div>
|
||||
@if ($isFirst)
|
||||
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
|
||||
@if (
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<div class="text-xs truncate subtitle lg:text-sm">{{ $project->name }}.</div>
|
||||
<div class="grid gap-2 lg:grid-cols-2">
|
||||
@forelse ($project->environments->sortBy('created_at') as $environment)
|
||||
<div class="gap-2 border border-transparent box group">
|
||||
<div class="gap-2 box group">
|
||||
<div class="flex flex-1 mx-6">
|
||||
<a class="flex flex-col justify-center flex-1"
|
||||
href="{{ route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class="absolute -top-1 -right-1 bg-error text-white text-xs rounded-full w-4.5 h
|
|||
x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2" class="absolute right-0 top-full mt-1 z-50 w-48" x-cloak>
|
||||
<div
|
||||
class="p-1 bg-white border rounded-sm shadow-lg dark:bg-coolgray-200 dark:border-black border-neutral-300">
|
||||
class="p-1 bg-white border rounded-sm shadow-lg dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- What's New Section -->
|
||||
@if ($unreadCount > 0)
|
||||
|
|
|
|||
88
templates/compose/elasticsearch-with-kibana.yaml
Normal file
88
templates/compose/elasticsearch-with-kibana.yaml
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# documentation: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker
|
||||
# slogan: Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack
|
||||
# tags: elastic,kibana,elasticsearch,search,visualization,logging,monitoring,observability,analytics,stack,devops
|
||||
# logo: svgs/elasticsearch.svg
|
||||
# port: 5601
|
||||
|
||||
services:
|
||||
elasticsearch:
|
||||
image: 'elastic/elasticsearch:9.1.2'
|
||||
container_name: elasticsearch
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}
|
||||
- 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
|
||||
- discovery.type=single-node
|
||||
- bootstrap.memory_lock=true
|
||||
- xpack.security.enabled=true
|
||||
- xpack.security.http.ssl.enabled=false
|
||||
- xpack.security.transport.ssl.enabled=false
|
||||
volumes:
|
||||
- '/etc/localtime:/etc/localtime:ro'
|
||||
- 'elasticsearch-data:/usr/share/elasticsearch/data'
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'curl --user elastic:${SERVICE_PASSWORD_ELASTICSEARCH} --silent --fail http://localhost:9200/_cluster/health || exit 1'
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 24
|
||||
|
||||
kibana:
|
||||
image: 'kibana:9.1.2'
|
||||
container_name: kibana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SERVICE_URL_KIBANA_5601
|
||||
- 'SERVER_NAME=${SERVICE_URL_KIBANA}'
|
||||
- 'SERVER_PUBLICBASEURL=${SERVICE_URL_KIBANA}'
|
||||
- 'ELASTICSEARCH_HOSTS=http://elasticsearch:9200'
|
||||
- 'ELASTICSEARCH_USERNAME=kibana_system'
|
||||
- 'ELASTICSEARCH_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
|
||||
- 'XPACK_SECURITY_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKSECURITY}'
|
||||
- 'XPACK_REPORTING_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKREPORTING}'
|
||||
- 'XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKENCRYPTEDSAVEDOBJECTS}'
|
||||
- 'TELEMETRY_OPTIN=${TELEMETRY_OPTIN:-false}'
|
||||
volumes:
|
||||
- '/etc/localtime:/etc/localtime:ro'
|
||||
- 'kibana-data:/usr/share/kibana/data'
|
||||
depends_on:
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "curl -s http://localhost:5601/api/status | grep -q '\"level\":\"available\"' || exit 1"
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 120
|
||||
|
||||
setup:
|
||||
image: 'elastic/elasticsearch:9.1.2'
|
||||
container_name: kibana-setup
|
||||
depends_on:
|
||||
elasticsearch:
|
||||
condition: service_healthy
|
||||
exclude_from_hc: true
|
||||
environment:
|
||||
- 'ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}'
|
||||
- 'KIBANA_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
|
||||
entrypoint:
|
||||
- sh
|
||||
- '-c'
|
||||
- |
|
||||
echo "Setting up Kibana user password..."
|
||||
|
||||
until curl -s -u "elastic:${ELASTIC_PASSWORD}" http://elasticsearch:9200/_cluster/health | grep -q '"status":"green\|yellow"'; do
|
||||
echo "Waiting for Elasticsearch..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Setting password for kibana_system user..."
|
||||
curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://elasticsearch:9200/_security/user/kibana_system/_password \
|
||||
-d "{\"password\":\"${KIBANA_PASSWORD}\"}" || exit 1
|
||||
|
||||
echo "Kibana setup completed successfully"
|
||||
restart: 'no'
|
||||
113
templates/compose/ente-photos-with-s3.yaml
Normal file
113
templates/compose/ente-photos-with-s3.yaml
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# documentation: https://help.ente.io/self-hosting/installation/compose
|
||||
# slogan: Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.
|
||||
# category: media
|
||||
# tags: photos,gallery,backup,encryption,privacy,self-hosted,google-photos,alternative
|
||||
# logo: svgs/ente-photos.svg
|
||||
# port: 8080
|
||||
|
||||
services:
|
||||
museum:
|
||||
image: ghcr.io/ente-io/server:latest
|
||||
environment:
|
||||
- SERVICE_URL_MUSEUM_8080
|
||||
- ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}
|
||||
|
||||
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
|
||||
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
|
||||
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
|
||||
|
||||
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
|
||||
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
|
||||
- ENTE_DB_NAME=${ENTE_DB_NAME:-ente_db}
|
||||
- ENTE_DB_USER=${SERVICE_USER_POSTGRES:-pguser}
|
||||
- ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
|
||||
- ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}
|
||||
- ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}
|
||||
|
||||
- ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}
|
||||
|
||||
- ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}
|
||||
- ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}
|
||||
|
||||
# S3/MinIO configuration
|
||||
- S3_ARE_LOCAL_BUCKETS=true
|
||||
- S3_USE_PATH_STYLE_URLS=true
|
||||
- S3_B2_EU_CEN_KEY=${SERVICE_USER_MINIO}
|
||||
- S3_B2_EU_CEN_SECRET=${SERVICE_PASSWORD_MINIO}
|
||||
- S3_B2_EU_CEN_ENDPOINT=${SERVICE_URL_MINIO_3200}
|
||||
- S3_B2_EU_CEN_REGION=eu-central-2
|
||||
- S3_B2_EU_CEN_BUCKET=b2-eu-cen
|
||||
volumes:
|
||||
- museum-data:/data
|
||||
- museum-config:/config
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
image: ghcr.io/ente-io/web
|
||||
environment:
|
||||
- SERVICE_URL_WEB_3000
|
||||
- ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}
|
||||
- ENTE_ALBUMS_ORIGIN=${SERVICE_URL_WEB_3002}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-ente_db}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
minio:
|
||||
image: quay.io/minio/minio:latest
|
||||
environment:
|
||||
- SERVICE_URL_MINIO_9000
|
||||
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
|
||||
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
|
||||
command: server /data --address ":9000" --console-address ":9001"
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
exclude_from_hc: true
|
||||
restart: no
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
|
||||
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set minio http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
|
||||
mc mb minio/b2-eu-cen --ignore-existing;
|
||||
mc mb minio/wasabi-eu-central-2-v3 --ignore-existing;
|
||||
mc mb minio/scw-eu-fr-v3 --ignore-existing;
|
||||
echo 'MinIO buckets created successfully';
|
||||
"
|
||||
80
templates/compose/ente-photos.yaml
Normal file
80
templates/compose/ente-photos.yaml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# documentation: https://help.ente.io/self-hosting/installation/compose
|
||||
# slogan: Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.
|
||||
# category: media
|
||||
# tags: photos,gallery,backup,encryption,privacy,self-hosted,google-photos,alternative
|
||||
# logo: svgs/ente-photos.svg
|
||||
# port: 8080
|
||||
|
||||
services:
|
||||
museum:
|
||||
image: ghcr.io/ente-io/server:latest
|
||||
environment:
|
||||
- SERVICE_URL_MUSEUM_8080
|
||||
|
||||
- ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}
|
||||
|
||||
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
|
||||
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
|
||||
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
|
||||
|
||||
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
|
||||
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
|
||||
- ENTE_DB_NAME=${ENTE_DB_NAME:-ente_db}
|
||||
- ENTE_DB_USER=${SERVICE_USER_POSTGRES:-pguser}
|
||||
- ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
|
||||
- ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}
|
||||
- ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}
|
||||
|
||||
- ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}
|
||||
|
||||
- ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}
|
||||
- ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}
|
||||
|
||||
- ENTE_S3_B2_EU_CEN_ARE_LOCAL_BUCKETS=${PRIMARY_STORAGE_ARE_LOCAL_BUCKETS:-false}
|
||||
- ENTE_S3_B2_EU_CEN_USE_PATH_STYLE_URLS=${PRIMARY_STORAGE_USE_PATH_STYLE_URLS:-true}
|
||||
- ENTE_S3_B2_EU_CEN_KEY=${S3_STORAGE_KEY:?}
|
||||
- ENTE_S3_B2_EU_CEN_SECRET=${S3_STORAGE_SECRET:?}
|
||||
- ENTE_S3_B2_EU_CEN_ENDPOINT=${S3_STORAGE_ENDPOINT:?}
|
||||
- ENTE_S3_B2_EU_CEN_REGION=${S3_STORAGE_REGION:-us-east-1}
|
||||
- ENTE_S3_B2_EU_CEN_BUCKET=${S3_STORAGE_BUCKET:?}
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- museum-data:/data
|
||||
- museum-config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
web:
|
||||
image: ghcr.io/ente-io/web
|
||||
environment:
|
||||
- SERVICE_URL_WEB_3000
|
||||
- ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}
|
||||
- ENTE_ALBUMS_ORIGIN=${SERVICE_URL_WEB_3002}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRES:-pguser}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
|
||||
- POSTGRES_DB=${SERVICE_DB_NAME:-ente_db}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
|
|
@ -899,6 +899,28 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "80"
|
||||
},
|
||||
"elasticsearch-with-kibana": {
|
||||
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
|
||||
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
|
||||
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0tJQkFOQV81NjAxCiAgICAgIC0gJ1NFUlZFUl9OQU1FPSR7U0VSVklDRV9VUkxfS0lCQU5BfScKICAgICAgLSAnU0VSVkVSX1BVQkxJQ0JBU0VVUkw9JHtTRVJWSUNFX1VSTF9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
|
||||
"tags": [
|
||||
"elastic",
|
||||
"kibana",
|
||||
"elasticsearch",
|
||||
"search",
|
||||
"visualization",
|
||||
"logging",
|
||||
"monitoring",
|
||||
"observability",
|
||||
"analytics",
|
||||
"stack",
|
||||
"devops"
|
||||
],
|
||||
"category": null,
|
||||
"logo": "svgs/elasticsearch.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "5601"
|
||||
},
|
||||
"elasticsearch": {
|
||||
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
|
||||
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
|
||||
|
|
@ -948,6 +970,44 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "6555"
|
||||
},
|
||||
"ente-photos-with-s3": {
|
||||
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
|
||||
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
|
||||
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSBTM19BUkVfTE9DQUxfQlVDS0VUUz10cnVlCiAgICAgIC0gUzNfVVNFX1BBVEhfU1RZTEVfVVJMUz10cnVlCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1NFUlZJQ0VfVVJMX01JTklPXzMyMDB9JwogICAgICAtIFMzX0IyX0VVX0NFTl9SRUdJT049ZXUtY2VudHJhbC0yCiAgICAgIC0gUzNfQjJfRVVfQ0VOX0JVQ0tFVD1iMi1ldS1jZW4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIG1pbmlvOgogICAgaW1hZ2U6ICdxdWF5LmlvL21pbmlvL21pbmlvOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
|
||||
"tags": [
|
||||
"photos",
|
||||
"gallery",
|
||||
"backup",
|
||||
"encryption",
|
||||
"privacy",
|
||||
"self-hosted",
|
||||
"google-photos",
|
||||
"alternative"
|
||||
],
|
||||
"category": "media",
|
||||
"logo": "svgs/ente-photos.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
},
|
||||
"ente-photos": {
|
||||
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
|
||||
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
|
||||
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
|
||||
"tags": [
|
||||
"photos",
|
||||
"gallery",
|
||||
"backup",
|
||||
"encryption",
|
||||
"privacy",
|
||||
"self-hosted",
|
||||
"google-photos",
|
||||
"alternative"
|
||||
],
|
||||
"category": "media",
|
||||
"logo": "svgs/ente-photos.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
},
|
||||
"evolution-api": {
|
||||
"documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io",
|
||||
"slogan": "Evolution API Installation with Postgres and Redis",
|
||||
|
|
|
|||
|
|
@ -899,6 +899,28 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "80"
|
||||
},
|
||||
"elasticsearch-with-kibana": {
|
||||
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
|
||||
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
|
||||
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9LSUJBTkFfNTYwMQogICAgICAtICdTRVJWRVJfTkFNRT0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdTRVJWRVJfUFVCTElDQkFTRVVSTD0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
|
||||
"tags": [
|
||||
"elastic",
|
||||
"kibana",
|
||||
"elasticsearch",
|
||||
"search",
|
||||
"visualization",
|
||||
"logging",
|
||||
"monitoring",
|
||||
"observability",
|
||||
"analytics",
|
||||
"stack",
|
||||
"devops"
|
||||
],
|
||||
"category": null,
|
||||
"logo": "svgs/elasticsearch.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "5601"
|
||||
},
|
||||
"elasticsearch": {
|
||||
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
|
||||
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
|
||||
|
|
@ -948,6 +970,44 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "6555"
|
||||
},
|
||||
"ente-photos-with-s3": {
|
||||
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
|
||||
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
|
||||
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gUzNfQVJFX0xPQ0FMX0JVQ0tFVFM9dHJ1ZQogICAgICAtIFMzX1VTRV9QQVRIX1NUWUxFX1VSTFM9dHJ1ZQogICAgICAtICdTM19CMl9FVV9DRU5fS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTRVJWSUNFX0ZRRE5fTUlOSU9fMzIwMH0nCiAgICAgIC0gUzNfQjJfRVVfQ0VOX1JFR0lPTj1ldS1jZW50cmFsLTIKICAgICAgLSBTM19CMl9FVV9DRU5fQlVDS0VUPWIyLWV1LWNlbgogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNS1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgbWluaW86CiAgICBpbWFnZTogJ3F1YXkuaW8vbWluaW8vbWluaW86bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
|
||||
"tags": [
|
||||
"photos",
|
||||
"gallery",
|
||||
"backup",
|
||||
"encryption",
|
||||
"privacy",
|
||||
"self-hosted",
|
||||
"google-photos",
|
||||
"alternative"
|
||||
],
|
||||
"category": "media",
|
||||
"logo": "svgs/ente-photos.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
},
|
||||
"ente-photos": {
|
||||
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
|
||||
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
|
||||
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
|
||||
"tags": [
|
||||
"photos",
|
||||
"gallery",
|
||||
"backup",
|
||||
"encryption",
|
||||
"privacy",
|
||||
"self-hosted",
|
||||
"google-photos",
|
||||
"alternative"
|
||||
],
|
||||
"category": "media",
|
||||
"logo": "svgs/ente-photos.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
},
|
||||
"evolution-api": {
|
||||
"documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io",
|
||||
"slogan": "Evolution API Installation with Postgres and Redis",
|
||||
|
|
|
|||
208
tests/Feature/MultilineEnvironmentVariableTest.php
Normal file
208
tests/Feature/MultilineEnvironmentVariableTest.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
test('multiline environment variables are properly escaped for docker build args', function () {
|
||||
$sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
|
||||
$variables = [
|
||||
['key' => 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true],
|
||||
['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
|
||||
// SSH key should use double quotes and have proper escaping
|
||||
$sshArg = $buildArgs->first();
|
||||
expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="');
|
||||
expect($sshArg)->toEndWith('"');
|
||||
expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes
|
||||
|
||||
// Regular var should use escapeshellarg (single quotes)
|
||||
$regularArg = $buildArgs->last();
|
||||
expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'");
|
||||
});
|
||||
|
||||
test('multiline variables with special bash characters are escaped correctly', function () {
|
||||
$valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`";
|
||||
|
||||
$variables = [
|
||||
['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Verify double quotes are escaped
|
||||
expect($arg)->toContain('\\"quotes\\"');
|
||||
// Verify dollar signs are escaped
|
||||
expect($arg)->toContain('\\$variables');
|
||||
// Verify backticks are escaped
|
||||
expect($arg)->toContain('\\`backticks\\`');
|
||||
});
|
||||
|
||||
test('single-line environment variables use escapeshellarg', function () {
|
||||
$variables = [
|
||||
['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should use single quotes from escapeshellarg
|
||||
expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'");
|
||||
});
|
||||
|
||||
test('multiline certificate with newlines is preserved', function () {
|
||||
$certificate = '-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF
|
||||
-----END CERTIFICATE-----';
|
||||
|
||||
$variables = [
|
||||
['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Newlines should be preserved in the output
|
||||
expect($arg)->toContain("\n");
|
||||
expect($arg)->toContain('BEGIN CERTIFICATE');
|
||||
expect($arg)->toContain('END CERTIFICATE');
|
||||
expect(substr_count($arg, "\n"))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('multiline JSON configuration is properly escaped', function () {
|
||||
$jsonConfig = '{
|
||||
"key": "value",
|
||||
"nested": {
|
||||
"array": [1, 2, 3]
|
||||
}
|
||||
}';
|
||||
|
||||
$variables = [
|
||||
['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// All double quotes in JSON should be escaped
|
||||
expect($arg)->toContain('\\"key\\"');
|
||||
expect($arg)->toContain('\\"value\\"');
|
||||
expect($arg)->toContain('\\"nested\\"');
|
||||
});
|
||||
|
||||
test('empty multiline variable is handled correctly', function () {
|
||||
$variables = [
|
||||
['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
expect($arg)->toBe('--build-arg EMPTY_VAR=""');
|
||||
});
|
||||
|
||||
test('multiline variable with only newlines', function () {
|
||||
$onlyNewlines = "\n\n\n";
|
||||
|
||||
$variables = [
|
||||
['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
expect($arg)->toContain("\n");
|
||||
// Should have 3 newlines preserved
|
||||
expect(substr_count($arg, "\n"))->toBe(3);
|
||||
});
|
||||
|
||||
test('multiline variable with backslashes is escaped correctly', function () {
|
||||
$valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32";
|
||||
|
||||
$variables = [
|
||||
['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Backslashes should be doubled
|
||||
expect($arg)->toContain('path\\\\to\\\\file');
|
||||
expect($arg)->toContain('C:\\\\Windows\\\\System32');
|
||||
});
|
||||
|
||||
test('generateDockerEnvFlags produces correct format', function () {
|
||||
$variables = [
|
||||
['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false],
|
||||
['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$envFlags = generateDockerEnvFlags($variables);
|
||||
|
||||
expect($envFlags)->toContain('-e NORMAL_VAR=');
|
||||
expect($envFlags)->toContain('-e MULTILINE_VAR="');
|
||||
expect($envFlags)->toContain('line1');
|
||||
expect($envFlags)->toContain('line2');
|
||||
});
|
||||
|
||||
test('helper functions work with collection input', function () {
|
||||
$variables = collect([
|
||||
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
|
||||
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
|
||||
]);
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
expect($buildArgs)->toHaveCount(2);
|
||||
|
||||
$envFlags = generateDockerEnvFlags($variables);
|
||||
expect($envFlags)->toBeString();
|
||||
expect($envFlags)->toContain('-e VAR1=');
|
||||
expect($envFlags)->toContain('-e VAR2="');
|
||||
});
|
||||
|
||||
test('variables without is_multiline default to false', function () {
|
||||
$variables = [
|
||||
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should use escapeshellarg (single quotes) since is_multiline defaults to false
|
||||
expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'");
|
||||
});
|
||||
|
||||
test('real world SSH key example', function () {
|
||||
// Simulate what real_value returns (wrapped in single quotes)
|
||||
$sshKey = "'-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----'";
|
||||
|
||||
$variables = [
|
||||
['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should produce clean output without wrapper quotes
|
||||
expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----');
|
||||
expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"');
|
||||
// Should NOT have the escaped quote sequence that was in the bug
|
||||
expect($arg)->not->toContain("''");
|
||||
expect($arg)->not->toContain("'\\''");
|
||||
});
|
||||
|
|
@ -1,94 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\DockerImageParser;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DockerImageParserTest extends TestCase
|
||||
{
|
||||
private DockerImageParser $parser;
|
||||
it('parses regular image with tag', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse('nginx:latest');
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->parser = new DockerImageParser;
|
||||
expect($parser->getImageName())->toBe('nginx')
|
||||
->and($parser->getTag())->toBe('latest')
|
||||
->and($parser->isImageHash())->toBeFalse()
|
||||
->and($parser->toString())->toBe('nginx:latest');
|
||||
});
|
||||
|
||||
it('parses image with sha256 hash using colon format', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
|
||||
$parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}");
|
||||
|
||||
expect($parser->getFullImageNameWithoutTag())->toBe('ghcr.io/benjaminehowe/rail-disruptions')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue()
|
||||
->and($parser->toString())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}")
|
||||
->and($parser->getFullImageNameWithHash())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}");
|
||||
});
|
||||
|
||||
it('parses image with sha256 hash using at sign format', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
|
||||
$parser->parse("nginx@sha256:{$hash}");
|
||||
|
||||
expect($parser->getImageName())->toBe('nginx')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue()
|
||||
->and($parser->toString())->toBe("nginx@sha256:{$hash}")
|
||||
->and($parser->getFullImageNameWithHash())->toBe("nginx@sha256:{$hash}");
|
||||
});
|
||||
|
||||
it('parses registry image with hash', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
|
||||
$parser->parse("docker.io/library/nginx:{$hash}");
|
||||
|
||||
expect($parser->getFullImageNameWithoutTag())->toBe('docker.io/library/nginx')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue()
|
||||
->and($parser->toString())->toBe("docker.io/library/nginx@sha256:{$hash}");
|
||||
});
|
||||
|
||||
it('parses image without tag defaults to latest', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse('nginx');
|
||||
|
||||
expect($parser->getImageName())->toBe('nginx')
|
||||
->and($parser->getTag())->toBe('latest')
|
||||
->and($parser->isImageHash())->toBeFalse()
|
||||
->and($parser->toString())->toBe('nginx:latest');
|
||||
});
|
||||
|
||||
it('parses registry with port', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse('registry.example.com:5000/myapp:latest');
|
||||
|
||||
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
|
||||
->and($parser->getTag())->toBe('latest')
|
||||
->and($parser->isImageHash())->toBeFalse();
|
||||
});
|
||||
|
||||
it('parses registry with port and hash', function () {
|
||||
$parser = new DockerImageParser;
|
||||
$hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
|
||||
$parser->parse("registry.example.com:5000/myapp:{$hash}");
|
||||
|
||||
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
|
||||
->and($parser->getTag())->toBe($hash)
|
||||
->and($parser->isImageHash())->toBeTrue()
|
||||
->and($parser->toString())->toBe("registry.example.com:5000/myapp@sha256:{$hash}");
|
||||
});
|
||||
|
||||
it('identifies valid sha256 hashes', function () {
|
||||
$validHashes = [
|
||||
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0',
|
||||
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
|
||||
];
|
||||
|
||||
foreach ($validHashes as $hash) {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse("image:{$hash}");
|
||||
expect($parser->isImageHash())->toBeTrue("Hash {$hash} should be recognized as valid SHA256");
|
||||
}
|
||||
});
|
||||
|
||||
#[Test]
|
||||
public function it_parses_simple_image_name()
|
||||
{
|
||||
$this->parser->parse('nginx');
|
||||
it('identifies invalid sha256 hashes', function () {
|
||||
$invalidHashes = [
|
||||
'latest',
|
||||
'v1.2.3',
|
||||
'abc123', // too short
|
||||
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short
|
||||
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long
|
||||
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char
|
||||
];
|
||||
|
||||
$this->assertEquals('', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('nginx', $this->parser->getImageName());
|
||||
$this->assertEquals('latest', $this->parser->getTag());
|
||||
foreach ($invalidHashes as $hash) {
|
||||
$parser = new DockerImageParser;
|
||||
$parser->parse("image:{$hash}");
|
||||
expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256");
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_parses_image_with_tag()
|
||||
{
|
||||
$this->parser->parse('nginx:1.19');
|
||||
|
||||
$this->assertEquals('', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('nginx', $this->parser->getImageName());
|
||||
$this->assertEquals('1.19', $this->parser->getTag());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_parses_image_with_organization()
|
||||
{
|
||||
$this->parser->parse('coollabs/coolify:latest');
|
||||
|
||||
$this->assertEquals('', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
|
||||
$this->assertEquals('latest', $this->parser->getTag());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_parses_image_with_registry_url()
|
||||
{
|
||||
$this->parser->parse('ghcr.io/coollabs/coolify:v4');
|
||||
|
||||
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
|
||||
$this->assertEquals('v4', $this->parser->getTag());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_parses_image_with_port_in_registry()
|
||||
{
|
||||
$this->parser->parse('localhost:5000/my-app:dev');
|
||||
|
||||
$this->assertEquals('localhost:5000', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('my-app', $this->parser->getImageName());
|
||||
$this->assertEquals('dev', $this->parser->getTag());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_parses_image_without_tag()
|
||||
{
|
||||
$this->parser->parse('ghcr.io/coollabs/coolify');
|
||||
|
||||
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
|
||||
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
|
||||
$this->assertEquals('latest', $this->parser->getTag());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_converts_back_to_string()
|
||||
{
|
||||
$originalString = 'ghcr.io/coollabs/coolify:v4';
|
||||
$this->parser->parse($originalString);
|
||||
|
||||
$this->assertEquals($originalString, $this->parser->toString());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function it_converts_to_string_with_default_tag()
|
||||
{
|
||||
$this->parser->parse('nginx');
|
||||
$this->assertEquals('nginx:latest', $this->parser->toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.433"
|
||||
"version": "4.0.0-beta.435"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.434"
|
||||
"version": "4.0.0-beta.436"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue