Merge branch 'next' into next
This commit is contained in:
commit
704e016d9b
38 changed files with 1385 additions and 149 deletions
156
.AI_INSTRUCTIONS_SYNC.md
Normal file
156
.AI_INSTRUCTIONS_SYNC.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# AI Instructions Synchronization Guide
|
||||
|
||||
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
|
||||
|
||||
## Overview
|
||||
|
||||
Coolify maintains AI instructions in two parallel systems:
|
||||
|
||||
1. **CLAUDE.md** - For Claude Code (claude.ai/code)
|
||||
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
|
||||
|
||||
Both systems share core principles but are optimized for their respective workflows.
|
||||
|
||||
## Structure
|
||||
|
||||
### CLAUDE.md
|
||||
- **Purpose**: Condensed, workflow-focused guide for Claude Code
|
||||
- **Format**: Single markdown file
|
||||
- **Includes**:
|
||||
- Quick-reference development commands
|
||||
- High-level architecture overview
|
||||
- Core patterns and guidelines
|
||||
- Embedded Laravel Boost guidelines
|
||||
- References to detailed .cursor/rules/ documentation
|
||||
|
||||
### .cursor/rules/
|
||||
- **Purpose**: Detailed, topic-specific documentation
|
||||
- **Format**: Multiple .mdc files organized by topic
|
||||
- **Structure**:
|
||||
- `README.mdc` - Main index and overview
|
||||
- `cursor_rules.mdc` - Maintenance guidelines
|
||||
- Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
|
||||
- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
|
||||
|
||||
## Cross-References
|
||||
|
||||
Both systems reference each other:
|
||||
|
||||
- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
|
||||
- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
|
||||
- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
|
||||
|
||||
## Maintaining Consistency
|
||||
|
||||
When updating AI instructions, follow these guidelines:
|
||||
|
||||
### 1. Core Principles (MUST be consistent)
|
||||
- Laravel version (currently Laravel 12)
|
||||
- PHP version (8.4)
|
||||
- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
|
||||
- Security patterns and authorization requirements
|
||||
- Code style requirements (Pint, PSR-12)
|
||||
|
||||
### 2. Where to Make Changes
|
||||
|
||||
**For workflow changes** (how to run commands, development setup):
|
||||
- Primary: `CLAUDE.md`
|
||||
- Secondary: `.cursor/rules/development-workflow.mdc`
|
||||
|
||||
**For architectural patterns** (how code should be structured):
|
||||
- Primary: `.cursor/rules/` topic files
|
||||
- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
|
||||
|
||||
**For testing patterns**:
|
||||
- Both: Must be synchronized
|
||||
- `CLAUDE.md` - Contains condensed testing execution rules
|
||||
- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
|
||||
|
||||
### 3. Update Checklist
|
||||
|
||||
When making significant changes:
|
||||
|
||||
- [ ] Identify if change affects core principles (version numbers, critical patterns)
|
||||
- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
|
||||
- [ ] Check if update affects cross-referenced content
|
||||
- [ ] Update secondary location if needed
|
||||
- [ ] Verify cross-references are still accurate
|
||||
- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
|
||||
|
||||
### 4. Common Inconsistencies to Watch
|
||||
|
||||
- **Version numbers**: Laravel, PHP, package versions
|
||||
- **Testing instructions**: Docker execution requirements
|
||||
- **File paths**: Ensure relative paths work from root
|
||||
- **Command syntax**: Docker commands, artisan commands
|
||||
- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
/
|
||||
├── CLAUDE.md # Claude Code instructions (condensed)
|
||||
├── .AI_INSTRUCTIONS_SYNC.md # This file
|
||||
└── .cursor/
|
||||
└── rules/
|
||||
├── README.mdc # Index and overview
|
||||
├── cursor_rules.mdc # Maintenance guide
|
||||
├── testing-patterns.mdc # Testing details
|
||||
├── development-workflow.mdc # Dev setup details
|
||||
├── security-patterns.mdc # Security details
|
||||
├── application-architecture.mdc
|
||||
├── deployment-architecture.mdc
|
||||
├── database-patterns.mdc
|
||||
├── frontend-patterns.mdc
|
||||
├── api-and-routing.mdc
|
||||
├── form-components.mdc
|
||||
├── technology-stack.mdc
|
||||
├── project-overview.mdc
|
||||
└── laravel-boost.mdc # Laravel-specific patterns
|
||||
```
|
||||
|
||||
## Recent Updates
|
||||
|
||||
### 2025-10-07
|
||||
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
|
||||
- ✅ Synchronized Laravel version (12) across all files
|
||||
- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
|
||||
- ✅ Added test design philosophy (prefer mocking over database)
|
||||
- ✅ Fixed inconsistencies in testing documentation
|
||||
- ✅ Created this synchronization guide
|
||||
|
||||
## Maintenance Commands
|
||||
|
||||
```bash
|
||||
# Check for version inconsistencies
|
||||
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
||||
|
||||
# Check for PHP version consistency
|
||||
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
|
||||
|
||||
# Format all documentation
|
||||
./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
|
||||
|
||||
# Search for specific patterns across all docs
|
||||
grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing documentation:
|
||||
|
||||
1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
|
||||
2. Add to appropriate location(s) based on guidelines above
|
||||
3. Add cross-references if creating new patterns
|
||||
4. Update this file if changing organizational structure
|
||||
5. Verify consistency before submitting PR
|
||||
|
||||
## Questions?
|
||||
|
||||
If unsure about where to document something:
|
||||
|
||||
- **Quick reference / workflow** → CLAUDE.md
|
||||
- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
|
||||
- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
|
||||
|
||||
When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.
|
||||
|
|
@ -9,6 +9,10 @@ alwaysApply: false
|
|||
|
||||
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
|
||||
|
||||
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
|
||||
>
|
||||
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
|
||||
|
||||
## Rule Categories
|
||||
|
||||
### 🏗️ Architecture & Foundation
|
||||
|
|
@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where:
|
|||
- **Multi-server** support with SSH connections
|
||||
|
||||
### 3. Technology Stack
|
||||
- **Backend**: Laravel 11 + PHP 8.4
|
||||
- **Backend**: Laravel 12 + PHP 8.4
|
||||
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
|
||||
- **Database**: PostgreSQL 15 + Redis 7
|
||||
- **Containerization**: Docker + Docker Compose
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc
|
|||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Cursor Rules Maintenance Guide
|
||||
|
||||
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
|
||||
>
|
||||
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
|
||||
|
||||
- **Required Rule Structure:**
|
||||
```markdown
|
||||
---
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,56 @@ alwaysApply: false
|
|||
---
|
||||
# Coolify Testing Architecture & Patterns
|
||||
|
||||
> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
|
||||
|
||||
!Important: Always run tests inside `coolify` container.
|
||||
### Test Execution Rules
|
||||
|
||||
**CRITICAL**: Tests are categorized by database dependency:
|
||||
|
||||
#### Unit Tests (`tests/Unit/`)
|
||||
- **MUST NOT** use database connections
|
||||
- **MUST** use mocking for models and external dependencies
|
||||
- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
|
||||
- Purpose: Test isolated logic, helper functions, and business rules
|
||||
|
||||
#### Feature Tests (`tests/Feature/`)
|
||||
- **MAY** use database connections (factories, migrations, models)
|
||||
- **MUST** run inside Docker container: `docker exec coolify php artisan test`
|
||||
- **MUST** use `RefreshDatabase` trait if touching database
|
||||
- Purpose: Test API endpoints, workflows, and integration scenarios
|
||||
|
||||
**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
|
||||
|
||||
### Prefer Mocking Over Database
|
||||
|
||||
When writing tests, always prefer mocking over real database operations:
|
||||
|
||||
```php
|
||||
// ❌ BAD: Unit test using database
|
||||
it('extracts custom commands', function () {
|
||||
$server = Server::factory()->create(['ip' => '1.2.3.4']);
|
||||
$commands = extract_custom_proxy_commands($server, $yaml);
|
||||
expect($commands)->toBeArray();
|
||||
});
|
||||
|
||||
// ✅ GOOD: Unit test using mocking
|
||||
it('extracts custom commands', function () {
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn('traefik');
|
||||
$commands = extract_custom_proxy_commands($server, $yaml);
|
||||
expect($commands)->toBeArray();
|
||||
});
|
||||
```
|
||||
|
||||
**Design principles for testable code:**
|
||||
- Use dependency injection instead of global state
|
||||
- Create interfaces for external dependencies (SSH, Docker, etc.)
|
||||
- Separate business logic from data persistence
|
||||
- Make functions accept interfaces instead of concrete models when possible
|
||||
|
||||
## Testing Framework Stack
|
||||
|
||||
|
|
|
|||
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -1,6 +1,10 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
|
||||
>
|
||||
> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
|
@ -23,7 +27,14 @@ ### Backend Development
|
|||
### Code Quality
|
||||
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
|
||||
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
|
||||
- `./vendor/bin/pest` - Run Pest tests
|
||||
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
|
||||
|
||||
### Running Tests
|
||||
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
|
||||
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
|
||||
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
|
||||
- Unit tests should use mocking and avoid database connections
|
||||
- Feature tests that require database must be run in the `coolify` container
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
|
|
@ -173,6 +184,21 @@ ### Testing Strategy
|
|||
- **Mocking**: Use Laravel's built-in mocking for external services
|
||||
- **Database**: Use RefreshDatabase trait for test isolation
|
||||
|
||||
#### Test Execution Environment
|
||||
**CRITICAL**: Database-dependent tests MUST run inside Docker container:
|
||||
- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
|
||||
- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
|
||||
- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
|
||||
- Always mock external services and SSH connections in tests
|
||||
|
||||
#### Test Design Philosophy
|
||||
**PREFER MOCKING**: When designing features and writing tests:
|
||||
- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
|
||||
- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
|
||||
- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
|
||||
- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
|
||||
- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
|
||||
|
||||
### Routing Conventions
|
||||
- Group routes by middleware and prefix
|
||||
- Use route model binding for cleaner controllers
|
||||
|
|
@ -228,7 +254,9 @@ ## Important Reminders
|
|||
|
||||
## Additional Documentation
|
||||
|
||||
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
|
||||
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
|
||||
|
||||
> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
|
||||
|
||||
### Architecture & Patterns
|
||||
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
|
||||
|
|
@ -434,7 +462,7 @@ ### Laravel 10 Structure
|
|||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
|
@ -543,6 +571,10 @@ ### Pest Tests
|
|||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
|
||||
- **Feature tests** can use database but MUST run inside Docker container.
|
||||
- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
|
||||
- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
|
|
@ -551,11 +583,23 @@ ### Pest Tests
|
|||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
|
||||
|
||||
**Unit Tests (no database):**
|
||||
- Run outside Docker: `./vendor/bin/pest tests/Unit`
|
||||
- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
|
||||
- These tests use mocking and don't require PostgreSQL
|
||||
|
||||
**Feature Tests (with database):**
|
||||
- Run inside Docker: `docker exec coolify php artisan test`
|
||||
- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
|
||||
- Filter by name: `docker exec coolify php artisan test --filter=testName`
|
||||
- These tests require PostgreSQL and use factories/migrations
|
||||
|
||||
**General Guidelines:**
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
|
||||
- If you get database connection errors, you're running a Feature test outside Docker - move it inside
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
|
|
@ -650,7 +694,12 @@ ### Replaced Utilities
|
|||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed.
|
||||
- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
|
||||
- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
|
||||
- Choose the correct test type based on database dependency:
|
||||
- No database needed? → Unit test with mocking
|
||||
- Database needed? → Feature test in Docker
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
|||
// 1. Force regenerate is requested
|
||||
// 2. Configuration file doesn't exist or is empty
|
||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
|
||||
// Extract custom commands from existing config before regenerating
|
||||
$custom_commands = [];
|
||||
if (! empty(trim($proxy_configuration ?? ''))) {
|
||||
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
||||
}
|
||||
|
||||
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
||||
}
|
||||
|
||||
if (empty($proxy_configuration)) {
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,10 @@ private function should_skip_build()
|
|||
$this->skip_build = true;
|
||||
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$this->generate_compose_file();
|
||||
|
||||
// Save runtime environment variables even when skipping build
|
||||
$this->save_runtime_environment_variables();
|
||||
|
||||
$this->push_to_docker_registry();
|
||||
$this->rolling_update();
|
||||
|
||||
|
|
@ -1014,6 +1018,10 @@ private function should_skip_build()
|
|||
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
$this->skip_build = true;
|
||||
$this->generate_compose_file();
|
||||
|
||||
// Save runtime environment variables even when skipping build
|
||||
$this->save_runtime_environment_variables();
|
||||
|
||||
$this->push_to_docker_registry();
|
||||
$this->rolling_update();
|
||||
|
||||
|
|
@ -1694,7 +1702,7 @@ private function create_workdir()
|
|||
}
|
||||
}
|
||||
|
||||
private function prepare_builder_image()
|
||||
private function prepare_builder_image(bool $firstTry = true)
|
||||
{
|
||||
$this->checkForCancellation();
|
||||
$settings = instanceSettings();
|
||||
|
|
@ -1717,7 +1725,12 @@ private function prepare_builder_image()
|
|||
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
}
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
|
||||
if ($firstTry) {
|
||||
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
|
||||
}
|
||||
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
|
|
@ -1740,7 +1753,7 @@ private function restart_builder_container_with_actual_commit()
|
|||
$this->env_args = null;
|
||||
|
||||
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
|
||||
$this->prepare_builder_image();
|
||||
$this->prepare_builder_image(firstTry: false);
|
||||
}
|
||||
|
||||
private function deploy_to_additional_destinations()
|
||||
|
|
@ -1988,6 +2001,15 @@ private function generate_nixpacks_confs()
|
|||
if ($this->nixpacks_type === 'elixir') {
|
||||
$this->elixir_finetunes();
|
||||
}
|
||||
if ($this->nixpacks_type === 'node') {
|
||||
// Check if NIXPACKS_NODE_VERSION is set
|
||||
$variables = data_get($parsed, 'variables', []);
|
||||
if (! isset($variables['NIXPACKS_NODE_VERSION'])) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
$this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.');
|
||||
$this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.');
|
||||
}
|
||||
}
|
||||
$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);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ public function handle()
|
|||
} elseif ($this->status === ProcessStatus::ERROR) {
|
||||
$this->body = "The preview deployment failed. 🔴\n\n";
|
||||
}
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
|
||||
|
||||
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
|
||||
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Models\Team;
|
||||
use App\Notifications\Database\BackupFailed;
|
||||
use App\Notifications\Database\BackupSuccess;
|
||||
use App\Notifications\Database\BackupSuccessWithS3Warning;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -74,6 +75,7 @@ public function __construct(public ScheduledDatabaseBackup $backup)
|
|||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
$this->backup_log_uuid = (string) new Cuid2;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
|
|
@ -298,6 +300,10 @@ public function handle(): void
|
|||
} while ($exists);
|
||||
|
||||
$size = 0;
|
||||
$localBackupSucceeded = false;
|
||||
$s3UploadError = null;
|
||||
|
||||
// Step 1: Create local backup
|
||||
try {
|
||||
if (str($databaseType)->contains('postgres')) {
|
||||
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
|
||||
|
|
@ -310,6 +316,7 @@ public function handle(): void
|
|||
'database_name' => $database,
|
||||
'filename' => $this->backup_location,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_postgresql($database);
|
||||
} elseif (str($databaseType)->contains('mongo')) {
|
||||
|
|
@ -330,6 +337,7 @@ public function handle(): void
|
|||
'database_name' => $databaseName,
|
||||
'filename' => $this->backup_location,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_mongodb($database);
|
||||
} elseif (str($databaseType)->contains('mysql')) {
|
||||
|
|
@ -343,6 +351,7 @@ public function handle(): void
|
|||
'database_name' => $database,
|
||||
'filename' => $this->backup_location,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_mysql($database);
|
||||
} elseif (str($databaseType)->contains('mariadb')) {
|
||||
|
|
@ -356,56 +365,77 @@ public function handle(): void
|
|||
'database_name' => $database,
|
||||
'filename' => $this->backup_location,
|
||||
'scheduled_database_backup_id' => $this->backup->id,
|
||||
'local_storage_deleted' => false,
|
||||
]);
|
||||
$this->backup_standalone_mariadb($database);
|
||||
} else {
|
||||
throw new \Exception('Unsupported database type');
|
||||
}
|
||||
|
||||
$size = $this->calculate_size();
|
||||
if ($this->backup->save_s3) {
|
||||
|
||||
// Verify local backup succeeded
|
||||
if ($size > 0) {
|
||||
$localBackupSucceeded = true;
|
||||
} else {
|
||||
throw new \Exception('Local backup file is empty or was not created');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Local backup failed
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
's3_uploaded' => null,
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: Upload to S3 if enabled (independent of local backup)
|
||||
$localStorageDeleted = false;
|
||||
if ($this->backup->save_s3 && $localBackupSucceeded) {
|
||||
try {
|
||||
$this->upload_to_s3();
|
||||
|
||||
// If local backup is disabled, delete the local file immediately after S3 upload
|
||||
if ($this->backup->disable_local_backup) {
|
||||
deleteBackupsLocally($this->backup_location, $this->server);
|
||||
$localStorageDeleted = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// S3 upload failed but local backup succeeded
|
||||
$s3UploadError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
// Step 3: Update status and send notifications based on results
|
||||
if ($localBackupSucceeded) {
|
||||
$message = $this->backup_output;
|
||||
|
||||
if ($s3UploadError) {
|
||||
$message = $message
|
||||
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
|
||||
: 'Warning: S3 upload failed: '.$s3UploadError;
|
||||
}
|
||||
|
||||
$this->backup_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->backup_output,
|
||||
'message' => $message,
|
||||
'size' => $size,
|
||||
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
|
||||
'local_storage_deleted' => $localStorageDeleted,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Check if backup actually failed or if it's just a post-backup issue
|
||||
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
|
||||
|
||||
if ($actualBackupFailed || $size === 0) {
|
||||
// Real backup failure
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'failed',
|
||||
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
|
||||
'size' => $size,
|
||||
'filename' => null,
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
|
||||
// Send appropriate notification
|
||||
if ($s3UploadError) {
|
||||
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
|
||||
} else {
|
||||
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
|
||||
if ($this->backup_log) {
|
||||
$this->backup_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
|
||||
'size' => $size,
|
||||
]);
|
||||
}
|
||||
// Send success notification since the backup itself succeeded
|
||||
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
|
||||
// Log the post-backup issue
|
||||
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -591,24 +621,24 @@ private function upload_to_s3(): void
|
|||
|
||||
$fullImageName = $this->getFullImageName();
|
||||
|
||||
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
|
||||
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
|
||||
if (filled($containerExists)) {
|
||||
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
|
||||
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
|
||||
}
|
||||
|
||||
if (isDev()) {
|
||||
if ($this->database->name === 'coolify-db') {
|
||||
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
} else {
|
||||
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
|
||||
}
|
||||
} else {
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||
}
|
||||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
||||
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
|
||||
$this->s3_uploaded = true;
|
||||
|
|
@ -617,7 +647,7 @@ private function upload_to_s3(): void
|
|||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$command = "docker rm -f backup-of-{$this->backup->uuid}";
|
||||
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
|
||||
instant_remote_process([$command], $this->server);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,21 @@ class GlobalSearch extends Component
|
|||
|
||||
public $allSearchableItems = [];
|
||||
|
||||
public $isCreateMode = false;
|
||||
|
||||
public $creatableItems = [];
|
||||
|
||||
public $autoOpenResource = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->searchQuery = '';
|
||||
$this->isModalOpen = false;
|
||||
$this->searchResults = [];
|
||||
$this->allSearchableItems = [];
|
||||
$this->isCreateMode = false;
|
||||
$this->creatableItems = [];
|
||||
$this->autoOpenResource = null;
|
||||
}
|
||||
|
||||
public function openSearchModal()
|
||||
|
|
@ -62,7 +71,63 @@ public static function clearTeamCache($teamId)
|
|||
|
||||
public function updatedSearchQuery()
|
||||
{
|
||||
$this->search();
|
||||
$query = strtolower(trim($this->searchQuery));
|
||||
|
||||
if (str_starts_with($query, 'new')) {
|
||||
$this->isCreateMode = true;
|
||||
$this->loadCreatableItems();
|
||||
$this->searchResults = [];
|
||||
|
||||
// Check for sub-commands like "new project", "new server", etc.
|
||||
// Use original query (not trimmed) to ensure exact match without trailing spaces
|
||||
$this->autoOpenResource = $this->detectSpecificResource(strtolower($this->searchQuery));
|
||||
} else {
|
||||
$this->isCreateMode = false;
|
||||
$this->creatableItems = [];
|
||||
$this->autoOpenResource = null;
|
||||
$this->search();
|
||||
}
|
||||
}
|
||||
|
||||
private function detectSpecificResource(string $query): ?string
|
||||
{
|
||||
// Map of keywords to resource types - order matters for multi-word matches
|
||||
$resourceMap = [
|
||||
'new project' => 'project',
|
||||
'new server' => 'server',
|
||||
'new team' => 'team',
|
||||
'new storage' => 'storage',
|
||||
'new s3' => 'storage',
|
||||
'new private key' => 'private-key',
|
||||
'new privatekey' => 'private-key',
|
||||
'new key' => 'private-key',
|
||||
'new github' => 'source',
|
||||
'new source' => 'source',
|
||||
'new git' => 'source',
|
||||
];
|
||||
|
||||
foreach ($resourceMap as $command => $type) {
|
||||
if ($query === $command) {
|
||||
// Check if user has permission for this resource type
|
||||
if ($this->canCreateResource($type)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function canCreateResource(string $type): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return match ($type) {
|
||||
'project', 'source' => $user->can('createAnyResource'),
|
||||
'server', 'storage', 'private-key' => $user->isAdmin() || $user->isOwner(),
|
||||
'team' => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function loadSearchableItems()
|
||||
|
|
@ -437,6 +502,72 @@ private function search()
|
|||
->toArray();
|
||||
}
|
||||
|
||||
private function loadCreatableItems()
|
||||
{
|
||||
$items = collect();
|
||||
$user = auth()->user();
|
||||
|
||||
// Project - can be created if user has createAnyResource permission
|
||||
if ($user->can('createAnyResource')) {
|
||||
$items->push([
|
||||
'name' => 'Project',
|
||||
'description' => 'Create a new project to organize your resources',
|
||||
'type' => 'project',
|
||||
'component' => 'project.add-empty',
|
||||
]);
|
||||
}
|
||||
|
||||
// Server - can be created if user is admin or owner
|
||||
if ($user->isAdmin() || $user->isOwner()) {
|
||||
$items->push([
|
||||
'name' => 'Server',
|
||||
'description' => 'Add a new server to deploy your applications',
|
||||
'type' => 'server',
|
||||
'component' => 'server.create',
|
||||
]);
|
||||
}
|
||||
|
||||
// Team - can be created by anyone (they become owner of new team)
|
||||
$items->push([
|
||||
'name' => 'Team',
|
||||
'description' => 'Create a new team to collaborate with others',
|
||||
'type' => 'team',
|
||||
'component' => 'team.create',
|
||||
]);
|
||||
|
||||
// Storage - can be created if user is admin or owner
|
||||
if ($user->isAdmin() || $user->isOwner()) {
|
||||
$items->push([
|
||||
'name' => 'S3 Storage',
|
||||
'description' => 'Add S3 storage for backups and file uploads',
|
||||
'type' => 'storage',
|
||||
'component' => 'storage.create',
|
||||
]);
|
||||
}
|
||||
|
||||
// Private Key - can be created if user is admin or owner
|
||||
if ($user->isAdmin() || $user->isOwner()) {
|
||||
$items->push([
|
||||
'name' => 'Private Key',
|
||||
'description' => 'Add an SSH private key for server access',
|
||||
'type' => 'private-key',
|
||||
'component' => 'security.private-key.create',
|
||||
]);
|
||||
}
|
||||
|
||||
// GitHub Source - can be created if user has createAnyResource permission
|
||||
if ($user->can('createAnyResource')) {
|
||||
$items->push([
|
||||
'name' => 'GitHub App',
|
||||
'description' => 'Connect a GitHub app for source control',
|
||||
'type' => 'source',
|
||||
'component' => 'source.github.create',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->creatableItems = $items->toArray();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.global-search');
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ public function submit()
|
|||
'uuid' => (string) new Cuid2,
|
||||
]);
|
||||
|
||||
return redirect()->route('project.show', $project->uuid);
|
||||
$productionEnvironment = $project->environments()->where('name', 'production')->first();
|
||||
|
||||
return redirect()->route('project.resource.index', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $productionEnvironment->uuid,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ private function customValidate()
|
|||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
|
||||
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
|
||||
}
|
||||
|
||||
$isValid = validate_cron_expression($this->backup->frequency);
|
||||
|
|
|
|||
|
|
@ -202,11 +202,6 @@ public function server()
|
|||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.database.backup-executions', [
|
||||
'checkboxes' => [
|
||||
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
|
||||
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
|
||||
],
|
||||
]);
|
||||
return view('livewire.project.database.backup-executions');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ public function mount()
|
|||
$this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) {
|
||||
$project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]);
|
||||
$project->canUpdate = auth()->user()->can('update', $project);
|
||||
$project->canCreateResource = auth()->user()->can('createAnyResource');
|
||||
$firstEnvironment = $project->environments->first();
|
||||
$project->addResourceRoute = $firstEnvironment
|
||||
? route('project.resource.create', [
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $firstEnvironment->uuid,
|
||||
])
|
||||
: null;
|
||||
|
||||
return $project;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component
|
|||
public ?string $publish_directory = null;
|
||||
|
||||
// In case of docker compose
|
||||
public ?string $base_directory = null;
|
||||
public ?string $base_directory = '/';
|
||||
|
||||
public ?string $docker_compose_location = '/docker-compose.yaml';
|
||||
// End of docker compose
|
||||
|
|
@ -198,6 +198,7 @@ public function submit()
|
|||
'build_pack' => $this->build_pack,
|
||||
'ports_exposes' => $this->port,
|
||||
'publish_directory' => $this->publish_directory,
|
||||
'base_directory' => $this->base_directory,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination_class,
|
||||
|
|
@ -212,7 +213,6 @@ public function submit()
|
|||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application['base_directory'] = $this->base_directory;
|
||||
}
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
|
|
|
|||
|
|
@ -212,6 +212,12 @@ private function handleSingleSubmit($data)
|
|||
$environment = $this->createEnvironmentVariable($data);
|
||||
$environment->order = $maxOrder + 1;
|
||||
$environment->save();
|
||||
|
||||
// Clear computed property cache to force refresh
|
||||
unset($this->environmentVariables);
|
||||
unset($this->environmentVariablesPreview);
|
||||
|
||||
$this->dispatch('success', 'Environment variable added.');
|
||||
}
|
||||
|
||||
private function createEnvironmentVariable($data)
|
||||
|
|
@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables)
|
|||
public function refreshEnvs()
|
||||
{
|
||||
$this->resource->refresh();
|
||||
// Clear computed property cache to force refresh
|
||||
unset($this->environmentVariables);
|
||||
unset($this->environmentVariablesPreview);
|
||||
$this->getDevView();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\S3Storage;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Form extends Component
|
||||
|
|
@ -91,9 +92,24 @@ public function submit()
|
|||
try {
|
||||
$this->authorize('update', $this->storage);
|
||||
|
||||
$this->validate();
|
||||
$this->testConnection();
|
||||
DB::transaction(function () {
|
||||
$this->validate();
|
||||
$this->storage->save();
|
||||
|
||||
// Test connection with new values - if this fails, transaction will rollback
|
||||
$this->storage->testConnection(shouldSave: false);
|
||||
|
||||
// If we get here, the connection test succeeded
|
||||
$this->storage->is_usable = true;
|
||||
$this->storage->unusable_email_sent = false;
|
||||
$this->storage->save();
|
||||
});
|
||||
|
||||
$this->dispatch('success', 'Storage settings updated and connection verified.');
|
||||
} catch (\Throwable $e) {
|
||||
// Refresh the model to revert UI to database values after rollback
|
||||
$this->storage->refresh();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function submit()
|
|||
'personal_team' => false,
|
||||
]);
|
||||
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
||||
refreshSession();
|
||||
refreshSession($team);
|
||||
|
||||
return redirect()->route('team.index');
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
's3_uploaded' => 'boolean',
|
||||
'local_storage_deleted' => 'boolean',
|
||||
's3_storage_deleted' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledDatabaseBackup(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledDatabaseBackup::class);
|
||||
|
|
|
|||
116
app/Notifications/Database/BackupSuccessWithS3Warning.php
Normal file
116
app/Notifications/Database/BackupSuccessWithS3Warning.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Notifications\CustomEmailNotification;
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Notifications\Dto\PushoverMessage;
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class BackupSuccessWithS3Warning extends CustomEmailNotification
|
||||
{
|
||||
public string $name;
|
||||
|
||||
public string $frequency;
|
||||
|
||||
public ?string $s3_storage_url = null;
|
||||
|
||||
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
|
||||
$this->name = $database->name;
|
||||
$this->frequency = $backup->frequency;
|
||||
|
||||
if ($backup->s3) {
|
||||
$this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid;
|
||||
}
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable->getEnabledChannels('backup_failure');
|
||||
}
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$mail = new MailMessage;
|
||||
$mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}");
|
||||
$mail->view('emails.backup-success-with-s3-warning', [
|
||||
'name' => $this->name,
|
||||
'database_name' => $this->database_name,
|
||||
'frequency' => $this->frequency,
|
||||
's3_error' => $this->s3_error,
|
||||
's3_storage_url' => $this->s3_storage_url,
|
||||
]);
|
||||
|
||||
return $mail;
|
||||
}
|
||||
|
||||
public function toDiscord(): DiscordMessage
|
||||
{
|
||||
$message = new DiscordMessage(
|
||||
title: ':warning: Database backup succeeded locally, S3 upload failed',
|
||||
description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.",
|
||||
color: DiscordMessage::warningColor(),
|
||||
);
|
||||
|
||||
$message->addField('Frequency', $this->frequency, true);
|
||||
$message->addField('S3 Error', $this->s3_error);
|
||||
|
||||
if ($this->s3_storage_url) {
|
||||
$message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
public function toTelegram(): array
|
||||
{
|
||||
$message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
|
||||
|
||||
if ($this->s3_storage_url) {
|
||||
$message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}";
|
||||
}
|
||||
|
||||
return [
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function toPushover(): PushoverMessage
|
||||
{
|
||||
$message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.<br/><br/><b>Frequency:</b> {$this->frequency}.<br/><b>S3 Error:</b> {$this->s3_error}";
|
||||
|
||||
if ($this->s3_storage_url) {
|
||||
$message .= "<br/><br/><a href=\"{$this->s3_storage_url}\">Check S3 Configuration</a>";
|
||||
}
|
||||
|
||||
return new PushoverMessage(
|
||||
title: 'Database backup succeeded locally, S3 upload failed',
|
||||
level: 'warning',
|
||||
message: $message,
|
||||
);
|
||||
}
|
||||
|
||||
public function toSlack(): SlackMessage
|
||||
{
|
||||
$title = 'Database backup succeeded locally, S3 upload failed';
|
||||
$description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.";
|
||||
|
||||
$description .= "\n\n*Frequency:* {$this->frequency}";
|
||||
$description .= "\n\n*S3 Error:* {$this->s3_error}";
|
||||
|
||||
if ($this->s3_storage_url) {
|
||||
$description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>";
|
||||
}
|
||||
|
||||
return new SlackMessage(
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: SlackMessage::warningColor()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -237,12 +237,11 @@ function removeOldBackups($backup): void
|
|||
{
|
||||
try {
|
||||
if ($backup->executions) {
|
||||
// If local backup is disabled, mark all executions as having local storage deleted
|
||||
if ($backup->disable_local_backup && $backup->save_s3) {
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', false)
|
||||
->update(['local_storage_deleted' => true]);
|
||||
} else {
|
||||
// Delete old local backups (only if local backup is NOT disabled)
|
||||
// Note: When disable_local_backup is enabled, each execution already marks its own
|
||||
// local_storage_deleted status at the time of backup, so we don't need to retroactively
|
||||
// update old executions
|
||||
if (! $backup->disable_local_backup) {
|
||||
$localBackupsToDelete = deleteOldBackupsLocally($backup);
|
||||
if ($localBackupsToDelete->isNotEmpty()) {
|
||||
$backup->executions()
|
||||
|
|
@ -261,18 +260,18 @@ function removeOldBackups($backup): void
|
|||
}
|
||||
}
|
||||
|
||||
// Delete executions where both local and S3 storage are marked as deleted
|
||||
// or where only S3 is enabled and S3 storage is deleted
|
||||
if ($backup->disable_local_backup && $backup->save_s3) {
|
||||
$backup->executions()
|
||||
->where('s3_storage_deleted', true)
|
||||
->delete();
|
||||
} else {
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where('s3_storage_deleted', true)
|
||||
->delete();
|
||||
}
|
||||
// Delete execution records where all backup copies are gone
|
||||
// Case 1: Both local and S3 backups are deleted
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->where('s3_storage_deleted', true)
|
||||
->delete();
|
||||
|
||||
// Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
|
||||
$backup->executions()
|
||||
->where('local_storage_deleted', true)
|
||||
->whereNull('s3_uploaded')
|
||||
->delete();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
throw $e;
|
||||
|
|
|
|||
|
|
@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server)
|
|||
|
||||
return $commands->flatten();
|
||||
}
|
||||
function generate_default_proxy_configuration(Server $server)
|
||||
function extractCustomProxyCommands(Server $server, string $existing_config): array
|
||||
{
|
||||
$custom_commands = [];
|
||||
$proxy_type = $server->proxyType();
|
||||
|
||||
if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) {
|
||||
return $custom_commands;
|
||||
}
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($existing_config);
|
||||
$existing_commands = data_get($yaml, 'services.traefik.command', []);
|
||||
|
||||
if (empty($existing_commands)) {
|
||||
return $custom_commands;
|
||||
}
|
||||
|
||||
// Define default commands that Coolify generates
|
||||
$default_command_prefixes = [
|
||||
'--ping=',
|
||||
'--api.',
|
||||
'--entrypoints.http.address=',
|
||||
'--entrypoints.https.address=',
|
||||
'--entrypoints.http.http.encodequerysemicolons=',
|
||||
'--entryPoints.http.http2.maxConcurrentStreams=',
|
||||
'--entrypoints.https.http.encodequerysemicolons=',
|
||||
'--entryPoints.https.http2.maxConcurrentStreams=',
|
||||
'--entrypoints.https.http3',
|
||||
'--providers.file.',
|
||||
'--certificatesresolvers.',
|
||||
'--providers.docker',
|
||||
'--providers.swarm',
|
||||
'--log.level=',
|
||||
'--accesslog.',
|
||||
];
|
||||
|
||||
// Extract commands that don't match default prefixes (these are custom)
|
||||
foreach ($existing_commands as $command) {
|
||||
$is_default = false;
|
||||
foreach ($default_command_prefixes as $prefix) {
|
||||
if (str_starts_with($command, $prefix)) {
|
||||
$is_default = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! $is_default) {
|
||||
$custom_commands[] = $command;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If we can't parse the config, return empty array
|
||||
// Silently fail to avoid breaking the proxy regeneration
|
||||
}
|
||||
|
||||
return $custom_commands;
|
||||
}
|
||||
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
|
||||
{
|
||||
$proxy_path = $server->proxyPath();
|
||||
$proxy_type = $server->proxyType();
|
||||
|
|
@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server)
|
|||
$config['services']['traefik']['command'][] = '--providers.docker=true';
|
||||
$config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false';
|
||||
}
|
||||
|
||||
// Append custom commands (e.g., trustedIPs for Cloudflare)
|
||||
if (! empty($custom_commands)) {
|
||||
foreach ($custom_commands as $custom_command) {
|
||||
$config['services']['traefik']['command'][] = $custom_command;
|
||||
}
|
||||
}
|
||||
} elseif ($proxy_type === 'CADDY') {
|
||||
$config = [
|
||||
'networks' => $array_of_networks->toArray(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
|
||||
$table->boolean('s3_uploaded')->nullable()->after('filename');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
|
||||
$table->dropColumn('s3_uploaded');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<x-emails.layout>
|
||||
Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} succeeded locally but failed to upload to S3.
|
||||
|
||||
S3 Error: {{ $s3_error }}
|
||||
|
||||
@if($s3_storage_url)
|
||||
Check S3 Configuration: {{ $s3_storage_url }}
|
||||
@endif
|
||||
</x-emails.layout>
|
||||
|
|
@ -14,8 +14,24 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<section>
|
||||
<h3 class="pb-2">Projects</h3>
|
||||
<section class="-mt-2">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h3>Projects</h3>
|
||||
@if ($projects->count() > 0)
|
||||
<x-modal-input buttonTitle="Add" title="New Project">
|
||||
<x-slot:content>
|
||||
<button
|
||||
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300">
|
||||
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.add-empty />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
@if ($projects->count() > 0)
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
@foreach ($projects as $project)
|
||||
|
|
@ -65,7 +81,23 @@
|
|||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="pb-2">Servers</h3>
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h3>Servers</h3>
|
||||
@if ($servers->count() > 0 && $privateKeys->count() > 0)
|
||||
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
|
||||
<x-slot:content>
|
||||
<button
|
||||
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300">
|
||||
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:server.create />
|
||||
</x-modal-input>
|
||||
@endif
|
||||
</div>
|
||||
@if ($servers->count() > 0)
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
@foreach ($servers as $server)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<div wire:poll.3000ms x-data="{
|
||||
expanded: @entangle('expanded')
|
||||
}" class="fixed bottom-0 z-50 mb-4 left-0 lg:left-56 ml-4">
|
||||
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
|
||||
@if ($this->deploymentCount > 0)
|
||||
<div class="relative">
|
||||
<!-- Indicator Button -->
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
@endif
|
||||
</div>
|
||||
<div class="subtitle">Network endpoints to deploy your resources.</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@forelse ($servers as $server)
|
||||
@forelse ($server->destinations() as $destination)
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
|
|
|
|||
|
|
@ -80,6 +80,20 @@
|
|||
document.removeEventListener('keydown', escapeKeyHandler);
|
||||
document.removeEventListener('keydown', arrowKeyHandler);
|
||||
});
|
||||
|
||||
// Watch for auto-open resource
|
||||
this.$watch('$wire.autoOpenResource', value => {
|
||||
if (value) {
|
||||
// Close search modal first
|
||||
this.closeModal();
|
||||
// Open the specific resource modal after a short delay
|
||||
setTimeout(() => {
|
||||
this.$dispatch('open-create-modal-' + value);
|
||||
// Reset the value so it can trigger again
|
||||
@this.set('autoOpenResource', null);
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
}">
|
||||
|
||||
|
|
@ -106,8 +120,8 @@ class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen
|
|||
</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) })"
|
||||
placeholder="Search for resources... (Type 'new' to create, or 'new project' to add directly)"
|
||||
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">
|
||||
|
|
@ -140,7 +154,71 @@ class="min-h-[200px] items-center justify-center p-8">
|
|||
<!-- Results content - hidden while loading -->
|
||||
<div wire:loading.remove wire:target="searchQuery"
|
||||
class="max-h-[60vh] overflow-y-auto scrollbar">
|
||||
@if (strlen($searchQuery) >= 2 && count($searchResults) > 0)
|
||||
@if ($isCreateMode && count($creatableItems) > 0 && !$autoOpenResource)
|
||||
<!-- Create new resources section -->
|
||||
<div class="py-2" x-data="{
|
||||
openModal(type) {
|
||||
// Close the parent search modal properly
|
||||
const parentModal = this.$root.closest('[x-data]');
|
||||
if (parentModal && parentModal.__x) {
|
||||
parentModal.__x.$data.closeModal();
|
||||
}
|
||||
// Dispatch event to open creation modal after a short delay
|
||||
setTimeout(() => {
|
||||
this.$dispatch('open-create-modal-' + type);
|
||||
}, 150);
|
||||
}
|
||||
}">
|
||||
<div
|
||||
class="px-4 py-2 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-100 dark:border-yellow-800">
|
||||
<h3 class="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
|
||||
Create New Resources
|
||||
</h3>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-0.5">
|
||||
Click on any item below to create a new resource
|
||||
</p>
|
||||
</div>
|
||||
@foreach ($creatableItems as $item)
|
||||
<button type="button" @click="openModal('{{ $item['type'] }}')"
|
||||
class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-transparent hover:border-yellow-500 focus:border-yellow-500">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
{{ $item['name'] }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300 shrink-0">
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{{ $item['description'] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) >= 2 && count($searchResults) > 0)
|
||||
<div class="py-2">
|
||||
@foreach ($searchResults as $index => $result)
|
||||
<a href="{{ $result['link'] ?? '#' }}"
|
||||
|
|
@ -191,7 +269,7 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
|||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0)
|
||||
@elseif (strlen($searchQuery) >= 2 && count($searchResults) === 0 && !$autoOpenResource)
|
||||
<div class="flex items-center justify-center py-12 px-4">
|
||||
<div class="text-center">
|
||||
<p class="mt-4 text-sm font-medium text-neutral-900 dark:text-white">
|
||||
|
|
@ -217,4 +295,257 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Create Resource Modals - Always rendered so they're available when triggered -->
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-project.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New Project</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 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">
|
||||
<livewire:project.add-empty key="create-modal-project" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-server.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New Server</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 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">
|
||||
<livewire:server.create key="create-modal-server" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-team.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New Team</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 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">
|
||||
<livewire:team.create key="create-modal-team" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-storage.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New S3 Storage</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 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">
|
||||
<livewire:storage.create key="create-modal-storage" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-private-key.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New Private Key</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 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">
|
||||
<livewire:security.private-key.create key="create-modal-private-key" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ modalOpen: false }" @open-create-modal-source.window="modalOpen = true"
|
||||
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
|
||||
if (value) {
|
||||
setTimeout(() => {
|
||||
const firstInput = $el.querySelector('input, textarea, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 200);
|
||||
}
|
||||
})"
|
||||
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" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0" @click="modalOpen=false"
|
||||
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-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">New GitHub App</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 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">
|
||||
<livewire:source.github.create key="create-modal-source" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Profile | Coolify
|
||||
</x-slot>
|
||||
<h1>Profile</h1>
|
||||
<div class="subtitle ">Your user profile settings.</div>
|
||||
<div class="subtitle -mt-2">Your user profile settings.</div>
|
||||
<form wire:submit='submit' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>General</h2>
|
||||
|
|
|
|||
|
|
@ -51,12 +51,14 @@ class="flex flex-col gap-4">
|
|||
data_get($execution, 'status') === 'running',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200 dark:shadow-red-900/5' =>
|
||||
data_get($execution, 'status') === 'failed',
|
||||
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200 dark:shadow-amber-900/5' =>
|
||||
data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') === false,
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 dark:shadow-green-900/5' =>
|
||||
data_get($execution, 'status') === 'success',
|
||||
data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') !== false,
|
||||
])>
|
||||
@php
|
||||
$statusText = match (data_get($execution, 'status')) {
|
||||
'success' => 'Success',
|
||||
'success' => data_get($execution, 's3_uploaded') === false ? 'Success (S3 Warning)' : 'Success',
|
||||
'running' => 'In Progress',
|
||||
'failed' => 'Failed',
|
||||
default => ucfirst(data_get($execution, 'status')),
|
||||
|
|
@ -120,20 +122,15 @@ class="flex flex-col gap-4">
|
|||
Local Storage
|
||||
</span>
|
||||
</span>
|
||||
@if ($backup->save_s3)
|
||||
@if (data_get($execution, 's3_uploaded') !== null)
|
||||
<span @class([
|
||||
'px-2 py-1 rounded-sm text-xs font-medium',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !data_get(
|
||||
$execution,
|
||||
's3_storage_deleted',
|
||||
false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get(
|
||||
$execution,
|
||||
's3_storage_deleted',
|
||||
false),
|
||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false),
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false),
|
||||
])>
|
||||
<span class="flex items-center gap-1">
|
||||
@if (!data_get($execution, 's3_storage_deleted', false))
|
||||
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd"
|
||||
|
|
@ -163,9 +160,25 @@ class="flex flex-col gap-4">
|
|||
<x-forms.button class="dark:hover:bg-coolgray-400"
|
||||
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
|
||||
@endif
|
||||
@php
|
||||
$executionCheckboxes = [];
|
||||
$deleteActions = [];
|
||||
|
||||
if (!data_get($execution, 'local_storage_deleted', false)) {
|
||||
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
|
||||
}
|
||||
|
||||
if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) {
|
||||
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
|
||||
}
|
||||
|
||||
if (empty($deleteActions)) {
|
||||
$deleteActions[] = 'This backup execution record will be deleted.';
|
||||
}
|
||||
@endphp
|
||||
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton
|
||||
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$checkboxes"
|
||||
:actions="['This backup will be permanently deleted from local storage.']" confirmationText="{{ data_get($execution, 'filename') }}"
|
||||
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes"
|
||||
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
|
||||
shortConfirmationLabel="Backup Filename" 1 />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,49 +11,29 @@
|
|||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">All your projects are here.</div>
|
||||
<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">
|
||||
<div>No project found with the search term "<span x-text="search"></span>".</div>
|
||||
</template>
|
||||
|
||||
<template x-for="project in filteredProjects" :key="project.uuid">
|
||||
<div class="box group cursor-pointer" @click="$wire.navigateToProject(project.uuid)">
|
||||
<div class="flex flex-col justify-center flex-1 mx-6">
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1" x-data="{ projects: @js($projects) }">
|
||||
<template x-for="project in projects" :key="project.uuid">
|
||||
<div class="box group cursor-pointer" @click="$wire.navigateToProject(project.uuid)">
|
||||
<div class="flex flex-1 mx-6">
|
||||
<div class="flex flex-col justify-center flex-1">
|
||||
<div class="box-title" x-text="project.name"></div>
|
||||
<div class="box-description">
|
||||
<div x-text="project.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2 pt-4 pb-2 mr-4 text-xs lg:py-0 lg:justify-normal"
|
||||
x-show="project.canUpdate">
|
||||
<a class="mx-4 font-bold hover:underline" wire:click.stop
|
||||
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold"
|
||||
x-show="project.canUpdate || project.canCreateResource">
|
||||
<a class="hover:underline" wire:click.stop x-show="project.addResourceRoute"
|
||||
:href="project.addResourceRoute">
|
||||
+ Add Resource
|
||||
</a>
|
||||
<a class="hover:underline" wire:click.stop x-show="project.canUpdate"
|
||||
:href="`/project/${project.uuid}/edit`">
|
||||
Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function searchComponent() {
|
||||
return {
|
||||
search: '',
|
||||
get filteredProjects() {
|
||||
const projects = @js($projects);
|
||||
if (this.search === '') {
|
||||
return projects;
|
||||
}
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return projects.filter(project => {
|
||||
return (project.name?.toLowerCase().includes(searchLower) ||
|
||||
project.description?.toLowerCase().includes(searchLower))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">All your servers are here.</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@forelse ($servers as $server)
|
||||
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
|
||||
@class([
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
</div>
|
||||
<div class="subtitle">Set Team / Project / Environment wide variables.</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 -mt-1">
|
||||
<a class="box group" href="{{ route('shared-variables.team.index') }}">
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
<div class="box-title">Team wide</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">S3 storages for backups.</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@forelse ($s3 as $storage)
|
||||
<a href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer box group'])>
|
||||
<div class="flex flex-col justify-center mx-6">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">Git sources for your applications.</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
|
||||
@forelse ($sources as $source)
|
||||
@if ($source->getMorphClass() === 'App\Models\GithubApp')
|
||||
<a class="flex gap-2 text-center hover:no-underline box group"
|
||||
|
|
|
|||
37
tests/Feature/DatabaseBackupJobTest.php
Normal file
37
tests/Feature/DatabaseBackupJobTest.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('scheduled_database_backup_executions table has s3_uploaded column', function () {
|
||||
expect(Schema::hasColumn('scheduled_database_backup_executions', 's3_uploaded'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('s3_uploaded column is nullable', function () {
|
||||
$columns = Schema::getColumns('scheduled_database_backup_executions');
|
||||
$s3UploadedColumn = collect($columns)->firstWhere('name', 's3_uploaded');
|
||||
|
||||
expect($s3UploadedColumn)->not->toBeNull();
|
||||
expect($s3UploadedColumn['nullable'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('scheduled database backup execution model casts s3_uploaded correctly', function () {
|
||||
$model = new ScheduledDatabaseBackupExecution;
|
||||
$casts = $model->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('s3_uploaded');
|
||||
expect($casts['s3_uploaded'])->toBe('boolean');
|
||||
});
|
||||
|
||||
test('scheduled database backup execution model casts storage deletion fields correctly', function () {
|
||||
$model = new ScheduledDatabaseBackupExecution;
|
||||
$casts = $model->getCasts();
|
||||
|
||||
expect($casts)->toHaveKey('local_storage_deleted');
|
||||
expect($casts['local_storage_deleted'])->toBe('boolean');
|
||||
expect($casts)->toHaveKey('s3_storage_deleted');
|
||||
expect($casts['s3_storage_deleted'])->toBe('boolean');
|
||||
});
|
||||
137
tests/Unit/ProxyCustomCommandsTest.php
Normal file
137
tests/Unit/ProxyCustomCommandsTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
it('extracts custom proxy commands from existing traefik configuration', function () {
|
||||
// Create a sample config with custom trustedIPs commands
|
||||
$existingConfig = [
|
||||
'services' => [
|
||||
'traefik' => [
|
||||
'command' => [
|
||||
'--ping=true',
|
||||
'--api.dashboard=true',
|
||||
'--entrypoints.http.address=:80',
|
||||
'--entrypoints.https.address=:443',
|
||||
'--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22',
|
||||
'--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22',
|
||||
'--providers.docker=true',
|
||||
'--providers.docker.exposedbydefault=false',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$yamlConfig = Yaml::dump($existingConfig);
|
||||
|
||||
// Mock a server with Traefik proxy type
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
|
||||
|
||||
expect($customCommands)
|
||||
->toBeArray()
|
||||
->toHaveCount(2)
|
||||
->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22')
|
||||
->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22');
|
||||
});
|
||||
|
||||
it('returns empty array when only default commands exist', function () {
|
||||
// Config with only default commands
|
||||
$existingConfig = [
|
||||
'services' => [
|
||||
'traefik' => [
|
||||
'command' => [
|
||||
'--ping=true',
|
||||
'--api.dashboard=true',
|
||||
'--entrypoints.http.address=:80',
|
||||
'--entrypoints.https.address=:443',
|
||||
'--providers.docker=true',
|
||||
'--providers.docker.exposedbydefault=false',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$yamlConfig = Yaml::dump($existingConfig);
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
|
||||
|
||||
expect($customCommands)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles invalid yaml gracefully', function () {
|
||||
$invalidYaml = 'this is not: valid: yaml::: content';
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, $invalidYaml);
|
||||
|
||||
expect($customCommands)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty array for caddy proxy type', function () {
|
||||
$existingConfig = [
|
||||
'services' => [
|
||||
'caddy' => [
|
||||
'environment' => ['SOME_VAR=value'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$yamlConfig = Yaml::dump($existingConfig);
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::CADDY->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
|
||||
|
||||
expect($customCommands)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty array when config is empty', function () {
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, '');
|
||||
|
||||
expect($customCommands)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
it('correctly identifies multiple custom command types', function () {
|
||||
$existingConfig = [
|
||||
'services' => [
|
||||
'traefik' => [
|
||||
'command' => [
|
||||
'--ping=true',
|
||||
'--api.dashboard=true',
|
||||
'--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20',
|
||||
'--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20',
|
||||
'--entrypoints.http.forwardedHeaders.insecure=true',
|
||||
'--metrics.prometheus=true',
|
||||
'--providers.docker=true',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$yamlConfig = Yaml::dump($existingConfig);
|
||||
|
||||
$server = Mockery::mock('App\Models\Server');
|
||||
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
|
||||
|
||||
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
|
||||
|
||||
expect($customCommands)
|
||||
->toBeArray()
|
||||
->toHaveCount(4)
|
||||
->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20')
|
||||
->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20')
|
||||
->toContain('--entrypoints.http.forwardedHeaders.insecure=true')
|
||||
->toContain('--metrics.prometheus=true');
|
||||
});
|
||||
Loading…
Reference in a new issue