commit
f22afa57b7
50 changed files with 2818 additions and 921 deletions
|
|
@ -8,6 +8,7 @@
|
|||
use App\Actions\User\DeleteUserTeams;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
|
|
@ -54,124 +55,141 @@ public function handle()
|
|||
return 1;
|
||||
}
|
||||
|
||||
$this->logAction("Starting user deletion process for: {$email}");
|
||||
// Implement file lock to prevent concurrent deletions of the same user
|
||||
$lockKey = "user_deletion_{$this->user->id}";
|
||||
$lock = Cache::lock($lockKey, 600); // 10 minute lock
|
||||
|
||||
// Phase 1: Show User Overview (outside transaction)
|
||||
if (! $this->showUserOverview()) {
|
||||
$this->info('User deletion cancelled.');
|
||||
if (! $lock->get()) {
|
||||
$this->error('Another deletion process is already running for this user. Please try again later.');
|
||||
$this->logAction("Deletion blocked for user {$email}: Another process is already running");
|
||||
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If not dry run, wrap everything in a transaction
|
||||
if (! $this->isDryRun) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$this->logAction("Starting user deletion process for: {$email}");
|
||||
|
||||
// Phase 1: Show User Overview (outside transaction)
|
||||
if (! $this->showUserOverview()) {
|
||||
$this->info('User deletion cancelled.');
|
||||
$lock->release();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If not dry run, wrap everything in a transaction
|
||||
if (! $this->isDryRun) {
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at team handling phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at final phase. All changes rolled back.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ User deletion completed successfully!');
|
||||
$this->logAction("User deletion completed for: {$email}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('An error occurred during user deletion: '.$e->getMessage());
|
||||
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Dry run mode - just run through the phases without transaction
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at resource deletion phase. All changes rolled back.');
|
||||
$this->info('User deletion would be cancelled at resource deletion phase.');
|
||||
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at server deletion phase. All changes rolled back.');
|
||||
$this->info('User deletion would be cancelled at server deletion phase.');
|
||||
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at team handling phase. All changes rolled back.');
|
||||
$this->info('User deletion would be cancelled at team handling phase.');
|
||||
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.');
|
||||
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
|
||||
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
DB::rollBack();
|
||||
$this->error('User deletion failed at final phase. All changes rolled back.');
|
||||
$this->info('User deletion would be cancelled at final phase.');
|
||||
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
DB::commit();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ User deletion completed successfully!');
|
||||
$this->logAction("User deletion completed for: {$email}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error('An error occurred during user deletion: '.$e->getMessage());
|
||||
$this->logAction("User deletion failed for {$email}: ".$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
// Dry run mode - just run through the phases without transaction
|
||||
// Phase 2: Delete Resources
|
||||
if (! $this->skipResources) {
|
||||
if (! $this->deleteResources()) {
|
||||
$this->info('User deletion would be cancelled at resource deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
|
||||
}
|
||||
|
||||
// Phase 3: Delete Servers
|
||||
if (! $this->deleteServers()) {
|
||||
$this->info('User deletion would be cancelled at server deletion phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 4: Handle Teams
|
||||
if (! $this->handleTeams()) {
|
||||
$this->info('User deletion would be cancelled at team handling phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Phase 5: Cancel Stripe Subscriptions
|
||||
if (! $this->skipStripe && isCloud()) {
|
||||
if (! $this->cancelStripeSubscriptions()) {
|
||||
$this->info('User deletion would be cancelled at Stripe cancellation phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Delete User Profile
|
||||
if (! $this->deleteUserProfile()) {
|
||||
$this->info('User deletion would be cancelled at final phase.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✅ DRY RUN completed successfully! No data was deleted.');
|
||||
return 0;
|
||||
} finally {
|
||||
// Ensure lock is always released
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function showUserOverview(): bool
|
||||
|
|
@ -683,24 +701,21 @@ private function deleteUserProfile(): bool
|
|||
|
||||
private function getSubscriptionMonthlyValue(string $planId): int
|
||||
{
|
||||
// Map plan IDs to monthly values based on config
|
||||
$subscriptionConfigs = config('subscription');
|
||||
// Try to get pricing from subscription metadata or config
|
||||
// Since we're using dynamic pricing, return 0 for now
|
||||
// This could be enhanced by fetching the actual price from Stripe API
|
||||
|
||||
foreach ($subscriptionConfigs as $key => $value) {
|
||||
if ($value === $planId && str_contains($key, 'stripe_price_id_')) {
|
||||
// Extract price from key pattern: stripe_price_id_basic_monthly -> basic
|
||||
$planType = str($key)->after('stripe_price_id_')->before('_')->toString();
|
||||
// Check if this is a dynamic pricing plan
|
||||
$dynamicMonthlyPlanId = config('subscription.stripe_price_id_dynamic_monthly');
|
||||
$dynamicYearlyPlanId = config('subscription.stripe_price_id_dynamic_yearly');
|
||||
|
||||
// Map to known prices (you may need to adjust these based on your actual pricing)
|
||||
return match ($planType) {
|
||||
'basic' => 29,
|
||||
'pro' => 49,
|
||||
'ultimate' => 99,
|
||||
default => 0
|
||||
};
|
||||
}
|
||||
if ($planId === $dynamicMonthlyPlanId || $planId === $dynamicYearlyPlanId) {
|
||||
// For dynamic pricing, we can't determine the exact amount without calling Stripe API
|
||||
// Return 0 to indicate dynamic/usage-based pricing
|
||||
return 0;
|
||||
}
|
||||
|
||||
// For any other plans, return 0 as we don't have hardcoded prices
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -716,6 +731,13 @@ private function logAction(string $message): void
|
|||
|
||||
// Also log to a dedicated user deletion log file
|
||||
$logFile = storage_path('logs/user-deletions.log');
|
||||
|
||||
// Ensure the logs directory exists
|
||||
$logDir = dirname($logFile);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$timestamp = now()->format('Y-m-d H:i:s');
|
||||
file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -773,7 +771,6 @@ private function deploy_nixpacks_buildpack()
|
|||
}
|
||||
}
|
||||
$this->clone_repository();
|
||||
$this->detect_laravel_symfony();
|
||||
$this->cleanup_git();
|
||||
$this->generate_nixpacks_confs();
|
||||
$this->generate_compose_file();
|
||||
|
|
@ -1288,24 +1285,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 +1467,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();
|
||||
|
|
@ -1727,78 +1733,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 +1742,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 +1781,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2977,6 +2890,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 +2914,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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.432',
|
||||
'version' => '4.0.0-beta.433',
|
||||
'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.432"
|
||||
"version": "4.0.0-beta.433"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.433"
|
||||
"version": "4.0.0-beta.434"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
this.$el.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">
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
x-bind:disabled="!canUpdate" />
|
||||
@else
|
||||
<x-forms.input id="application.docker_registry_image_name"
|
||||
helper="Empty means it won't push the image to a docker registry."
|
||||
helper="Empty means it won't push the image to a docker registry. Pre-tag the image with your registry url if you want to push it to a private registry (default: Dockerhub). <br><br>Example: ghcr.io/myimage"
|
||||
placeholder="Empty means it won't push the image to a docker registry."
|
||||
label="Docker Image" x-bind:disabled="!canUpdate" />
|
||||
<x-forms.input id="application.docker_registry_image_tag"
|
||||
|
|
|
|||
|
|
@ -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,73 +1,75 @@
|
|||
<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 class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
<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>
|
||||
</div>
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
@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.',
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
@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 directory" />
|
||||
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
|
||||
<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>
|
||||
@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>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</form>
|
||||
@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
|
||||
</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,50 +9,338 @@
|
|||
$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>
|
||||
<h3>{{ 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">
|
||||
<div class="flex flex-col gap-4">
|
||||
@foreach ($fileStorage->sort() as $fileStorage)
|
||||
<livewire:project.service.file-storage :fileStorage="$fileStorage"
|
||||
wire:key="resource-{{ $fileStorage->uuid }}" />
|
||||
|
|
|
|||
|
|
@ -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,76 +1,166 @@
|
|||
<div>
|
||||
<h2>Resource Operations</h2>
|
||||
<div>You can easily make different kind of operations on this resource.</div>
|
||||
<h3 class="pt-4">Clone</h3>
|
||||
<div class="pb-4">To another project / environment on a different / same server.</div>
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-col flex-wrap gap-2">
|
||||
@can('update', $resource)
|
||||
@foreach ($servers->sortBy('id') as $server)
|
||||
<h5>Server: <span class="font-bold text-dark dark:text-white">{{ $server->name }}</span></h5>
|
||||
@foreach ($server->destinations() as $destination)
|
||||
<x-modal-confirmation title="Clone Resource?" buttonTitle="Clone Resource"
|
||||
submitAction="cloneTo({{ data_get($destination, 'id') }})" :actions="[
|
||||
'All containers of this resource will be duplicated and cloned to the selected destination.',
|
||||
]" :confirmWithText="false"
|
||||
:confirmWithPassword="false" step2ButtonText="Clone Resource" dispatchEvent="true"
|
||||
dispatchEventType="success"
|
||||
dispatchEventMessage="Resource cloned to {{ $destination->name }} destination.">
|
||||
<x:slot name="content">
|
||||
<div class="box group">
|
||||
<div class="flex flex-col">
|
||||
<div class="box-title">Network</div>
|
||||
<div class="box-description">{{ $destination->name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</x:slot>
|
||||
</x-modal-confirmation>
|
||||
@endforeach
|
||||
@endforeach
|
||||
@else
|
||||
<x-callout type="warning" title="Access Restricted">
|
||||
You don't have permission to clone resources. Contact your team administrator to request access.
|
||||
</x-callout>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<h3>Move</h3>
|
||||
<div class="pb-4">Between projects / environments.</div>
|
||||
<div>
|
||||
<div class="pb-2">
|
||||
This resource is currently in the <span
|
||||
class="font-bold dark:text-warning">{{ $resource->environment->project->name }} /
|
||||
{{ $resource->environment->name }}</span> environment.
|
||||
</div>
|
||||
<div class="flex flex-col flex-wrap gap-2">
|
||||
@can('update', $resource)
|
||||
@forelse ($projects as $project)
|
||||
<h5>Project: <span class="font-bold text-dark dark:text-white">{{ $project->name }}</span></h5>
|
||||
|
||||
@foreach ($project->environments as $environment)
|
||||
<x-modal-confirmation title="Move Resource?" buttonTitle="Move Resource"
|
||||
submitAction="moveTo({{ data_get($environment, 'id') }})" :actions="['All containers of this resource will be moved to the selected environment.']" :confirmWithText="false"
|
||||
:confirmWithPassword="false" step2ButtonText="Move Resource" dispatchEvent="true"
|
||||
dispatchEventType="success"
|
||||
dispatchEventMessage="Resource moved to {{ $environment->name }} environment.">
|
||||
<x:slot:content>
|
||||
<div class="box group">
|
||||
<div class="flex flex-col">
|
||||
<div class="box-title">Environment</div>
|
||||
<div class="box-description">{{ $environment->name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</x:slot>
|
||||
</x-modal-confirmation>
|
||||
@endforeach
|
||||
@empty
|
||||
<div>No projects found to move to</div>
|
||||
@endforelse
|
||||
<div x-data="{
|
||||
selectedCloneServer: null,
|
||||
selectedCloneDestination: null,
|
||||
selectedMoveProject: null,
|
||||
selectedMoveEnvironment: null,
|
||||
currentProjectId: {{ $resource->environment->project->id }},
|
||||
currentEnvironmentId: {{ $resource->environment->id }},
|
||||
servers: @js(
|
||||
$servers->map(
|
||||
fn($s) => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'ip' => $s->ip,
|
||||
'destinations' => $s->destinations()->map(
|
||||
fn($d) => [
|
||||
'id' => $d->id,
|
||||
'name' => $d->name,
|
||||
'server_id' => $s->id,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
projects: @js(
|
||||
$projects->map(
|
||||
fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'environments' => $p->environments->map(
|
||||
fn($e) => [
|
||||
'id' => $e->id,
|
||||
'name' => $e->name,
|
||||
'project_id' => $p->id,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
get availableDestinations() {
|
||||
if (!this.selectedCloneServer) return [];
|
||||
const server = this.servers.find(s => s.id == this.selectedCloneServer);
|
||||
return server ? server.destinations : [];
|
||||
},
|
||||
get availableEnvironments() {
|
||||
if (!this.selectedMoveProject) return [];
|
||||
const project = this.projects.find(p => p.id == this.selectedMoveProject);
|
||||
if (!project) return [];
|
||||
// Filter out the current environment if we're in the same project
|
||||
return project.environments.filter(e => {
|
||||
if (project.id === this.currentProjectId) {
|
||||
return e.id !== this.currentEnvironmentId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
get isCurrentProjectSelected() {
|
||||
return this.selectedMoveProject == this.currentProjectId;
|
||||
}
|
||||
}">
|
||||
<h3 class="pt-4">Clone Resource</h3>
|
||||
<div class="pb-2">Duplicate this resource to another server or network destination.</div>
|
||||
|
||||
@can('update', $resource)
|
||||
<div class="space-y-4 pb-8">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-2">Select Server</label>
|
||||
<select x-model="selectedCloneServer" @change="selectedCloneDestination = null" class="select">
|
||||
<option value="">Choose a server...</option>
|
||||
<template x-for="server in servers" :key="server.id">
|
||||
<option :value="server.id" x-text="`${server.name} (${server.ip})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-2">Select Network Destination</label>
|
||||
<select x-model="selectedCloneDestination" :disabled="!selectedCloneServer" class="select">
|
||||
<option value="">Choose a destination...</option>
|
||||
<template x-for="destination in availableDestinations" :key="destination.id">
|
||||
<option :value="destination.id" x-text="destination.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedCloneDestination" x-cloak>
|
||||
<x-forms.button isHighlighted @click="$wire.cloneTo(selectedCloneDestination)" class="mt-2">
|
||||
Clone Resource
|
||||
</x-forms.button>
|
||||
<div class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
All configurations will be duplicated to the selected destination. The running application won't be
|
||||
touched.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-callout type="warning" title="Access Restricted">
|
||||
You don't have permission to clone resources. Contact your team administrator to request access.
|
||||
</x-callout>
|
||||
@endcan
|
||||
|
||||
<h3 class="pt-4">Move Resource</h3>
|
||||
<div class="pb-4">Transfer this resource between projects and environments.</div>
|
||||
|
||||
@can('update', $resource)
|
||||
@if ($projects->count() > 0)
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-2">Select Target Project</label>
|
||||
<select x-model="selectedMoveProject" @change="selectedMoveEnvironment = null" class="select">
|
||||
<option value="">Choose a project...</option>
|
||||
<template x-for="project in projects" :key="project.id">
|
||||
<option :value="project.id"
|
||||
x-text="project.name + (project.id === currentProjectId ? ' (current)' : '')">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium mb-2 flex gap-2 items-center">Select Target
|
||||
Environment
|
||||
<x-helper helper="Current environment is excluded." />
|
||||
</label>
|
||||
<select x-model="selectedMoveEnvironment"
|
||||
:disabled="!selectedMoveProject || availableEnvironments.length === 0" class="select">
|
||||
<option value=""
|
||||
x-text="availableEnvironments.length === 0 && isCurrentProjectSelected ? 'No other environments available' : 'Choose an environment...'">
|
||||
</option>
|
||||
<template x-for="environment in availableEnvironments" :key="environment.id">
|
||||
<option :value="environment.id"
|
||||
x-text="environment.name + (environment.id === currentEnvironmentId ? ' (current)' : '')">
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedMoveEnvironment" x-cloak>
|
||||
<x-forms.button isHighlighted @click="$wire.moveTo(selectedMoveEnvironment)" class="mt-2">
|
||||
Move Resource
|
||||
</x-forms.button>
|
||||
<div class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
All configurations will be moved to the selected environment. The running application won't be
|
||||
touched.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-callout type="warning" title="Access Restricted">
|
||||
You don't have permission to move resources between projects or environments. Contact your team administrator to request access.
|
||||
</x-callout>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="text-neutral-600 dark:text-neutral-400">No other projects available for moving this resource.
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<x-callout type="warning" title="Access Restricted">
|
||||
You don't have permission to move resources between projects or environments. Contact your team
|
||||
administrator to request access.
|
||||
</x-callout>
|
||||
@endcan
|
||||
</div>
|
||||
</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,5 +1,5 @@
|
|||
<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)
|
||||
@if ($isFirst)
|
||||
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
|
||||
|
|
|
|||
|
|
@ -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]) }}">
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@
|
|||
<li>Optionally delete unused volumes (if enabled in advanced options).</li>
|
||||
<li>Optionally remove unused networks (if enabled in advanced options).</li>
|
||||
</ul>"
|
||||
instantSave id="forceDockerCleanup" label="Force Docker Cleanup"
|
||||
/>
|
||||
instantSave id="forceDockerCleanup" label="Force Docker Cleanup" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -61,7 +60,8 @@
|
|||
<div class="flex flex-col gap-2 mt-6">
|
||||
<h3>Advanced</h3>
|
||||
<x-callout type="warning" title="Caution">
|
||||
<p>These options can cause permanent data loss and functional issues. Only enable if you fully understand the consequences</p>
|
||||
<p>These options can cause permanent data loss and functional issues. Only enable if you fully
|
||||
understand the consequences.</p>
|
||||
</x-callout>
|
||||
<div class="w-full sm:w-96">
|
||||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave id="deleteUnusedVolumes"
|
||||
|
|
@ -70,16 +70,14 @@
|
|||
<ul class='list-disc pl-4 mt-2'>
|
||||
<li>Volumes not attached to running containers will be permanently deleted (volumes from stopped containers are affected).</li>
|
||||
<li>Data stored in deleted volumes cannot be recovered.</li>
|
||||
</ul>"
|
||||
/>
|
||||
</ul>" />
|
||||
<x-forms.checkbox canGate="update" :canResource="$server" instantSave id="deleteUnusedNetworks"
|
||||
label="Delete Unused Networks"
|
||||
helper="This option will remove all unused Docker networks during cleanup.<br><br><strong>Warning: Functionality may be lost and containers may not be able to communicate with each other!</strong><br><br>Consequences include:<br>
|
||||
<ul class='list-disc pl-4 mt-2'>
|
||||
<li>Networks not attached to running containers will be permanently deleted (networks used by stopped containers are affected).</li>
|
||||
<li>Containers may lose connectivity if required networks are removed.</li>
|
||||
</ul>"
|
||||
/>
|
||||
</ul>" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.432"
|
||||
"version": "4.0.0-beta.433"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.433"
|
||||
"version": "4.0.0-beta.434"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue