mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Merge branch 'next' into feature/add-hi-events-service
This commit is contained in:
commit
bdc73fba85
68 changed files with 10215 additions and 309 deletions
1666
.ai/design-system.md
Normal file
1666
.ai/design-system.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,19 +23,28 @@ Activate this skill when:
|
|||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
## Test Directory Structure
|
||||
|
||||
### Creating Tests
|
||||
- `tests/Feature/` and `tests/Unit/` — Legacy tests (keep, don't delete)
|
||||
- `tests/v4/Feature/` — New feature tests (SQLite :memory: database)
|
||||
- `tests/v4/Browser/` — Browser tests (Pest Browser Plugin + Playwright)
|
||||
- `tests/Browser/` — Legacy Dusk browser tests (keep, don't delete)
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
New tests go in `tests/v4/`. The v4 suite uses SQLite :memory: with a schema dump (`database/schema/testing-schema.sql`) instead of running migrations.
|
||||
|
||||
### Test Organization
|
||||
Do NOT remove tests without approval.
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
## Running Tests
|
||||
|
||||
### Basic Test Structure
|
||||
- All v4 tests: `php artisan test --compact tests/v4/`
|
||||
- Browser tests: `php artisan test --compact tests/v4/Browser/`
|
||||
- Feature tests: `php artisan test --compact tests/v4/Feature/`
|
||||
- Specific file: `php artisan test --compact tests/v4/Browser/LoginTest.php`
|
||||
- Filter: `php artisan test --compact --filter=testName`
|
||||
- Headed (see browser): `./vendor/bin/pest tests/v4/Browser/ --headed`
|
||||
- Debug (pause on failure): `./vendor/bin/pest tests/v4/Browser/ --debug`
|
||||
|
||||
## Basic Test Structure
|
||||
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
|
||||
|
|
@ -45,24 +54,10 @@ it('is true', function () {
|
|||
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<code-snippet name="Pest Response Assertion" lang="php">
|
||||
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
|
||||
</code-snippet>
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
|
|
@ -75,7 +70,7 @@ Import mock function before use: `use function Pest\Laravel\mock;`
|
|||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
Use datasets for repetitive tests:
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
|
||||
|
|
@ -88,73 +83,94 @@ it('has emails', function (string $email) {
|
|||
|
||||
</code-snippet>
|
||||
|
||||
## Pest 4 Features
|
||||
## Browser Testing (Pest Browser Plugin + Playwright)
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
Browser tests use `pestphp/pest-plugin-browser` with Playwright. They run **outside Docker** — the plugin starts an in-process HTTP server and Playwright browser automatically.
|
||||
|
||||
### Browser Test Example
|
||||
### Key Rules
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
1. **Always use `RefreshDatabase`** — the in-process server uses SQLite :memory:
|
||||
2. **Always seed `InstanceSettings::create(['id' => 0])` in `beforeEach`** — most pages crash without it
|
||||
3. **Use `User::factory()` for auth tests** — create users with `id => 0` for root user
|
||||
4. **No Dusk, no Selenium** — use `visit()`, `fill()`, `click()`, `assertSee()` from the Pest Browser API
|
||||
5. **Place tests in `tests/v4/Browser/`**
|
||||
6. **Views with bare `function` declarations** will crash on the second request in the same process — wrap with `function_exists()` guard if you encounter this
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
### Browser Test Template
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
<code-snippet name="Coolify Browser Test Template" lang="php">
|
||||
<?php
|
||||
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
});
|
||||
|
||||
it('can visit the page', function () {
|
||||
$page = visit('/login');
|
||||
|
||||
$page->assertSee('Login');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Smoke Testing
|
||||
### Browser Test with Form Interaction
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
<code-snippet name="Browser Test Form Example" lang="php">
|
||||
it('fails login with invalid credentials', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'random@email.com')
|
||||
->fill('password', 'wrongpassword123')
|
||||
->click('Login')
|
||||
->assertSee('These credentials do not match our records');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Visual Regression Testing
|
||||
### Browser API Reference
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `visit('/path')` | Navigate to a page |
|
||||
| `->fill('field', 'value')` | Fill an input by name |
|
||||
| `->click('Button Text')` | Click a button/link by text |
|
||||
| `->assertSee('text')` | Assert visible text |
|
||||
| `->assertDontSee('text')` | Assert text is not visible |
|
||||
| `->assertPathIs('/path')` | Assert current URL path |
|
||||
| `->assertSeeIn('.selector', 'text')` | Assert text in element |
|
||||
| `->screenshot()` | Capture screenshot |
|
||||
| `->debug()` | Pause test, keep browser open |
|
||||
| `->wait(seconds)` | Wait N seconds |
|
||||
|
||||
### Test Sharding
|
||||
### Debugging
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
- Screenshots auto-saved to `tests/Browser/Screenshots/` on failure
|
||||
- `->debug()` pauses and keeps browser open (press Enter to continue)
|
||||
- `->screenshot()` captures state at any point
|
||||
- `--headed` flag shows browser, `--debug` pauses on failure
|
||||
|
||||
### Architecture Testing
|
||||
## SQLite Testing Setup
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
v4 tests use SQLite :memory: instead of PostgreSQL. Schema loaded from `database/schema/testing-schema.sql`.
|
||||
|
||||
### Regenerating the Schema
|
||||
|
||||
When migrations change, regenerate from the running PostgreSQL database:
|
||||
|
||||
```bash
|
||||
docker exec coolify php artisan schema:generate-testing
|
||||
```
|
||||
|
||||
## Architecture Testing
|
||||
|
||||
<code-snippet name="Architecture Test Example" lang="php">
|
||||
|
||||
|
|
@ -171,4 +187,7 @@ arch('controllers')
|
|||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- **Browser tests: forgetting `InstanceSettings::create(['id' => 0])` — most pages crash without it**
|
||||
- **Browser tests: forgetting `RefreshDatabase` — SQLite :memory: starts empty**
|
||||
- **Browser tests: views with bare `function` declarations crash on second request — wrap with `function_exists()` guard**
|
||||
|
|
|
|||
15
.env.testing
Normal file
15
.env.testing
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
APP_ENV=testing
|
||||
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
|
||||
APP_DEBUG=true
|
||||
|
||||
DB_CONNECTION=testing
|
||||
|
||||
CACHE_DRIVER=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
MAIL_MAILER=array
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
|
||||
SELF_HOSTED=true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -39,3 +39,5 @@ docker/coolify-realtime/node_modules
|
|||
CHANGELOG.md
|
||||
/.workspaces
|
||||
/.claude/settings.local.json
|
||||
tests/Browser/Screenshots
|
||||
tests/v4/Browser/Screenshots
|
||||
|
|
|
|||
|
|
@ -112,12 +112,52 @@ class StartDatabaseProxy
|
|||
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
|
||||
$nginxconf_base64 = base64_encode($nginxconf);
|
||||
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
|
||||
instant_remote_process([
|
||||
"mkdir -p $configuration_dir",
|
||||
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
|
||||
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
|
||||
"docker compose --project-directory {$configuration_dir} pull",
|
||||
"docker compose --project-directory {$configuration_dir} up -d",
|
||||
], $server);
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
"mkdir -p $configuration_dir",
|
||||
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
|
||||
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
|
||||
"docker compose --project-directory {$configuration_dir} pull",
|
||||
"docker compose --project-directory {$configuration_dir} up -d",
|
||||
], $server);
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($this->isNonTransientError($e->getMessage())) {
|
||||
$database->update(['is_public' => false]);
|
||||
|
||||
$team = data_get($database, 'environment.project.team')
|
||||
?? data_get($database, 'service.environment.project.team');
|
||||
|
||||
$team?->notify(
|
||||
new \App\Notifications\Container\ContainerRestarted(
|
||||
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
|
||||
$server,
|
||||
)
|
||||
);
|
||||
|
||||
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function isNonTransientError(string $message): bool
|
||||
{
|
||||
$nonTransientPatterns = [
|
||||
'port is already allocated',
|
||||
'address already in use',
|
||||
'Bind for',
|
||||
];
|
||||
|
||||
foreach ($nonTransientPatterns as $pattern) {
|
||||
if (str_contains($message, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ class OpenApi extends Command
|
|||
echo $process->output();
|
||||
|
||||
$yaml = file_get_contents('openapi.yaml');
|
||||
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT);
|
||||
|
||||
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
|
||||
file_put_contents('openapi.json', $json);
|
||||
echo "Converted OpenAPI YAML to JSON.\n";
|
||||
}
|
||||
|
|
|
|||
222
app/Console/Commands/GenerateTestingSchema.php
Normal file
222
app/Console/Commands/GenerateTestingSchema.php
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GenerateTestingSchema extends Command
|
||||
{
|
||||
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
|
||||
|
||||
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
|
||||
|
||||
private array $typeMap = [
|
||||
'/\bbigint\b/' => 'INTEGER',
|
||||
'/\binteger\b/' => 'INTEGER',
|
||||
'/\bsmallint\b/' => 'INTEGER',
|
||||
'/\bboolean\b/' => 'INTEGER',
|
||||
'/character varying\(\d+\)/' => 'TEXT',
|
||||
'/timestamp\(\d+\) without time zone/' => 'TEXT',
|
||||
'/timestamp\(\d+\) with time zone/' => 'TEXT',
|
||||
'/\bjsonb\b/' => 'TEXT',
|
||||
'/\bjson\b/' => 'TEXT',
|
||||
'/\buuid\b/' => 'TEXT',
|
||||
'/double precision/' => 'REAL',
|
||||
'/numeric\(\d+,\d+\)/' => 'REAL',
|
||||
'/\bdate\b/' => 'TEXT',
|
||||
];
|
||||
|
||||
private array $castRemovals = [
|
||||
'::character varying',
|
||||
'::text',
|
||||
'::integer',
|
||||
'::boolean',
|
||||
'::timestamp without time zone',
|
||||
'::timestamp with time zone',
|
||||
'::numeric',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$connection = $this->option('connection');
|
||||
|
||||
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
|
||||
$this->error("Connection '{$connection}' is not PostgreSQL.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Reading schema from PostgreSQL...');
|
||||
|
||||
$tables = $this->getTables($connection);
|
||||
$lastMigration = DB::connection($connection)
|
||||
->table('migrations')
|
||||
->orderByDesc('id')
|
||||
->value('migration');
|
||||
|
||||
$output = [];
|
||||
$output[] = '-- Generated by: php artisan schema:generate-testing';
|
||||
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
|
||||
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
|
||||
$output[] = '';
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$columns = $this->getColumns($connection, $table);
|
||||
$output[] = $this->generateCreateTable($table, $columns);
|
||||
}
|
||||
|
||||
$indexes = $this->getIndexes($connection, $tables);
|
||||
foreach ($indexes as $index) {
|
||||
$output[] = $index;
|
||||
}
|
||||
|
||||
$output[] = '';
|
||||
$output[] = '-- Migration records';
|
||||
|
||||
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
|
||||
foreach ($migrations as $m) {
|
||||
$migration = str_replace("'", "''", $m->migration);
|
||||
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
|
||||
}
|
||||
|
||||
$path = database_path('schema/testing-schema.sql');
|
||||
|
||||
if (! is_dir(dirname($path))) {
|
||||
mkdir(dirname($path), 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, implode("\n", $output)."\n");
|
||||
|
||||
$this->info("Schema written to {$path}");
|
||||
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTables(string $connection): array
|
||||
{
|
||||
return collect(DB::connection($connection)->select(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
||||
))->pluck('tablename')->toArray();
|
||||
}
|
||||
|
||||
private function getColumns(string $connection, string $table): array
|
||||
{
|
||||
return DB::connection($connection)->select(
|
||||
"SELECT column_name, data_type, character_maximum_length, column_default,
|
||||
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = ?
|
||||
ORDER BY ordinal_position",
|
||||
[$table]
|
||||
);
|
||||
}
|
||||
|
||||
private function generateCreateTable(string $table, array $columns): string
|
||||
{
|
||||
$lines = [];
|
||||
|
||||
foreach ($columns as $col) {
|
||||
$lines[] = ' '.$this->generateColumnDef($table, $col);
|
||||
}
|
||||
|
||||
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
|
||||
}
|
||||
|
||||
private function generateColumnDef(string $table, object $col): string
|
||||
{
|
||||
$name = $col->column_name;
|
||||
$sqliteType = $this->convertType($col);
|
||||
|
||||
// Auto-increment primary key for id columns
|
||||
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
|
||||
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
|
||||
}
|
||||
|
||||
$parts = ["\"{$name}\"", $sqliteType];
|
||||
|
||||
// Default value
|
||||
$default = $col->column_default;
|
||||
if ($default !== null && ! str_contains($default, 'nextval')) {
|
||||
$default = $this->cleanDefault($default);
|
||||
$parts[] = "DEFAULT {$default}";
|
||||
}
|
||||
|
||||
// NOT NULL
|
||||
if ($col->is_nullable === 'NO') {
|
||||
$parts[] = 'NOT NULL';
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function convertType(object $col): string
|
||||
{
|
||||
$pgType = $col->data_type;
|
||||
|
||||
return match (true) {
|
||||
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
|
||||
$pgType === 'boolean' => 'INTEGER',
|
||||
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
|
||||
str_contains($pgType, 'timestamp') => 'TEXT',
|
||||
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
|
||||
$pgType === 'uuid' => 'TEXT',
|
||||
$pgType === 'double precision' => 'REAL',
|
||||
$pgType === 'numeric' => 'REAL',
|
||||
$pgType === 'date' => 'TEXT',
|
||||
default => 'TEXT',
|
||||
};
|
||||
}
|
||||
|
||||
private function cleanDefault(string $default): string
|
||||
{
|
||||
foreach ($this->castRemovals as $cast) {
|
||||
$default = str_replace($cast, '', $default);
|
||||
}
|
||||
|
||||
// Remove array type casts like ::text[]
|
||||
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function getIndexes(string $connection, array $tables): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
$indexes = DB::connection($connection)->select(
|
||||
"SELECT indexname, tablename, indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY tablename, indexname"
|
||||
);
|
||||
|
||||
foreach ($indexes as $idx) {
|
||||
$def = $idx->indexdef;
|
||||
|
||||
// Skip primary key indexes
|
||||
if (str_contains($def, '_pkey')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip PG-specific indexes (GIN, GIST, expression indexes)
|
||||
if (preg_match('/USING (gin|gist)/i', $def)) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($def, '->>') || str_contains($def, '::')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert to SQLite-compatible CREATE INDEX
|
||||
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
|
||||
|
||||
// Extract columns from the index definition
|
||||
if (preg_match('/\((.+)\)$/', $def, $m)) {
|
||||
$cols = $m[1];
|
||||
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@ use App\Rules\ValidGitBranch;
|
|||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Services\DockerImageParser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Spatie\Url\Url;
|
||||
|
|
@ -2919,10 +2919,7 @@ class ApplicationsController extends Controller
|
|||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
|
||||
]
|
||||
ref: '#/components/schemas/EnvironmentVariable'
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
|||
922
app/Http/Controllers/Api/ScheduledTasksController.php
Normal file
922
app/Http/Controllers/Api/ScheduledTasksController.php
Normal file
|
|
@ -0,0 +1,922 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ScheduledTasksController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($task)
|
||||
{
|
||||
$task->makeHidden([
|
||||
'id',
|
||||
'team_id',
|
||||
'application_id',
|
||||
'service_id',
|
||||
]);
|
||||
|
||||
return serializeApiResponse($task);
|
||||
}
|
||||
|
||||
private function resolveApplication(Request $request, int $teamId): ?Application
|
||||
{
|
||||
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function resolveService(Request $request, int $teamId): ?Service
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$tasks = $resource->scheduled_tasks->map(function ($task) {
|
||||
return $this->removeSensitiveData($task);
|
||||
});
|
||||
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (! validate_cron_expression($request->frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
$task = new ScheduledTask;
|
||||
$task->name = $request->name;
|
||||
$task->command = $request->command;
|
||||
$task->frequency = $request->frequency;
|
||||
$task->container = $request->container;
|
||||
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
|
||||
$task->enabled = $request->has('enabled') ? $request->enabled : true;
|
||||
$task->team_id = $teamId;
|
||||
|
||||
if ($resource instanceof Application) {
|
||||
$task->application_id = $resource->id;
|
||||
} elseif ($resource instanceof Service) {
|
||||
$task->service_id = $resource->id;
|
||||
}
|
||||
|
||||
$task->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 201);
|
||||
}
|
||||
|
||||
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
if ($request->all() === []) {
|
||||
return response()->json(['message' => 'At least one field must be provided.'], 422);
|
||||
}
|
||||
|
||||
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
'command' => 'string',
|
||||
'frequency' => 'string',
|
||||
'container' => 'string|nullable',
|
||||
'timeout' => 'integer|min:1',
|
||||
'enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$task->update($request->only($allowedFields));
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 200);
|
||||
}
|
||||
|
||||
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
|
||||
if (! $deleted) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Scheduled task deleted.']);
|
||||
}
|
||||
|
||||
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$executions = $task->executions()->get()->map(function ($execution) {
|
||||
$execution->makeHidden(['id', 'scheduled_task_id']);
|
||||
|
||||
return serializeApiResponse($execution);
|
||||
});
|
||||
|
||||
return response()->json($executions);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tasks',
|
||||
description: 'List all scheduled tasks for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks',
|
||||
operationId: 'list-scheduled-tasks-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all scheduled tasks for an application.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->listTasks($application);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Task',
|
||||
description: 'Create a new scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks',
|
||||
operationId: 'create-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'command', 'frequency'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Scheduled task created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->createTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Task',
|
||||
description: 'Update a scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'update-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->updateTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Task',
|
||||
description: 'Delete a scheduled task for an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'delete-scheduled-task-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->deleteTask($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Executions',
|
||||
description: 'List all executions for a scheduled task on an application.',
|
||||
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
|
||||
operationId: 'list-scheduled-task-executions-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all executions for a scheduled task.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$application = $this->resolveApplication($request, $teamId);
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->getExecutions($request, $application);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tasks',
|
||||
description: 'List all scheduled tasks for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks',
|
||||
operationId: 'list-scheduled-tasks-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all scheduled tasks for a service.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->listTasks($service);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Task',
|
||||
description: 'Create a new scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks',
|
||||
operationId: 'create-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
required: ['name', 'command', 'frequency'],
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Scheduled task created.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->createTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update Task',
|
||||
description: 'Update a scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'update-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
description: 'Scheduled task data',
|
||||
required: true,
|
||||
content: new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task updated.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->updateTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Task',
|
||||
description: 'Delete a scheduled task for a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
|
||||
operationId: 'delete-scheduled-task-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Scheduled task deleted.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->deleteTask($request, $service);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Executions',
|
||||
description: 'List all executions for a scheduled task on a service.',
|
||||
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
|
||||
operationId: 'list-scheduled-task-executions-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Scheduled Tasks'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'task_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the scheduled task.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
)
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get all executions for a scheduled task.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
return $this->getExecutions($request, $service);
|
||||
}
|
||||
}
|
||||
|
|
@ -519,9 +519,13 @@ class ServersController extends Controller
|
|||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$allServers = ModelsServer::whereIp($request->ip)->get();
|
||||
if ($allServers->count() > 0) {
|
||||
return response()->json(['message' => 'Server with this IP already exists.'], 400);
|
||||
$foundServer = ModelsServer::whereIp($request->ip)->first();
|
||||
if ($foundServer) {
|
||||
if ($foundServer->team_id === $teamId) {
|
||||
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
|
||||
}
|
||||
|
||||
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
|
||||
|
|
|
|||
|
|
@ -1141,10 +1141,7 @@ class ServicesController extends Controller
|
|||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
|
||||
]
|
||||
ref: '#/components/schemas/EnvironmentVariable'
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
@ -1265,10 +1262,8 @@ class ServicesController extends Controller
|
|||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'message' => ['type' => 'string', 'example' => 'Environment variables updated.'],
|
||||
]
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
|
||||
)
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
$serviceId = $labels->get('coolify.serviceId');
|
||||
$subType = $labels->get('coolify.service.subType');
|
||||
$subId = $labels->get('coolify.service.subId');
|
||||
if (empty($subId)) {
|
||||
continue;
|
||||
}
|
||||
if ($subType === 'application') {
|
||||
$this->foundServiceApplicationIds->push($subId);
|
||||
// Store container status for aggregation
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ class ScheduledJobManager implements ShouldQueue
|
|||
*/
|
||||
private ?Carbon $executionTime = null;
|
||||
|
||||
private int $dispatchedCount = 0;
|
||||
|
||||
private int $skippedCount = 0;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
|
|
@ -61,6 +65,12 @@ class ScheduledJobManager implements ShouldQueue
|
|||
{
|
||||
// Freeze the execution time at the start of the job
|
||||
$this->executionTime = Carbon::now();
|
||||
$this->dispatchedCount = 0;
|
||||
$this->skippedCount = 0;
|
||||
|
||||
Log::channel('scheduled')->info('ScheduledJobManager started', [
|
||||
'execution_time' => $this->executionTime->toIso8601String(),
|
||||
]);
|
||||
|
||||
// Process backups - don't let failures stop task processing
|
||||
try {
|
||||
|
|
@ -91,6 +101,13 @@ class ScheduledJobManager implements ShouldQueue
|
|||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
|
||||
Log::channel('scheduled')->info('ScheduledJobManager completed', [
|
||||
'execution_time' => $this->executionTime->toIso8601String(),
|
||||
'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime),
|
||||
'dispatched' => $this->dispatchedCount,
|
||||
'skipped' => $this->skippedCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processScheduledBackups(): void
|
||||
|
|
@ -101,8 +118,16 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
foreach ($backups as $backup) {
|
||||
try {
|
||||
// Apply the same filtering logic as the original
|
||||
if (! $this->shouldProcessBackup($backup)) {
|
||||
$skipReason = $this->getBackupSkipReason($backup);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('backup', $skipReason, [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +145,14 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||
DatabaseBackupJob::dispatch($backup);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Backup dispatched', [
|
||||
'backup_id' => $backup->id,
|
||||
'database_id' => $backup->database_id,
|
||||
'database_type' => $backup->database_type,
|
||||
'team_id' => $backup->team_id ?? null,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing backup', [
|
||||
|
|
@ -138,7 +171,15 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
if (! $this->shouldProcessTask($task)) {
|
||||
$skipReason = $this->getTaskSkipReason($task);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('task', $skipReason, [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $task->server()?->team_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +197,13 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
if ($this->shouldRunNow($frequency, $serverTimezone)) {
|
||||
ScheduledTaskJob::dispatch($task);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Task dispatched', [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
'team_id' => $server->team_id,
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing task', [
|
||||
|
|
@ -166,33 +214,33 @@ class ScheduledJobManager implements ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
|
||||
private function getBackupSkipReason(ScheduledDatabaseBackup $backup): ?string
|
||||
{
|
||||
if (blank(data_get($backup, 'database'))) {
|
||||
$backup->delete();
|
||||
|
||||
return false;
|
||||
return 'database_deleted';
|
||||
}
|
||||
|
||||
$server = $backup->server();
|
||||
if (blank($server)) {
|
||||
$backup->delete();
|
||||
|
||||
return false;
|
||||
return 'server_deleted';
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
return false;
|
||||
return 'server_not_functional';
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
return false;
|
||||
return 'subscription_unpaid';
|
||||
}
|
||||
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function shouldProcessTask(ScheduledTask $task): bool
|
||||
private function getTaskSkipReason(ScheduledTask $task): ?string
|
||||
{
|
||||
$service = $task->service;
|
||||
$application = $task->application;
|
||||
|
|
@ -201,32 +249,32 @@ class ScheduledJobManager implements ShouldQueue
|
|||
if (blank($server)) {
|
||||
$task->delete();
|
||||
|
||||
return false;
|
||||
return 'server_deleted';
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
return false;
|
||||
return 'server_not_functional';
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
return false;
|
||||
return 'subscription_unpaid';
|
||||
}
|
||||
|
||||
if (! $service && ! $application) {
|
||||
$task->delete();
|
||||
|
||||
return false;
|
||||
return 'resource_deleted';
|
||||
}
|
||||
|
||||
if ($application && str($application->status)->contains('running') === false) {
|
||||
return false;
|
||||
return 'application_not_running';
|
||||
}
|
||||
|
||||
if ($service && str($service->status)->contains('running') === false) {
|
||||
return false;
|
||||
return 'service_not_running';
|
||||
}
|
||||
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function shouldRunNow(string $frequency, string $timezone): bool
|
||||
|
|
@ -248,7 +296,15 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
if (! $this->shouldProcessDockerCleanup($server)) {
|
||||
$skipReason = $this->getDockerCleanupSkipReason($server);
|
||||
if ($skipReason !== null) {
|
||||
$this->skippedCount++;
|
||||
$this->logSkip('docker_cleanup', $skipReason, [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -270,6 +326,12 @@ class ScheduledJobManager implements ShouldQueue
|
|||
$server->settings->delete_unused_volumes,
|
||||
$server->settings->delete_unused_networks
|
||||
);
|
||||
$this->dispatchedCount++;
|
||||
Log::channel('scheduled')->info('Docker cleanup dispatched', [
|
||||
'server_id' => $server->id,
|
||||
'server_name' => $server->name,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
|
||||
|
|
@ -296,19 +358,28 @@ class ScheduledJobManager implements ShouldQueue
|
|||
return $query->get();
|
||||
}
|
||||
|
||||
private function shouldProcessDockerCleanup(Server $server): bool
|
||||
private function getDockerCleanupSkipReason(Server $server): ?string
|
||||
{
|
||||
if (! $server->isFunctional()) {
|
||||
return false;
|
||||
return 'server_not_functional';
|
||||
}
|
||||
|
||||
// In cloud, check subscription status (except team 0)
|
||||
if (isCloud() && $server->team_id !== 0) {
|
||||
if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) {
|
||||
return false;
|
||||
return 'subscription_unpaid';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
private function logSkip(string $type, string $reason, array $context = []): void
|
||||
{
|
||||
Log::channel('scheduled')->info(ucfirst(str_replace('_', ' ', $type)).' skipped', array_merge([
|
||||
'type' => $type,
|
||||
'skip_reason' => $reason,
|
||||
'execution_time' => $this->executionTime?->toIso8601String(),
|
||||
], $context));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
|||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -33,6 +34,19 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public function __construct(public Server $server) {}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -101,12 +101,31 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
|
|||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerConnectionCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHetznerStatus(): void
|
||||
{
|
||||
$status = null;
|
||||
|
||||
try {
|
||||
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
|
||||
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Horizon\Contracts\Silenced;
|
||||
|
||||
|
|
@ -28,6 +29,19 @@ class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||
|
||||
public function __construct(public Server $server, public int|string|null $percentage = null) {}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
|
||||
Log::warning('ServerStorageCheckJob timed out', [
|
||||
'server_id' => $this->server->id,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
// Delete the queue job so it doesn't appear in Horizon's failed list.
|
||||
$this->job?->delete();
|
||||
}
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,11 @@ class Index extends Component
|
|||
$this->privateKey = formatPrivateKey($this->privateKey);
|
||||
$foundServer = Server::whereIp($this->remoteServerHost)->first();
|
||||
if ($foundServer) {
|
||||
return $this->dispatch('error', 'IP address is already in use by another team.');
|
||||
if ($foundServer->team_id === currentTeam()->id) {
|
||||
return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
|
||||
}
|
||||
|
||||
return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
|
||||
}
|
||||
$this->createdServer = Server::create([
|
||||
'name' => $this->remoteServerName,
|
||||
|
|
|
|||
|
|
@ -1495,6 +1495,7 @@ class GlobalSearch extends Component
|
|||
'type' => 'one-click-service-'.$serviceKey,
|
||||
'category' => 'Services',
|
||||
'resourceType' => 'service',
|
||||
'logo' => data_get($service, 'logo'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ class GithubPrivateRepository extends Component
|
|||
$this->github_apps = GithubApp::private();
|
||||
}
|
||||
|
||||
public function updatedSelectedRepositoryId(): void
|
||||
{
|
||||
$this->loadBranches();
|
||||
}
|
||||
|
||||
public function updatedBuildPack()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
|
|
|
|||
|
|
@ -97,10 +97,13 @@ class ByIp extends Component
|
|||
$this->validate();
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
if (Server::where('team_id', currentTeam()->id)
|
||||
->where('ip', $this->ip)
|
||||
->exists()) {
|
||||
return $this->dispatch('error', 'This IP/Domain is already in use by another server in your team.');
|
||||
$foundServer = Server::whereIp($this->ip)->first();
|
||||
if ($foundServer) {
|
||||
if ($foundServer->team_id === currentTeam()->id) {
|
||||
return $this->dispatch('error', 'A server with this IP/Domain already exists in your team.');
|
||||
}
|
||||
|
||||
return $this->dispatch('error', 'A server with this IP/Domain is already in use by another team.');
|
||||
}
|
||||
|
||||
if (is_null($this->private_key_id)) {
|
||||
|
|
|
|||
|
|
@ -189,12 +189,16 @@ class Show extends Component
|
|||
$this->validate();
|
||||
|
||||
$this->authorize('update', $this->server);
|
||||
if (Server::where('team_id', currentTeam()->id)
|
||||
->where('ip', $this->ip)
|
||||
$foundServer = Server::where('ip', $this->ip)
|
||||
->where('id', '!=', $this->server->id)
|
||||
->exists()) {
|
||||
->first();
|
||||
if ($foundServer) {
|
||||
$this->ip = $this->server->ip;
|
||||
throw new \Exception('This IP/Domain is already in use by another server in your team.');
|
||||
if ($foundServer->team_id === currentTeam()->id) {
|
||||
throw new \Exception('A server with this IP/Domain already exists in your team.');
|
||||
}
|
||||
|
||||
throw new \Exception('A server with this IP/Domain is already in use by another team.');
|
||||
}
|
||||
|
||||
$this->server->name = $this->name;
|
||||
|
|
|
|||
211
app/Livewire/Settings/ScheduledJobs.php
Normal file
211
app/Livewire/Settings/ScheduledJobs.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
|
||||
class ScheduledJobs extends Component
|
||||
{
|
||||
public string $filterType = 'all';
|
||||
|
||||
public string $filterDate = 'last_24h';
|
||||
|
||||
protected Collection $executions;
|
||||
|
||||
protected Collection $skipLogs;
|
||||
|
||||
protected Collection $managerRuns;
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->executions = collect();
|
||||
$this->skipLogs = collect();
|
||||
$this->managerRuns = collect();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
redirect()->route('dashboard');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedFilterType(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedFilterDate(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.scheduled-jobs', [
|
||||
'executions' => $this->executions,
|
||||
'skipLogs' => $this->skipLogs,
|
||||
'managerRuns' => $this->managerRuns,
|
||||
]);
|
||||
}
|
||||
|
||||
private function loadData(?int $teamId = null): void
|
||||
{
|
||||
$this->executions = $this->getExecutions($teamId);
|
||||
|
||||
$parser = new SchedulerLogParser;
|
||||
$this->skipLogs = $parser->getRecentSkips(50, $teamId);
|
||||
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
|
||||
}
|
||||
|
||||
private function getExecutions(?int $teamId = null): Collection
|
||||
{
|
||||
$dateFrom = $this->getDateFrom();
|
||||
|
||||
$backups = collect();
|
||||
$tasks = collect();
|
||||
$cleanups = collect();
|
||||
|
||||
if ($this->filterType === 'all' || $this->filterType === 'backup') {
|
||||
$backups = $this->getBackupExecutions($dateFrom, $teamId);
|
||||
}
|
||||
|
||||
if ($this->filterType === 'all' || $this->filterType === 'task') {
|
||||
$tasks = $this->getTaskExecutions($dateFrom, $teamId);
|
||||
}
|
||||
|
||||
if ($this->filterType === 'all' || $this->filterType === 'cleanup') {
|
||||
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
|
||||
}
|
||||
|
||||
return $backups->concat($tasks)->concat($cleanups)
|
||||
->sortByDesc('created_at')
|
||||
->values()
|
||||
->take(100);
|
||||
}
|
||||
|
||||
private function getBackupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
|
||||
{
|
||||
$query = ScheduledDatabaseBackupExecution::with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.team'])
|
||||
->where('status', 'failed')
|
||||
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
|
||||
->when($teamId, fn ($q) => $q->whereRelation('scheduledDatabaseBackup.team', 'id', $teamId))
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return $query->map(function ($execution) {
|
||||
$backup = $execution->scheduledDatabaseBackup;
|
||||
$database = $backup?->database;
|
||||
$server = $backup?->server();
|
||||
|
||||
return [
|
||||
'id' => $execution->id,
|
||||
'type' => 'backup',
|
||||
'status' => $execution->status ?? 'unknown',
|
||||
'resource_name' => $database?->name ?? 'Deleted database',
|
||||
'resource_type' => $database ? class_basename($database) : null,
|
||||
'server_name' => $server?->name ?? 'Unknown',
|
||||
'server_id' => $server?->id,
|
||||
'team_id' => $backup?->team_id,
|
||||
'created_at' => $execution->created_at,
|
||||
'finished_at' => $execution->updated_at,
|
||||
'message' => $execution->message,
|
||||
'size' => $execution->size ?? null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function getTaskExecutions(?Carbon $dateFrom, ?int $teamId): Collection
|
||||
{
|
||||
$query = ScheduledTaskExecution::with(['scheduledTask.application', 'scheduledTask.service'])
|
||||
->where('status', 'failed')
|
||||
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
|
||||
->when($teamId, function ($q) use ($teamId) {
|
||||
$q->where(function ($sub) use ($teamId) {
|
||||
$sub->whereRelation('scheduledTask.application.environment.project.team', 'id', $teamId)
|
||||
->orWhereRelation('scheduledTask.service.environment.project.team', 'id', $teamId);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return $query->map(function ($execution) {
|
||||
$task = $execution->scheduledTask;
|
||||
$resource = $task?->application ?? $task?->service;
|
||||
$server = $task?->server();
|
||||
$teamId = $server?->team_id;
|
||||
|
||||
return [
|
||||
'id' => $execution->id,
|
||||
'type' => 'task',
|
||||
'status' => $execution->status ?? 'unknown',
|
||||
'resource_name' => $task?->name ?? 'Deleted task',
|
||||
'resource_type' => $resource ? class_basename($resource) : null,
|
||||
'server_name' => $server?->name ?? 'Unknown',
|
||||
'server_id' => $server?->id,
|
||||
'team_id' => $teamId,
|
||||
'created_at' => $execution->created_at,
|
||||
'finished_at' => $execution->finished_at,
|
||||
'message' => $execution->message,
|
||||
'size' => null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function getCleanupExecutions(?Carbon $dateFrom, ?int $teamId): Collection
|
||||
{
|
||||
$query = DockerCleanupExecution::with(['server'])
|
||||
->where('status', 'failed')
|
||||
->when($dateFrom, fn ($q) => $q->where('created_at', '>=', $dateFrom))
|
||||
->when($teamId, fn ($q) => $q->whereRelation('server', 'team_id', $teamId))
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
return $query->map(function ($execution) {
|
||||
$server = $execution->server;
|
||||
|
||||
return [
|
||||
'id' => $execution->id,
|
||||
'type' => 'cleanup',
|
||||
'status' => $execution->status ?? 'unknown',
|
||||
'resource_name' => $server?->name ?? 'Deleted server',
|
||||
'resource_type' => 'Server',
|
||||
'server_name' => $server?->name ?? 'Unknown',
|
||||
'server_id' => $server?->id,
|
||||
'team_id' => $server?->team_id,
|
||||
'created_at' => $execution->created_at,
|
||||
'finished_at' => $execution->finished_at ?? $execution->updated_at,
|
||||
'message' => $execution->message,
|
||||
'size' => null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function getDateFrom(): ?Carbon
|
||||
{
|
||||
return match ($this->filterDate) {
|
||||
'last_24h' => now()->subDay(),
|
||||
'last_7d' => now()->subWeek(),
|
||||
'last_30d' => now()->subMonth(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,35 @@ namespace App\Models;
|
|||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Scheduled Task model',
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer', 'description' => 'The unique identifier of the scheduled task in the database.'],
|
||||
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the scheduled task.'],
|
||||
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
|
||||
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
|
||||
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
|
||||
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.'],
|
||||
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was created.'],
|
||||
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'The date and time when the scheduled task was last updated.'],
|
||||
],
|
||||
)]
|
||||
class ScheduledTask extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
public static function ownedByCurrentTeamAPI(int $teamId)
|
||||
{
|
||||
return static::where('team_id', $teamId)->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -3,7 +3,23 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Scheduled Task Execution model',
|
||||
type: 'object',
|
||||
properties: [
|
||||
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the execution.'],
|
||||
'status' => ['type' => 'string', 'enum' => ['success', 'failed', 'running'], 'description' => 'The status of the execution.'],
|
||||
'message' => ['type' => 'string', 'nullable' => true, 'description' => 'The output message of the execution.'],
|
||||
'retry_count' => ['type' => 'integer', 'description' => 'The number of retries.'],
|
||||
'duration' => ['type' => 'number', 'nullable' => true, 'description' => 'Duration in seconds.'],
|
||||
'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution started.'],
|
||||
'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'When the execution finished.'],
|
||||
'created_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was created.'],
|
||||
'updated_at' => ['type' => 'string', 'format' => 'date-time', 'description' => 'When the record was last updated.'],
|
||||
],
|
||||
)]
|
||||
class ScheduledTaskExecution extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
|
|
|||
188
app/Services/SchedulerLogParser.php
Normal file
188
app/Services/SchedulerLogParser.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class SchedulerLogParser
|
||||
{
|
||||
/**
|
||||
* Get recent skip events from the scheduled log files.
|
||||
*
|
||||
* @return Collection<int, array{timestamp: string, type: string, reason: string, team_id: ?int, context: array}>
|
||||
*/
|
||||
public function getRecentSkips(int $limit = 100, ?int $teamId = null): Collection
|
||||
{
|
||||
$logFiles = $this->getLogFiles();
|
||||
|
||||
$skips = collect();
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
$lines = $this->readLastLines($logFile, 2000);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$entry = $this->parseLogLine($line);
|
||||
if ($entry === null || ! isset($entry['context']['skip_reason'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($teamId !== null && ($entry['context']['team_id'] ?? null) !== $teamId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skips->push([
|
||||
'timestamp' => $entry['timestamp'],
|
||||
'type' => $entry['context']['type'] ?? 'unknown',
|
||||
'reason' => $entry['context']['skip_reason'],
|
||||
'team_id' => $entry['context']['team_id'] ?? null,
|
||||
'context' => $entry['context'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $skips->sortByDesc('timestamp')->values()->take($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent manager execution logs (start/complete events).
|
||||
*
|
||||
* @return Collection<int, array{timestamp: string, message: string, duration_ms: ?int, dispatched: ?int, skipped: ?int}>
|
||||
*/
|
||||
public function getRecentRuns(int $limit = 60, ?int $teamId = null): Collection
|
||||
{
|
||||
$logFiles = $this->getLogFiles();
|
||||
|
||||
$runs = collect();
|
||||
|
||||
foreach ($logFiles as $logFile) {
|
||||
$lines = $this->readLastLines($logFile, 2000);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$entry = $this->parseLogLine($line);
|
||||
if ($entry === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains($entry['message'], 'ScheduledJobManager')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$runs->push([
|
||||
'timestamp' => $entry['timestamp'],
|
||||
'message' => $entry['message'],
|
||||
'duration_ms' => $entry['context']['duration_ms'] ?? null,
|
||||
'dispatched' => $entry['context']['dispatched'] ?? null,
|
||||
'skipped' => $entry['context']['skipped'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $runs->sortByDesc('timestamp')->values()->take($limit);
|
||||
}
|
||||
|
||||
private function getLogFiles(): array
|
||||
{
|
||||
$logDir = storage_path('logs');
|
||||
if (! File::isDirectory($logDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = File::glob($logDir.'/scheduled-*.log');
|
||||
|
||||
// Sort by modification time, newest first
|
||||
usort($files, fn ($a, $b) => filemtime($b) - filemtime($a));
|
||||
|
||||
// Only check last 3 days of logs
|
||||
return array_slice($files, 0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{timestamp: string, level: string, message: string, context: array}|null
|
||||
*/
|
||||
private function parseLogLine(string $line): ?array
|
||||
{
|
||||
// Laravel daily log format: [2024-01-15 10:30:00] production.INFO: Message {"key":"value"}
|
||||
if (! preg_match('/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.(\w+): (.+)$/', $line, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = $matches[1];
|
||||
$level = $matches[2];
|
||||
$rest = $matches[3];
|
||||
|
||||
// Extract JSON context if present
|
||||
$context = [];
|
||||
if (preg_match('/^(.+?)\s+(\{.+\})\s*$/', $rest, $contextMatches)) {
|
||||
$message = $contextMatches[1];
|
||||
$decoded = json_decode($contextMatches[2], true);
|
||||
if (is_array($decoded)) {
|
||||
$context = $decoded;
|
||||
}
|
||||
} else {
|
||||
$message = $rest;
|
||||
}
|
||||
|
||||
return [
|
||||
'timestamp' => $timestamp,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently read the last N lines of a file.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function readLastLines(string $filePath, int $lines): array
|
||||
{
|
||||
if (! File::exists($filePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fileSize = File::size($filePath);
|
||||
if ($fileSize === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For small files, read the whole thing
|
||||
if ($fileSize < 1024 * 1024) {
|
||||
$content = File::get($filePath);
|
||||
|
||||
return array_filter(explode("\n", $content), fn ($line) => $line !== '');
|
||||
}
|
||||
|
||||
// For large files, read from the end
|
||||
$handle = fopen($filePath, 'r');
|
||||
if ($handle === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$chunkSize = 8192;
|
||||
$buffer = '';
|
||||
$position = $fileSize;
|
||||
|
||||
while ($position > 0 && count($result) < $lines) {
|
||||
$readSize = min($chunkSize, $position);
|
||||
$position -= $readSize;
|
||||
fseek($handle, $position);
|
||||
$buffer = fread($handle, $readSize).$buffer;
|
||||
|
||||
$bufferLines = explode("\n", $buffer);
|
||||
$buffer = array_shift($bufferLines);
|
||||
|
||||
$result = array_merge(array_filter($bufferLines, fn ($line) => $line !== ''), $result);
|
||||
}
|
||||
|
||||
if ($buffer !== '' && count($result) < $lines) {
|
||||
array_unshift($result, $buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return array_slice($result, -$lines);
|
||||
}
|
||||
}
|
||||
|
|
@ -95,9 +95,6 @@ trait SshRetryable
|
|||
if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
|
||||
$delay = $this->calculateRetryDelay($attempt);
|
||||
|
||||
// Track SSH retry event in Sentry
|
||||
$this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
|
||||
|
||||
// Add deployment log if available (for ExecuteRemoteCommand trait)
|
||||
if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
|
||||
$this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
|
||||
|
|
@ -133,42 +130,4 @@ trait SshRetryable
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track SSH retry event in Sentry
|
||||
*/
|
||||
protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
|
||||
{
|
||||
// Only track in production/cloud instances
|
||||
if (isDev() || ! config('constants.sentry.sentry_dsn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app('sentry')->captureMessage(
|
||||
'SSH connection retry triggered',
|
||||
\Sentry\Severity::warning(),
|
||||
[
|
||||
'extra' => [
|
||||
'attempt' => $attempt,
|
||||
'max_retries' => $maxRetries,
|
||||
'delay_seconds' => $delay,
|
||||
'error_message' => $errorMessage,
|
||||
'context' => $context,
|
||||
'retryable_error' => true,
|
||||
],
|
||||
'tags' => [
|
||||
'component' => 'ssh_retry',
|
||||
'error_type' => 'connection_retry',
|
||||
],
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let Sentry tracking errors break the SSH retry flow
|
||||
Log::warning('Failed to track SSH retry event in Sentry', [
|
||||
'error' => $e->getMessage(),
|
||||
'original_attempt' => $attempt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,10 @@ function currentTeam()
|
|||
|
||||
function showBoarding(): bool
|
||||
{
|
||||
if (isDev()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Auth::user()?->isMember()) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"mockery/mockery": "^1.6.12",
|
||||
"nunomaduro/collision": "^8.8.3",
|
||||
"pestphp/pest": "^4.3.2",
|
||||
"pestphp/pest-plugin-browser": "^4.2",
|
||||
"phpstan/phpstan": "^2.1.38",
|
||||
"rector/rector": "^2.3.5",
|
||||
"serversideup/spin": "^3.1.1",
|
||||
|
|
|
|||
1565
composer.lock
generated
1565
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -54,18 +54,10 @@ return [
|
|||
],
|
||||
|
||||
'testing' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DATABASE_TEST_URL'),
|
||||
'host' => env('DB_TEST_HOST', 'postgres'),
|
||||
'port' => env('DB_TEST_PORT', '5432'),
|
||||
'database' => env('DB_TEST_DATABASE', 'coolify_test'),
|
||||
'username' => env('DB_TEST_USERNAME', 'coolify'),
|
||||
'password' => env('DB_TEST_PASSWORD', 'password'),
|
||||
'charset' => 'utf8',
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
'foreign_key_constraints' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
|
|
|||
21
database/factories/ScheduledTaskFactory.php
Normal file
21
database/factories/ScheduledTaskFactory.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ScheduledTaskFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->word(),
|
||||
'command' => 'echo hello',
|
||||
'frequency' => '* * * * *',
|
||||
'timeout' => 300,
|
||||
'enabled' => true,
|
||||
'team_id' => Team::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,10 @@ class AddIndexToActivityLog extends Migration
|
|||
{
|
||||
public function up()
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb');
|
||||
DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)');
|
||||
|
|
@ -18,6 +22,10 @@ class AddIndexToActivityLog extends Migration
|
|||
|
||||
public function down()
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::statement('DROP INDEX IF EXISTS idx_activity_type_uuid');
|
||||
DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE json USING properties::json');
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ return new class extends Migration
|
|||
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->indexes as [$table, $columns, $indexName]) {
|
||||
if (! $this->indexExists($indexName)) {
|
||||
$columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns));
|
||||
|
|
@ -32,6 +36,10 @@ return new class extends Migration
|
|||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() !== 'pgsql') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->indexes as [, , $indexName]) {
|
||||
DB::statement("DROP INDEX IF EXISTS \"{$indexName}\"");
|
||||
}
|
||||
|
|
|
|||
1754
database/schema/testing-schema.sql
Normal file
1754
database/schema/testing-schema.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -26,6 +26,8 @@ services:
|
|||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
- dev_backups_data:/var/www/html/storage/app/backups
|
||||
networks:
|
||||
- coolify
|
||||
postgres:
|
||||
pull_policy: always
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ RUN apk add --no-cache \
|
|||
lsof \
|
||||
vim
|
||||
|
||||
# Install PHP extensions
|
||||
RUN install-php-extensions sockets
|
||||
|
||||
# Configure shell aliases
|
||||
RUN echo "alias ll='ls -al'" >> /etc/profile && \
|
||||
echo "alias a='php artisan'" >> /etc/profile && \
|
||||
|
|
|
|||
829
openapi.json
829
openapi.json
|
|
@ -3063,13 +3063,7 @@
|
|||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Environment variable updated."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
"$ref": "#\/components\/schemas\/EnvironmentVariable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8055,6 +8049,698 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}\/scheduled-tasks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "List Tasks",
|
||||
"description": "List all scheduled tasks for an application.",
|
||||
"operationId": "list-scheduled-tasks-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all scheduled tasks for an application.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Create Task",
|
||||
"description": "Create a new scheduled task for an application.",
|
||||
"operationId": "create-scheduled-task-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Scheduled task data",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"name",
|
||||
"command",
|
||||
"frequency"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scheduled task."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute."
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "The frequency of the scheduled task."
|
||||
},
|
||||
"container": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The container where the command should be executed."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "The timeout of the scheduled task in seconds.",
|
||||
"default": 300
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the scheduled task is enabled.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Scheduled task created.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Delete Task",
|
||||
"description": "Delete a scheduled task for an application.",
|
||||
"operationId": "delete-scheduled-task-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled task deleted.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Scheduled task deleted."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Update Task",
|
||||
"description": "Update a scheduled task for an application.",
|
||||
"operationId": "update-scheduled-task-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Scheduled task data",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scheduled task."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute."
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "The frequency of the scheduled task."
|
||||
},
|
||||
"container": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The container where the command should be executed."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "The timeout of the scheduled task in seconds.",
|
||||
"default": 300
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the scheduled task is enabled.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled task updated.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/applications\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "List Executions",
|
||||
"description": "List all executions for a scheduled task on an application.",
|
||||
"operationId": "list-scheduled-task-executions-by-application-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the application.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all executions for a scheduled task.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTaskExecution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/scheduled-tasks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "List Tasks",
|
||||
"description": "List all scheduled tasks for a service.",
|
||||
"operationId": "list-scheduled-tasks-by-service-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the service.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all scheduled tasks for a service.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Create Task",
|
||||
"description": "Create a new scheduled task for a service.",
|
||||
"operationId": "create-scheduled-task-by-service-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the service.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Scheduled task data",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"name",
|
||||
"command",
|
||||
"frequency"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scheduled task."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute."
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "The frequency of the scheduled task."
|
||||
},
|
||||
"container": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The container where the command should be executed."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "The timeout of the scheduled task in seconds.",
|
||||
"default": 300
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the scheduled task is enabled.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Scheduled task created.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/scheduled-tasks\/{task_uuid}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Delete Task",
|
||||
"description": "Delete a scheduled task for a service.",
|
||||
"operationId": "delete-scheduled-task-by-service-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the service.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled task deleted.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Scheduled task deleted."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "Update Task",
|
||||
"description": "Update a scheduled task for a service.",
|
||||
"operationId": "update-scheduled-task-by-service-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the service.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Scheduled task data",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scheduled task."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute."
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "The frequency of the scheduled task."
|
||||
},
|
||||
"container": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The container where the command should be executed."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "The timeout of the scheduled task in seconds.",
|
||||
"default": 300
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the scheduled task is enabled.",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Scheduled task updated.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"$ref": "#\/components\/responses\/422"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/services\/{uuid}\/scheduled-tasks\/{task_uuid}\/executions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scheduled Tasks"
|
||||
],
|
||||
"summary": "List Executions",
|
||||
"description": "List all executions for a scheduled task on a service.",
|
||||
"operationId": "list-scheduled-task-executions-by-service-uuid",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the service.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task_uuid",
|
||||
"in": "path",
|
||||
"description": "UUID of the scheduled task.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Get all executions for a scheduled task.",
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/ScheduledTaskExecution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/security\/keys": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -9617,13 +10303,7 @@
|
|||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Environment variable updated."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
"$ref": "#\/components\/schemas\/EnvironmentVariable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9721,13 +10401,10 @@
|
|||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"example": "Environment variables updated."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#\/components\/schemas\/EnvironmentVariable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10786,6 +11463,110 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ScheduledTask": {
|
||||
"description": "Scheduled Task model",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": "The unique identifier of the scheduled task in the database."
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "The unique identifier of the scheduled task."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "The flag to indicate if the scheduled task is enabled."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the scheduled task."
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute."
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "The frequency of the scheduled task."
|
||||
},
|
||||
"container": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The container where the command should be executed."
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "The timeout of the scheduled task in seconds."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date and time when the scheduled task was created."
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date and time when the scheduled task was last updated."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ScheduledTaskExecution": {
|
||||
"description": "Scheduled Task Execution model",
|
||||
"properties": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"description": "The unique identifier of the execution."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"failed",
|
||||
"running"
|
||||
],
|
||||
"description": "The status of the execution."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The output message of the execution."
|
||||
},
|
||||
"retry_count": {
|
||||
"type": "integer",
|
||||
"description": "The number of retries."
|
||||
},
|
||||
"duration": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Duration in seconds."
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "When the execution started."
|
||||
},
|
||||
"finished_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "When the execution finished."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the record was created."
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the record was last updated."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Server": {
|
||||
"description": "Server model",
|
||||
"properties": {
|
||||
|
|
@ -11298,6 +12079,10 @@
|
|||
"name": "Resources",
|
||||
"description": "Resources"
|
||||
},
|
||||
{
|
||||
"name": "Scheduled Tasks",
|
||||
"description": "Scheduled Tasks"
|
||||
},
|
||||
{
|
||||
"name": "Private Keys",
|
||||
"description": "Private Keys"
|
||||
|
|
@ -11315,4 +12100,4 @@
|
|||
"description": "Teams"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
569
openapi.yaml
569
openapi.yaml
|
|
@ -1952,9 +1952,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Environment variable updated.' }
|
||||
type: object
|
||||
$ref: '#/components/schemas/EnvironmentVariable'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
|
|
@ -5087,6 +5085,478 @@ paths:
|
|||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}/scheduled-tasks':
|
||||
get:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'List Tasks'
|
||||
description: 'List all scheduled tasks for an application.'
|
||||
operationId: list-scheduled-tasks-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all scheduled tasks for an application.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
post:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Create Task'
|
||||
description: 'Create a new scheduled task for an application.'
|
||||
operationId: create-scheduled-task-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: 'Scheduled task data'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- name
|
||||
- command
|
||||
- frequency
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the scheduled task.'
|
||||
command:
|
||||
type: string
|
||||
description: 'The command to execute.'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'The frequency of the scheduled task.'
|
||||
container:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The container where the command should be executed.'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'The timeout of the scheduled task in seconds.'
|
||||
default: 300
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the scheduled task is enabled.'
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'Scheduled task created.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}/scheduled-tasks/{task_uuid}':
|
||||
delete:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Delete Task'
|
||||
description: 'Delete a scheduled task for an application.'
|
||||
operationId: delete-scheduled-task-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Scheduled task deleted.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Scheduled task deleted.' }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Update Task'
|
||||
description: 'Update a scheduled task for an application.'
|
||||
operationId: update-scheduled-task-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: 'Scheduled task data'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the scheduled task.'
|
||||
command:
|
||||
type: string
|
||||
description: 'The command to execute.'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'The frequency of the scheduled task.'
|
||||
container:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The container where the command should be executed.'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'The timeout of the scheduled task in seconds.'
|
||||
default: 300
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the scheduled task is enabled.'
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Scheduled task updated.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/applications/{uuid}/scheduled-tasks/{task_uuid}/executions':
|
||||
get:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'List Executions'
|
||||
description: 'List all executions for a scheduled task on an application.'
|
||||
operationId: list-scheduled-task-executions-by-application-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the application.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all executions for a scheduled task.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScheduledTaskExecution'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/scheduled-tasks':
|
||||
get:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'List Tasks'
|
||||
description: 'List all scheduled tasks for a service.'
|
||||
operationId: list-scheduled-tasks-by-service-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the service.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all scheduled tasks for a service.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
post:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Create Task'
|
||||
description: 'Create a new scheduled task for a service.'
|
||||
operationId: create-scheduled-task-by-service-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the service.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: 'Scheduled task data'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- name
|
||||
- command
|
||||
- frequency
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the scheduled task.'
|
||||
command:
|
||||
type: string
|
||||
description: 'The command to execute.'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'The frequency of the scheduled task.'
|
||||
container:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The container where the command should be executed.'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'The timeout of the scheduled task in seconds.'
|
||||
default: 300
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the scheduled task is enabled.'
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: 'Scheduled task created.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/scheduled-tasks/{task_uuid}':
|
||||
delete:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Delete Task'
|
||||
description: 'Delete a scheduled task for a service.'
|
||||
operationId: delete-scheduled-task-by-service-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the service.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Scheduled task deleted.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Scheduled task deleted.' }
|
||||
type: object
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
patch:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'Update Task'
|
||||
description: 'Update a scheduled task for a service.'
|
||||
operationId: update-scheduled-task-by-service-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the service.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: 'Scheduled task data'
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the scheduled task.'
|
||||
command:
|
||||
type: string
|
||||
description: 'The command to execute.'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'The frequency of the scheduled task.'
|
||||
container:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The container where the command should be executed.'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'The timeout of the scheduled task in seconds.'
|
||||
default: 300
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the scheduled task is enabled.'
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Scheduled task updated.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScheduledTask'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
$ref: '#/components/responses/422'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/services/{uuid}/scheduled-tasks/{task_uuid}/executions':
|
||||
get:
|
||||
tags:
|
||||
- 'Scheduled Tasks'
|
||||
summary: 'List Executions'
|
||||
description: 'List all executions for a scheduled task on a service.'
|
||||
operationId: list-scheduled-task-executions-by-service-uuid
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
description: 'UUID of the service.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: task_uuid
|
||||
in: path
|
||||
description: 'UUID of the scheduled task.'
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Get all executions for a scheduled task.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScheduledTaskExecution'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/security/keys:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -6027,9 +6497,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Environment variable updated.' }
|
||||
type: object
|
||||
$ref: '#/components/schemas/EnvironmentVariable'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
|
|
@ -6075,9 +6543,9 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
message: { type: string, example: 'Environment variables updated.' }
|
||||
type: object
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EnvironmentVariable'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'400':
|
||||
|
|
@ -6811,6 +7279,86 @@ components:
|
|||
description:
|
||||
type: string
|
||||
type: object
|
||||
ScheduledTask:
|
||||
description: 'Scheduled Task model'
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: 'The unique identifier of the scheduled task in the database.'
|
||||
uuid:
|
||||
type: string
|
||||
description: 'The unique identifier of the scheduled task.'
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 'The flag to indicate if the scheduled task is enabled.'
|
||||
name:
|
||||
type: string
|
||||
description: 'The name of the scheduled task.'
|
||||
command:
|
||||
type: string
|
||||
description: 'The command to execute.'
|
||||
frequency:
|
||||
type: string
|
||||
description: 'The frequency of the scheduled task.'
|
||||
container:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The container where the command should be executed.'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'The timeout of the scheduled task in seconds.'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 'The date and time when the scheduled task was created.'
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 'The date and time when the scheduled task was last updated.'
|
||||
type: object
|
||||
ScheduledTaskExecution:
|
||||
description: 'Scheduled Task Execution model'
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
description: 'The unique identifier of the execution.'
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- failed
|
||||
- running
|
||||
description: 'The status of the execution.'
|
||||
message:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'The output message of the execution.'
|
||||
retry_count:
|
||||
type: integer
|
||||
description: 'The number of retries.'
|
||||
duration:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Duration in seconds.'
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 'When the execution started.'
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 'When the execution finished.'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 'When the record was created.'
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: 'When the record was last updated.'
|
||||
type: object
|
||||
Server:
|
||||
description: 'Server model'
|
||||
properties:
|
||||
|
|
@ -7165,6 +7713,9 @@ tags:
|
|||
-
|
||||
name: Resources
|
||||
description: Resources
|
||||
-
|
||||
name: 'Scheduled Tasks'
|
||||
description: 'Scheduled Tasks'
|
||||
-
|
||||
name: 'Private Keys'
|
||||
description: 'Private Keys'
|
||||
|
|
|
|||
65
package-lock.json
generated
65
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
|||
"@tailwindcss/typography": "0.5.16",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"ioredis": "5.6.1"
|
||||
"ioredis": "5.6.1",
|
||||
"playwright": "^1.58.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
|
|
@ -949,7 +950,8 @@
|
|||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.10",
|
||||
|
|
@ -1402,8 +1404,7 @@
|
|||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -1556,6 +1557,7 @@
|
|||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
|
|
@ -1570,6 +1572,7 @@
|
|||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
|
|
@ -2330,7 +2333,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2338,6 +2340,50 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
@ -2407,7 +2453,6 @@
|
|||
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
|
|
@ -2512,6 +2557,7 @@
|
|||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
|
|
@ -2556,8 +2602,7 @@
|
|||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
|
|
@ -2609,7 +2654,6 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -2709,7 +2753,6 @@
|
|||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
|
|
@ -2732,6 +2775,7 @@
|
|||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
|
@ -2753,6 +2797,7 @@
|
|||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"@tailwindcss/typography": "0.5.16",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"ioredis": "5.6.1"
|
||||
"ioredis": "5.6.1",
|
||||
"playwright": "^1.58.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
phpunit.xml
15
phpunit.xml
|
|
@ -7,17 +7,20 @@
|
|||
<testsuite name="Feature">
|
||||
<directory suffix="Test.php">./tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="v4">
|
||||
<directory suffix="Test.php">./tests/v4</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<coverage/>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_DRIVER" value="array"/>
|
||||
<env name="DB_CONNECTION" value="testing"/>
|
||||
<env name="DB_TEST_DATABASE" value="coolify"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="CACHE_DRIVER" value="array" force="true"/>
|
||||
<env name="DB_CONNECTION" value="testing" force="true"/>
|
||||
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
</php>
|
||||
<source>
|
||||
|
|
|
|||
BIN
public/svgs/spacebot.png
Normal file
BIN
public/svgs/spacebot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
BIN
public/svgs/sure.png
Normal file
BIN
public/svgs/sure.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,7 +1,9 @@
|
|||
<?php
|
||||
function getOldOrLocal($key, $localValue)
|
||||
{
|
||||
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
|
||||
if (! function_exists('getOldOrLocal')) {
|
||||
function getOldOrLocal($key, $localValue)
|
||||
{
|
||||
return old($key) != '' ? old($key) : (app()->environment('local') ? $localValue : '');
|
||||
}
|
||||
}
|
||||
|
||||
$name = getOldOrLocal('name', 'test3 normal user');
|
||||
|
|
|
|||
|
|
@ -99,8 +99,14 @@
|
|||
{{-- Unified Input Container with Tags Inside --}}
|
||||
<div @click="$refs.searchInput.focus()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
|
||||
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 px-2 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text px-1 text-black dark:text-white"
|
||||
:style="focused ? 'box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;' : 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;'"
|
||||
x-init="$watch('focused', () => { if ($root.classList.contains('dark') || document.documentElement.classList.contains('dark')) { $el.style.boxShadow = focused ? 'inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424' : 'inset 4px 0 0 transparent, inset 0 0 0 2px #242424'; } })"
|
||||
:style="(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const accent = isDark ? '#fcd452' : '#6b16ed';
|
||||
const border = isDark ? '#242424' : '#e5e5e5';
|
||||
return focused
|
||||
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
|
||||
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
|
||||
})()"
|
||||
:class="{
|
||||
'opacity-50': {{ $disabled ? 'true' : 'false' }}
|
||||
}" wire:loading.class="opacity-50"
|
||||
|
|
@ -225,8 +231,14 @@
|
|||
{{-- Input Container --}}
|
||||
<div @click="openDropdown()" x-data="{ focused: false }" @focusin="focused = true" @focusout="focused = false"
|
||||
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 bg-white dark:bg-coolgray-100 cursor-text text-black dark:text-white"
|
||||
:style="focused ? 'box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;' : 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;'"
|
||||
x-init="$watch('focused', () => { if ($root.classList.contains('dark') || document.documentElement.classList.contains('dark')) { $el.style.boxShadow = focused ? 'inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424' : 'inset 4px 0 0 transparent, inset 0 0 0 2px #242424'; } })"
|
||||
:style="(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const accent = isDark ? '#fcd452' : '#6b16ed';
|
||||
const border = isDark ? '#242424' : '#e5e5e5';
|
||||
return focused
|
||||
? 'box-shadow: inset 4px 0 0 ' + accent + ', inset 0 0 0 2px ' + border + ';'
|
||||
: 'box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px ' + border + ';';
|
||||
})()"
|
||||
:class="{
|
||||
'opacity-50': {{ $disabled ? 'true' : 'false' }}
|
||||
}" wire:loading.class="opacity-50" wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
href="{{ route('settings.oauth') }}">
|
||||
OAuth
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('settings.scheduled-jobs') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.scheduled-jobs') }}">
|
||||
Scheduled Jobs
|
||||
</a>
|
||||
<div class="flex-1"></div>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -699,15 +699,21 @@
|
|||
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-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-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-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>
|
||||
@if (! empty($item['logo']))
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
||||
<img src="{{ asset($item['logo']) }}" alt="{{ $item['name'] }}" class="w-7 h-7 object-contain">
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-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>
|
||||
@endif
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="font-medium text-neutral-900 dark:text-white truncate">
|
||||
|
|
@ -808,15 +814,22 @@
|
|||
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-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-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-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>
|
||||
<template x-if="item.logo">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center overflow-hidden">
|
||||
<img :src="'/' + item.logo" :alt="item.name" class="w-7 h-7 object-contain">
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!item.logo">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-warning-600 dark:text-warning-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>
|
||||
</template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="font-medium text-neutral-900 dark:text-white truncate"
|
||||
|
|
|
|||
260
resources/views/livewire/settings/scheduled-jobs.blade.php
Normal file
260
resources/views/livewire/settings/scheduled-jobs.blade.php
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Scheduled Job Issues | Coolify
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'executions' }"
|
||||
class="flex flex-col gap-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Scheduled Job Issues</h2>
|
||||
<x-forms.button wire:click="refresh">Refresh</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4">Shows failed executions, skipped jobs, and scheduler health.</div>
|
||||
</div>
|
||||
|
||||
{{-- Tab Buttons --}}
|
||||
<div class="flex flex-row gap-4">
|
||||
<div @class([
|
||||
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
|
||||
])
|
||||
:class="activeTab === 'executions' && 'dark:bg-coollabs bg-coollabs text-white'"
|
||||
@click="activeTab = 'executions'; window.location.hash = 'executions'">
|
||||
Failures ({{ $executions->count() }})
|
||||
</div>
|
||||
<div @class([
|
||||
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
|
||||
])
|
||||
:class="activeTab === 'scheduler-runs' && 'dark:bg-coollabs bg-coollabs text-white'"
|
||||
@click="activeTab = 'scheduler-runs'; window.location.hash = 'scheduler-runs'">
|
||||
Scheduler Runs ({{ $managerRuns->count() }})
|
||||
</div>
|
||||
<div @class([
|
||||
'box-without-bg cursor-pointer dark:bg-coolgray-100 dark:text-white w-full text-center items-center justify-center',
|
||||
])
|
||||
:class="activeTab === 'skipped-jobs' && 'dark:bg-coollabs bg-coollabs text-white'"
|
||||
@click="activeTab = 'skipped-jobs'; window.location.hash = 'skipped-jobs'">
|
||||
Skipped Jobs ({{ $skipLogs->count() }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Executions Tab --}}
|
||||
<div x-show="activeTab === 'executions'" x-cloak>
|
||||
{{-- Filters --}}
|
||||
<div class="flex gap-4 flex-wrap mb-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium">Type</label>
|
||||
<select wire:model.live="filterType"
|
||||
class="w-40 border bg-white dark:bg-coolgray-100 border-gray-300 dark:border-coolgray-400 rounded-md text-sm">
|
||||
<option value="all">All Types</option>
|
||||
<option value="backup">Backups</option>
|
||||
<option value="task">Tasks</option>
|
||||
<option value="cleanup">Docker Cleanup</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium">Time Range</label>
|
||||
<select wire:model.live="filterDate"
|
||||
class="w-40 border bg-white dark:bg-coolgray-100 border-gray-300 dark:border-coolgray-400 rounded-md text-sm">
|
||||
<option value="last_24h">Last 24 Hours</option>
|
||||
<option value="last_7d">Last 7 Days</option>
|
||||
<option value="last_30d">Last 30 Days</option>
|
||||
<option value="all">All Time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Resource</th>
|
||||
<th class="px-4 py-3">Server</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($executions as $execution)
|
||||
<tr wire:key="exec-{{ $execution['type'] }}-{{ $execution['id'] }}"
|
||||
class="border-b border-gray-200 dark:border-coolgray-400 hover:bg-gray-50 dark:hover:bg-coolgray-200">
|
||||
<td class="px-4 py-3">
|
||||
@php
|
||||
$typeLabel = match($execution['type']) {
|
||||
'backup' => 'Backup',
|
||||
'task' => 'Task',
|
||||
'cleanup' => 'Cleanup',
|
||||
default => ucfirst($execution['type']),
|
||||
};
|
||||
$typeBg = match($execution['type']) {
|
||||
'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
'cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
};
|
||||
@endphp
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium {{ $typeBg }}">
|
||||
{{ $typeLabel }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ $execution['resource_name'] }}
|
||||
@if($execution['resource_type'])
|
||||
<span class="text-xs text-gray-500">({{ $execution['resource_type'] }})</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ $execution['server_name'] }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{{ $execution['created_at']->diffForHumans() }}
|
||||
<span class="block text-xs text-gray-500">{{ $execution['created_at']->format('M d H:i') }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
@if($execution['finished_at'] && $execution['created_at'])
|
||||
{{ \Carbon\Carbon::parse($execution['created_at'])->diffInSeconds(\Carbon\Carbon::parse($execution['finished_at'])) }}s
|
||||
@elseif($execution['status'] === 'running')
|
||||
<x-loading class="w-4 h-4" />
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 max-w-xs truncate" title="{{ $execution['message'] }}">
|
||||
{{ \Illuminate\Support\Str::limit($execution['message'], 80) }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
|
||||
No failures found for the selected filters.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Scheduler Runs Tab --}}
|
||||
<div x-show="activeTab === 'scheduler-runs'" x-cloak>
|
||||
<div class="pb-4 text-sm text-gray-500">Shows when the ScheduledJobManager executed. Gaps indicate lock conflicts or missed runs.</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Time</th>
|
||||
<th class="px-4 py-3">Event</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Dispatched</th>
|
||||
<th class="px-4 py-3">Skipped</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($managerRuns as $run)
|
||||
<tr wire:key="run-{{ $loop->index }}"
|
||||
class="border-b border-gray-200 dark:border-coolgray-400">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-xs">{{ $run['timestamp'] }}</td>
|
||||
<td class="px-4 py-2">{{ $run['message'] }}</td>
|
||||
<td class="px-4 py-2">
|
||||
@if($run['duration_ms'] !== null)
|
||||
{{ $run['duration_ms'] }}ms
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2">{{ $run['dispatched'] ?? '-' }}</td>
|
||||
<td class="px-4 py-2">
|
||||
@if(($run['skipped'] ?? 0) > 0)
|
||||
<span class="text-warning">{{ $run['skipped'] }}</span>
|
||||
@else
|
||||
{{ $run['skipped'] ?? '-' }}
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-4 text-center text-gray-500">
|
||||
No scheduler run logs found. Logs appear after the ScheduledJobManager runs.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Skipped Jobs Tab --}}
|
||||
<div x-show="activeTab === 'skipped-jobs'" x-cloak>
|
||||
<div class="pb-4 text-sm text-gray-500">Jobs that were not dispatched because conditions were not met.</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead class="text-xs uppercase bg-gray-50 dark:bg-coolgray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Time</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Reason</th>
|
||||
<th class="px-4 py-3">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($skipLogs as $skip)
|
||||
<tr wire:key="skip-{{ $loop->index }}"
|
||||
class="border-b border-gray-200 dark:border-coolgray-400">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-xs">{{ $skip['timestamp'] }}</td>
|
||||
<td class="px-4 py-2">
|
||||
@php
|
||||
$skipTypeBg = match($skip['type']) {
|
||||
'backup' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
'task' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
'docker_cleanup' => 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
|
||||
default => 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300',
|
||||
};
|
||||
@endphp
|
||||
<span class="px-2 py-1 rounded-md text-xs font-medium {{ $skipTypeBg }}">
|
||||
{{ ucfirst(str_replace('_', ' ', $skip['type'])) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
@php
|
||||
$reasonLabel = match($skip['reason']) {
|
||||
'server_not_functional' => 'Server not functional',
|
||||
'subscription_unpaid' => 'Subscription unpaid',
|
||||
'database_deleted' => 'Database deleted',
|
||||
'server_deleted' => 'Server deleted',
|
||||
'resource_deleted' => 'Resource deleted',
|
||||
'application_not_running' => 'Application not running',
|
||||
'service_not_running' => 'Service not running',
|
||||
default => ucfirst(str_replace('_', ' ', $skip['reason'])),
|
||||
};
|
||||
$reasonBg = match($skip['reason']) {
|
||||
'server_not_functional', 'database_deleted', 'server_deleted', 'resource_deleted' => 'text-red-600 dark:text-red-400',
|
||||
'subscription_unpaid' => 'text-warning',
|
||||
'application_not_running', 'service_not_running' => 'text-orange-600 dark:text-orange-400',
|
||||
default => '',
|
||||
};
|
||||
@endphp
|
||||
<span class="{{ $reasonBg }}">{{ $reasonLabel }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-500">
|
||||
@php
|
||||
$details = collect($skip['context'])
|
||||
->except(['type', 'skip_reason', 'execution_time'])
|
||||
->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v)
|
||||
->implode(', ');
|
||||
@endphp
|
||||
{{ $details }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-4 py-4 text-center text-gray-500">
|
||||
No skipped jobs found. This means all scheduled jobs passed their conditions.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -9,6 +9,7 @@ use App\Http\Controllers\Api\HetznerController;
|
|||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\ResourcesController;
|
||||
use App\Http\Controllers\Api\ScheduledTasksController;
|
||||
use App\Http\Controllers\Api\SecurityController;
|
||||
use App\Http\Controllers\Api\ServersController;
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
|
|
@ -106,7 +107,7 @@ Route::group([
|
|||
|
||||
/**
|
||||
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services.
|
||||
*/
|
||||
*/
|
||||
Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
|
||||
|
|
@ -171,6 +172,18 @@ Route::group([
|
|||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
|
||||
Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']);
|
||||
});
|
||||
|
||||
Route::group([
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ use App\Livewire\Server\Show as ServerShow;
|
|||
use App\Livewire\Server\Swarm as ServerSwarm;
|
||||
use App\Livewire\Settings\Advanced as SettingsAdvanced;
|
||||
use App\Livewire\Settings\Index as SettingsIndex;
|
||||
use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs;
|
||||
use App\Livewire\Settings\Updates as SettingsUpdates;
|
||||
use App\Livewire\SettingsBackup;
|
||||
use App\Livewire\SettingsEmail;
|
||||
|
|
@ -119,6 +120,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
|
||||
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
|
||||
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
|
||||
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
|
||||
|
||||
Route::get('/profile', ProfileIndex::class)->name('profile');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# documentation: https://glitchtip.com
|
||||
# slogan: GlitchTip is a self-hosted, open-source error tracking system.
|
||||
# slogan: GlitchTip is a error tracking system.
|
||||
# category: monitoring
|
||||
# tags: error, tracking, open-source, self-hosted, sentry
|
||||
# tags: error, tracking, sentry
|
||||
# logo: svgs/glitchtip.png
|
||||
# port: 8080
|
||||
# port: 8000
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
|
@ -29,14 +29,14 @@ services:
|
|||
retries: 10
|
||||
|
||||
web:
|
||||
image: glitchtip/glitchtip
|
||||
image: glitchtip/glitchtip:6.0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- SERVICE_URL_GLITCHTIP_8080
|
||||
- SERVICE_URL_GLITCHTIP_8000
|
||||
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip}
|
||||
- SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION
|
||||
- EMAIL_URL=${EMAIL_URL:-consolemail://}
|
||||
|
|
@ -53,7 +53,7 @@ services:
|
|||
retries: 10
|
||||
|
||||
worker:
|
||||
image: glitchtip/glitchtip
|
||||
image: glitchtip/glitchtip:6.0
|
||||
command: ./bin/run-celery-with-beat.sh
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -77,7 +77,7 @@ services:
|
|||
retries: 10
|
||||
|
||||
migrate:
|
||||
image: glitchtip/glitchtip
|
||||
image: glitchtip/glitchtip:6.0
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# ignore: true
|
||||
# documentation: https://github.com/maybe-finance/maybe
|
||||
# slogan: Maybe, the OS for your personal finances.
|
||||
# category: productivity
|
||||
|
|
|
|||
31
templates/compose/spacebot.yaml
Normal file
31
templates/compose/spacebot.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# documentation: https://docs.spacebot.sh/docker
|
||||
# slogan: An agentic AI system with specialized processes for thinking, working, and remembering.
|
||||
# category: ai
|
||||
# tags: ai, agent, anthropic, openai, discord, slack, llm, agentic
|
||||
# logo: svgs/spacebot.png
|
||||
# port: 19898
|
||||
|
||||
services:
|
||||
spacebot:
|
||||
image: "ghcr.io/spacedriveapp/spacebot:full"
|
||||
environment:
|
||||
- SERVICE_FQDN_SPACEBOT_19898
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
|
||||
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
|
||||
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN}
|
||||
- BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY}
|
||||
- SPACEBOT_CHANNEL_MODEL=${SPACEBOT_CHANNEL_MODEL}
|
||||
- SPACEBOT_WORKER_MODEL=${SPACEBOT_WORKER_MODEL}
|
||||
volumes:
|
||||
- "spacebot-data:/data"
|
||||
security_opt:
|
||||
- seccomp=unconfined
|
||||
shm_size: 1g
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:19898/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
93
templates/compose/sure.yaml
Normal file
93
templates/compose/sure.yaml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# documentation: https://github.com/we-promise/sure
|
||||
# slogan: An all-in-one personal finance platform.
|
||||
# category: finance
|
||||
# tags: budgeting,budget,money,expenses,income
|
||||
# logo: svgs/sure.png
|
||||
# port: 3000
|
||||
|
||||
services:
|
||||
web:
|
||||
image: ghcr.io/we-promise/sure:0.6.7
|
||||
environment:
|
||||
- SERVICE_URL_SURE_3000
|
||||
- APP_DOMAIN=$SERVICE_FQDN_SURE
|
||||
- SECRET_KEY_BASE=$SERVICE_BASE64_BASE
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
|
||||
- POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
|
||||
- 'REDIS_URL=redis://valkey:6379'
|
||||
- DB_HOST=postgresql
|
||||
- DB_PORT=5432
|
||||
- 'SELF_HOSTED=true'
|
||||
- 'RAILS_FORCE_SSL=false'
|
||||
- 'RAILS_ASSUME_SSL=false'
|
||||
- ONBOARDING_STATE=${ONBOARDING_STATE:-open}
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- sure-app-storage:/rails/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"]
|
||||
interval: 15s
|
||||
timeout: 20s
|
||||
retries: 5
|
||||
|
||||
worker:
|
||||
image: ghcr.io/we-promise/sure:0.6.7
|
||||
command: bundle exec sidekiq
|
||||
environment:
|
||||
- APP_DOMAIN=$SERVICE_FQDN_SURE
|
||||
- SECRET_KEY_BASE=$SERVICE_BASE64_BASE
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
|
||||
- POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
|
||||
- 'REDIS_URL=redis://valkey:6379'
|
||||
- DB_HOST=postgresql
|
||||
- DB_PORT=5432
|
||||
- 'SELF_HOSTED=true'
|
||||
- 'RAILS_FORCE_SSL=false'
|
||||
- 'RAILS_ASSUME_SSL=false'
|
||||
- ONBOARDING_STATE=${ONBOARDING_STATE:-open}
|
||||
volumes:
|
||||
- sure-app-storage:/rails/storage
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"]
|
||||
interval: 15s
|
||||
timeout: 20s
|
||||
retries: 5
|
||||
|
||||
postgresql:
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- sure-postgresql-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
|
||||
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
|
||||
- POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
command: valkey-server --appendonly yes
|
||||
volumes:
|
||||
- sure-valkey:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- 'valkey-cli ping | grep PONG'
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 3s
|
||||
|
|
@ -1758,19 +1758,17 @@
|
|||
},
|
||||
"glitchtip": {
|
||||
"documentation": "https://glitchtip.com?utm_source=coolify.io",
|
||||
"slogan": "GlitchTip is a self-hosted, open-source error tracking system.",
|
||||
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
|
||||
"slogan": "GlitchTip is a error tracking system.",
|
||||
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
|
||||
"tags": [
|
||||
"error",
|
||||
"tracking",
|
||||
"open-source",
|
||||
"self-hosted",
|
||||
"sentry"
|
||||
],
|
||||
"category": "monitoring",
|
||||
"logo": "svgs/glitchtip.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
"port": "8000"
|
||||
},
|
||||
"glpi": {
|
||||
"documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io",
|
||||
|
|
@ -2681,24 +2679,6 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "8065"
|
||||
},
|
||||
"maybe": {
|
||||
"documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io",
|
||||
"slogan": "Maybe, the OS for your personal finances.",
|
||||
"compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==",
|
||||
"tags": [
|
||||
"finances",
|
||||
"wallets",
|
||||
"coins",
|
||||
"stocks",
|
||||
"investments",
|
||||
"open",
|
||||
"source"
|
||||
],
|
||||
"category": "productivity",
|
||||
"logo": "svgs/maybe.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "3000"
|
||||
},
|
||||
"mealie": {
|
||||
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
|
||||
"slogan": "A recipe manager and meal planner.",
|
||||
|
|
@ -4404,6 +4384,25 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "8989"
|
||||
},
|
||||
"spacebot": {
|
||||
"documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io",
|
||||
"slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.",
|
||||
"compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
|
||||
"tags": [
|
||||
"ai",
|
||||
"agent",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"discord",
|
||||
"slack",
|
||||
"llm",
|
||||
"agentic"
|
||||
],
|
||||
"category": "ai",
|
||||
"logo": "svgs/spacebot.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "19898"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io",
|
||||
"slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.",
|
||||
|
|
@ -4548,6 +4547,22 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "3567"
|
||||
},
|
||||
"sure": {
|
||||
"documentation": "https://github.com/we-promise/sure?utm_source=coolify.io",
|
||||
"slogan": "An all-in-one personal finance platform.",
|
||||
"compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK",
|
||||
"tags": [
|
||||
"budgeting",
|
||||
"budget",
|
||||
"money",
|
||||
"expenses",
|
||||
"income"
|
||||
],
|
||||
"category": "finance",
|
||||
"logo": "svgs/sure.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "3000"
|
||||
},
|
||||
"swetrix": {
|
||||
"documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io",
|
||||
"slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.",
|
||||
|
|
|
|||
|
|
@ -1758,19 +1758,17 @@
|
|||
},
|
||||
"glitchtip": {
|
||||
"documentation": "https://glitchtip.com?utm_source=coolify.io",
|
||||
"slogan": "GlitchTip is a self-hosted, open-source error tracking system.",
|
||||
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
|
||||
"slogan": "GlitchTip is a error tracking system.",
|
||||
"compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
|
||||
"tags": [
|
||||
"error",
|
||||
"tracking",
|
||||
"open-source",
|
||||
"self-hosted",
|
||||
"sentry"
|
||||
],
|
||||
"category": "monitoring",
|
||||
"logo": "svgs/glitchtip.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "8080"
|
||||
"port": "8000"
|
||||
},
|
||||
"glpi": {
|
||||
"documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io",
|
||||
|
|
@ -2681,24 +2679,6 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "8065"
|
||||
},
|
||||
"maybe": {
|
||||
"documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io",
|
||||
"slogan": "Maybe, the OS for your personal finances.",
|
||||
"compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=",
|
||||
"tags": [
|
||||
"finances",
|
||||
"wallets",
|
||||
"coins",
|
||||
"stocks",
|
||||
"investments",
|
||||
"open",
|
||||
"source"
|
||||
],
|
||||
"category": "productivity",
|
||||
"logo": "svgs/maybe.svg",
|
||||
"minversion": "0.0.0",
|
||||
"port": "3000"
|
||||
},
|
||||
"mealie": {
|
||||
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
|
||||
"slogan": "A recipe manager and meal planner.",
|
||||
|
|
@ -4404,6 +4384,25 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "8989"
|
||||
},
|
||||
"spacebot": {
|
||||
"documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io",
|
||||
"slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.",
|
||||
"compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
|
||||
"tags": [
|
||||
"ai",
|
||||
"agent",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"discord",
|
||||
"slack",
|
||||
"llm",
|
||||
"agentic"
|
||||
],
|
||||
"category": "ai",
|
||||
"logo": "svgs/spacebot.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "19898"
|
||||
},
|
||||
"sparkyfitness": {
|
||||
"documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io",
|
||||
"slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.",
|
||||
|
|
@ -4548,6 +4547,22 @@
|
|||
"minversion": "0.0.0",
|
||||
"port": "3567"
|
||||
},
|
||||
"sure": {
|
||||
"documentation": "https://github.com/we-promise/sure?utm_source=coolify.io",
|
||||
"slogan": "An all-in-one personal finance platform.",
|
||||
"compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==",
|
||||
"tags": [
|
||||
"budgeting",
|
||||
"budget",
|
||||
"money",
|
||||
"expenses",
|
||||
"income"
|
||||
],
|
||||
"category": "finance",
|
||||
"logo": "svgs/sure.png",
|
||||
"minversion": "0.0.0",
|
||||
"port": "3000"
|
||||
},
|
||||
"swetrix": {
|
||||
"documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io",
|
||||
"slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.",
|
||||
|
|
|
|||
2
tests/Browser/screenshots/.gitignore
vendored
2
tests/Browser/screenshots/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
194
tests/Feature/EnvironmentVariableUpdateApiTest.php
Normal file
194
tests/Feature/EnvironmentVariableUpdateApiTest.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/envs', function () {
|
||||
test('returns the updated environment variable object', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_IMAGE_TAG',
|
||||
'value' => 'old-value',
|
||||
'resourceable_type' => Service::class,
|
||||
'resourceable_id' => $service->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
|
||||
'key' => 'APP_IMAGE_TAG',
|
||||
'value' => 'new-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure([
|
||||
'uuid',
|
||||
'key',
|
||||
'is_literal',
|
||||
'is_multiline',
|
||||
'is_shown_once',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
|
||||
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
|
||||
});
|
||||
|
||||
test('returns 404 when environment variable does not exist', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
|
||||
'key' => 'NONEXISTENT_KEY',
|
||||
'value' => 'some-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJson(['message' => 'Environment variable not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when service does not exist', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/services/non-existent-uuid/envs', [
|
||||
'key' => 'APP_IMAGE_TAG',
|
||||
'value' => 'some-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 422 when key is missing', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
|
||||
'value' => 'some-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
|
||||
test('returns the updated environment variable object', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'APP_IMAGE_TAG',
|
||||
'value' => 'old-value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
|
||||
'key' => 'APP_IMAGE_TAG',
|
||||
'value' => 'new-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure([
|
||||
'uuid',
|
||||
'key',
|
||||
'is_literal',
|
||||
'is_multiline',
|
||||
'is_shown_once',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
|
||||
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
|
||||
});
|
||||
|
||||
test('returns 404 when environment variable does not exist', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
|
||||
'key' => 'NONEXISTENT_KEY',
|
||||
'value' => 'some-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 422 when key is missing', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
|
||||
'value' => 'some-value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
76
tests/Feature/PushServerUpdateJobTest.php
Normal file
76
tests/Feature/PushServerUpdateJobTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('containers with empty service subId are skipped', function () {
|
||||
$server = Server::factory()->create();
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'test-container',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => true,
|
||||
'coolify.serviceId' => (string) $service->id,
|
||||
'coolify.service.subType' => 'application',
|
||||
'coolify.service.subId' => '',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
|
||||
// Run handle - should not throw a PDOException about empty bigint
|
||||
$job->handle();
|
||||
|
||||
// The empty subId container should have been skipped
|
||||
expect($job->foundServiceApplicationIds)->not->toContain('');
|
||||
expect($job->serviceContainerStatuses)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('containers with valid service subId are processed', function () {
|
||||
$server = Server::factory()->create();
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
$serviceApp = ServiceApplication::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'containers' => [
|
||||
[
|
||||
'name' => 'test-container',
|
||||
'state' => 'running',
|
||||
'health_status' => 'healthy',
|
||||
'labels' => [
|
||||
'coolify.managed' => true,
|
||||
'coolify.serviceId' => (string) $service->id,
|
||||
'coolify.service.subType' => 'application',
|
||||
'coolify.service.subId' => (string) $serviceApp->id,
|
||||
'com.docker.compose.service' => 'myapp',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new PushServerUpdateJob($server, $data);
|
||||
$job->handle();
|
||||
|
||||
expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id);
|
||||
});
|
||||
200
tests/Feature/ScheduledJobMonitoringTest.php
Normal file
200
tests/Feature/ScheduledJobMonitoringTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Settings\ScheduledJobs;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\SchedulerLogParser;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create root team (id 0) and root user
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
$this->rootUser = User::factory()->create();
|
||||
$this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']);
|
||||
|
||||
// Create regular team and user
|
||||
$this->regularTeam = Team::factory()->create();
|
||||
$this->regularUser = User::factory()->create();
|
||||
$this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']);
|
||||
});
|
||||
|
||||
test('scheduled jobs page requires instance admin access', function () {
|
||||
$this->actingAs($this->regularUser);
|
||||
session(['currentTeam' => $this->regularTeam]);
|
||||
|
||||
$response = $this->get(route('settings.scheduled-jobs'));
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('scheduled jobs page is accessible by instance admin', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Scheduled Job Issues');
|
||||
});
|
||||
|
||||
test('scheduled jobs page shows failed backup executions', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'frequency' => '0 * * * *',
|
||||
'database_id' => 1,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'failed',
|
||||
'message' => 'Backup failed: connection timeout',
|
||||
]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Backup');
|
||||
});
|
||||
|
||||
test('scheduled jobs page shows failed cleanup executions', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
]);
|
||||
|
||||
DockerCleanupExecution::create([
|
||||
'server_id' => $server->id,
|
||||
'status' => 'failed',
|
||||
'message' => 'Cleanup failed: disk full',
|
||||
]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Cleanup');
|
||||
});
|
||||
|
||||
test('filter by type works', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->set('filterType', 'backup')
|
||||
->assertStatus(200)
|
||||
->set('filterType', 'cleanup')
|
||||
->assertStatus(200)
|
||||
->set('filterType', 'task')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
test('only failed executions are shown', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'frequency' => '0 * * * *',
|
||||
'database_id' => 1,
|
||||
'database_type' => 'App\Models\StandalonePostgresql',
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'success',
|
||||
'message' => 'Backup completed successfully',
|
||||
]);
|
||||
|
||||
ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $backup->id,
|
||||
'status' => 'failed',
|
||||
'message' => 'Backup failed: connection refused',
|
||||
]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->assertSee('Backup failed: connection refused')
|
||||
->assertDontSee('Backup completed successfully');
|
||||
});
|
||||
|
||||
test('filter by date range works', function () {
|
||||
$this->actingAs($this->rootUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
|
||||
Livewire::test(ScheduledJobs::class)
|
||||
->set('filterDate', 'last_7d')
|
||||
->assertStatus(200)
|
||||
->set('filterDate', 'last_30d')
|
||||
->assertStatus(200)
|
||||
->set('filterDate', 'all')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
test('scheduler log parser returns empty collection when no logs exist', function () {
|
||||
$parser = new SchedulerLogParser;
|
||||
|
||||
$skips = $parser->getRecentSkips();
|
||||
expect($skips)->toBeEmpty();
|
||||
|
||||
$runs = $parser->getRecentRuns();
|
||||
expect($runs)->toBeEmpty();
|
||||
})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests');
|
||||
|
||||
test('scheduler log parser parses skip entries correctly', function () {
|
||||
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
$logDir = dirname($logPath);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}';
|
||||
file_put_contents($logPath, $logLine."\n");
|
||||
|
||||
$parser = new SchedulerLogParser;
|
||||
$skips = $parser->getRecentSkips();
|
||||
|
||||
expect($skips)->toHaveCount(1);
|
||||
expect($skips->first()['type'])->toBe('backup');
|
||||
expect($skips->first()['reason'])->toBe('server_not_functional');
|
||||
expect($skips->first()['team_id'])->toBe(5);
|
||||
|
||||
// Cleanup
|
||||
@unlink($logPath);
|
||||
});
|
||||
|
||||
test('scheduler log parser filters by team id', function () {
|
||||
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
|
||||
$logDir = dirname($logPath);
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}',
|
||||
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}',
|
||||
];
|
||||
file_put_contents($logPath, implode("\n", $lines)."\n");
|
||||
|
||||
$parser = new SchedulerLogParser;
|
||||
|
||||
$allSkips = $parser->getRecentSkips(100);
|
||||
expect($allSkips)->toHaveCount(2);
|
||||
|
||||
$team1Skips = $parser->getRecentSkips(100, 1);
|
||||
expect($team1Skips)->toHaveCount(1);
|
||||
expect($team1Skips->first()['team_id'])->toBe(1);
|
||||
|
||||
// Cleanup
|
||||
@unlink($logPath);
|
||||
});
|
||||
365
tests/Feature/ScheduledTaskApiTest.php
Normal file
365
tests/Feature/ScheduledTaskApiTest.php
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function authHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
test('returns empty array when no tasks exist', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([]);
|
||||
});
|
||||
|
||||
test('returns tasks when they exist', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'Test Task']);
|
||||
});
|
||||
|
||||
test('returns 404 for unknown application uuid', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
|
||||
test('creates a task with valid data', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Backup',
|
||||
'command' => 'php artisan backup',
|
||||
'frequency' => '0 0 * * *',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['name' => 'Backup']);
|
||||
|
||||
$this->assertDatabaseHas('scheduled_tasks', [
|
||||
'name' => 'Backup',
|
||||
'command' => 'php artisan backup',
|
||||
'frequency' => '0 0 * * *',
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns 422 when name is missing', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'command' => 'echo test',
|
||||
'frequency' => '* * * * *',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 for invalid cron expression', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
'frequency' => 'not-a-cron',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.');
|
||||
});
|
||||
|
||||
test('returns 422 when extra fields are present', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
'frequency' => '* * * * *',
|
||||
'unknown_field' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('defaults timeout and enabled when not provided', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
|
||||
'name' => 'Test',
|
||||
'command' => 'echo test',
|
||||
'frequency' => '* * * * *',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('scheduled_tasks', [
|
||||
'name' => 'Test',
|
||||
'timeout' => 300,
|
||||
'enabled' => true,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
|
||||
test('updates task with partial data', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['name' => 'New Name']);
|
||||
});
|
||||
|
||||
test('returns 404 when task not found', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
|
||||
'name' => 'Test',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
|
||||
test('deletes task successfully', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Scheduled task deleted.']);
|
||||
|
||||
$this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]);
|
||||
});
|
||||
|
||||
test('returns 404 when task not found', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () {
|
||||
test('returns executions for a task', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $application->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $task->id,
|
||||
'status' => 'success',
|
||||
'message' => 'OK',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['status' => 'success']);
|
||||
});
|
||||
|
||||
test('returns 404 when task not found', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service scheduled tasks API', function () {
|
||||
test('can list tasks for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
ScheduledTask::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Service Task',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'Service Task']);
|
||||
});
|
||||
|
||||
test('can create a task for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
|
||||
'name' => 'Service Backup',
|
||||
'command' => 'pg_dump',
|
||||
'frequency' => '0 2 * * *',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['name' => 'Service Backup']);
|
||||
});
|
||||
|
||||
test('can delete a task for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Scheduled task deleted.']);
|
||||
});
|
||||
});
|
||||
110
tests/Feature/ServerIpUniquenessTest.php
Normal file
110
tests/Feature/ServerIpUniquenessTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-key-content',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects duplicate ip within the same team', function () {
|
||||
Server::factory()->create([
|
||||
'ip' => '1.2.3.4',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$foundServer = Server::whereIp('1.2.3.4')->first();
|
||||
|
||||
expect($foundServer)->not->toBeNull();
|
||||
expect($foundServer->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
it('detects duplicate ip from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
Server::factory()->create([
|
||||
'ip' => '5.6.7.8',
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
|
||||
$foundServer = Server::whereIp('5.6.7.8')->first();
|
||||
|
||||
expect($foundServer)->not->toBeNull();
|
||||
expect($foundServer->team_id)->not->toBe($this->team->id);
|
||||
});
|
||||
|
||||
it('shows correct error message for same team duplicate in boarding', function () {
|
||||
Server::factory()->create([
|
||||
'ip' => '1.2.3.4',
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
|
||||
$foundServer = Server::whereIp('1.2.3.4')->first();
|
||||
if ($foundServer->team_id === currentTeam()->id) {
|
||||
$message = 'A server with this IP/Domain already exists in your team.';
|
||||
} else {
|
||||
$message = 'A server with this IP/Domain is already in use by another team.';
|
||||
}
|
||||
|
||||
expect($message)->toBe('A server with this IP/Domain already exists in your team.');
|
||||
});
|
||||
|
||||
it('shows correct error message for other team duplicate in boarding', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
Server::factory()->create([
|
||||
'ip' => '5.6.7.8',
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
|
||||
$foundServer = Server::whereIp('5.6.7.8')->first();
|
||||
if ($foundServer->team_id === currentTeam()->id) {
|
||||
$message = 'A server with this IP/Domain already exists in your team.';
|
||||
} else {
|
||||
$message = 'A server with this IP/Domain is already in use by another team.';
|
||||
}
|
||||
|
||||
expect($message)->toBe('A server with this IP/Domain is already in use by another team.');
|
||||
});
|
||||
|
||||
it('allows adding ip that does not exist globally', function () {
|
||||
$foundServer = Server::whereIp('10.20.30.40')->first();
|
||||
|
||||
expect($foundServer)->toBeNull();
|
||||
});
|
||||
|
||||
it('enforces global uniqueness not just team-scoped', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
Server::factory()->create([
|
||||
'ip' => '9.8.7.6',
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
|
||||
// Global check finds the server even though it belongs to another team
|
||||
$foundServer = Server::whereIp('9.8.7.6')->first();
|
||||
expect($foundServer)->not->toBeNull();
|
||||
|
||||
// Team-scoped check would miss it - this is why global check is needed
|
||||
$teamScopedServer = Server::where('team_id', $this->team->id)
|
||||
->where('ip', '9.8.7.6')
|
||||
->first();
|
||||
expect($teamScopedServer)->toBeNull();
|
||||
});
|
||||
45
tests/Feature/StartDatabaseProxyTest.php
Normal file
45
tests/Feature/StartDatabaseProxyTest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
test('database proxy is disabled on port already allocated error', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'is_public' => true,
|
||||
'public_port' => 5432,
|
||||
]);
|
||||
|
||||
expect($database->is_public)->toBeTrue();
|
||||
|
||||
$action = new StartDatabaseProxy;
|
||||
|
||||
// Use reflection to test the private method directly
|
||||
$method = new ReflectionMethod($action, 'isNonTransientError');
|
||||
|
||||
expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue();
|
||||
expect($method->invoke($action, 'address already in use'))->toBeTrue();
|
||||
expect($method->invoke($action, 'some other error'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isNonTransientError detects port conflict patterns', function () {
|
||||
$action = new StartDatabaseProxy;
|
||||
$method = new ReflectionMethod($action, 'isNonTransientError');
|
||||
|
||||
expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue()
|
||||
->and($method->invoke($action, 'address already in use'))->toBeTrue()
|
||||
->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue()
|
||||
->and($method->invoke($action, 'network timeout'))->toBeFalse()
|
||||
->and($method->invoke($action, 'connection refused'))->toBeFalse();
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ use Illuminate\Support\Once;
|
|||
| need to change it using the "uses()" function to bind a different classes or traits.
|
||||
|
|
||||
*/
|
||||
uses(Tests\TestCase::class)->in('Feature');
|
||||
uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
162
tests/v4/Browser/DashboardTest.php
Normal file
162
tests/v4/Browser/DashboardTest.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'id' => 0,
|
||||
'name' => 'Root User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
PrivateKey::create([
|
||||
'id' => 1,
|
||||
'uuid' => 'ssh-test',
|
||||
'team_id' => 0,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'id' => 0,
|
||||
'uuid' => 'localhost',
|
||||
'name' => 'localhost',
|
||||
'description' => 'This is a test docker container in development mode',
|
||||
'ip' => 'coolify-testing-host',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'uuid' => 'production-1',
|
||||
'name' => 'production-web',
|
||||
'description' => 'Production web server cluster',
|
||||
'ip' => '10.0.0.1',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
Server::create([
|
||||
'uuid' => 'staging-1',
|
||||
'name' => 'staging-server',
|
||||
'description' => 'Staging environment server',
|
||||
'ip' => '10.0.0.2',
|
||||
'team_id' => 0,
|
||||
'private_key_id' => 1,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-1',
|
||||
'name' => 'My first project',
|
||||
'description' => 'This is a test project in development',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-2',
|
||||
'name' => 'Production API',
|
||||
'description' => 'Backend services for production',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
|
||||
Project::create([
|
||||
'uuid' => 'project-3',
|
||||
'name' => 'Staging Environment',
|
||||
'description' => 'Staging and QA testing',
|
||||
'team_id' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
function loginAndSkipOnboarding(): mixed
|
||||
{
|
||||
return visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->click('Skip Setup');
|
||||
}
|
||||
|
||||
it('redirects to login when not authenticated', function () {
|
||||
$page = visit('/');
|
||||
|
||||
$page->assertPathIs('/login')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows onboarding after first login', function () {
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Welcome to Coolify')
|
||||
->assertSee("Let's go!")
|
||||
->assertSee('Skip Setup')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows dashboard after skipping onboarding', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Dashboard')
|
||||
->assertSee('Your self-hosted infrastructure.')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows all projects on dashboard', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Projects')
|
||||
->assertSee('My first project')
|
||||
->assertSee('This is a test project in development')
|
||||
->assertSee('Production API')
|
||||
->assertSee('Backend services for production')
|
||||
->assertSee('Staging Environment')
|
||||
->assertSee('Staging and QA testing')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows servers on dashboard', function () {
|
||||
$page = loginAndSkipOnboarding();
|
||||
|
||||
$page->assertSee('Servers')
|
||||
->assertSee('localhost')
|
||||
->assertSee('This is a test docker container in development mode')
|
||||
->assertSee('production-web')
|
||||
->assertSee('Production web server cluster')
|
||||
->assertSee('staging-server')
|
||||
->assertSee('Staging environment server')
|
||||
->screenshot();
|
||||
});
|
||||
52
tests/v4/Browser/LoginTest.php
Normal file
52
tests/v4/Browser/LoginTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
});
|
||||
|
||||
it('shows registration page when no users exist', function () {
|
||||
$page = visit('/login');
|
||||
|
||||
$page->assertSee('Root User Setup')
|
||||
->assertSee('Create Account')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('can login with valid credentials', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Welcome to Coolify')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('fails login with invalid credentials', function () {
|
||||
User::factory()->create([
|
||||
'id' => 0,
|
||||
'email' => 'test@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
]);
|
||||
|
||||
$page = visit('/login');
|
||||
|
||||
$page->fill('email', 'random@email.com')
|
||||
->fill('password', 'wrongpassword123')
|
||||
->click('Login')
|
||||
->assertSee('These credentials do not match our records')
|
||||
->screenshot();
|
||||
});
|
||||
67
tests/v4/Browser/RegistrationTest.php
Normal file
67
tests/v4/Browser/RegistrationTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
});
|
||||
|
||||
it('shows registration page when no users exist', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->assertSee('Root User Setup')
|
||||
->assertSee('Create Account')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('can register a new root user', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->fill('name', 'Test User')
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'Password1!@')
|
||||
->fill('password_confirmation', 'Password1!@')
|
||||
->click('Create Account')
|
||||
->assertPathIs('/onboarding')
|
||||
->screenshot();
|
||||
|
||||
expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('fails registration with mismatched passwords', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->fill('name', 'Test User')
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'Password1!@')
|
||||
->fill('password_confirmation', 'DifferentPass1!@')
|
||||
->click('Create Account')
|
||||
->assertSee('password')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('fails registration with weak password', function () {
|
||||
$page = visit('/register');
|
||||
|
||||
$page->fill('name', 'Test User')
|
||||
->fill('email', 'root@example.com')
|
||||
->fill('password', 'short')
|
||||
->fill('password_confirmation', 'short')
|
||||
->click('Create Account')
|
||||
->assertSee('password')
|
||||
->screenshot();
|
||||
});
|
||||
|
||||
it('shows login link when a user already exists', function () {
|
||||
User::factory()->create(['id' => 0]);
|
||||
|
||||
$page = visit('/register');
|
||||
|
||||
$page->assertSee('Already registered?')
|
||||
->assertDontSee('Root User Setup')
|
||||
->screenshot();
|
||||
});
|
||||
18
tests/v4/Feature/SqliteDatabaseTest.php
Normal file
18
tests/v4/Feature/SqliteDatabaseTest.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('uses sqlite for testing', function () {
|
||||
expect(DB::connection()->getDriverName())->toBe('sqlite');
|
||||
});
|
||||
|
||||
it('runs migrations successfully', function () {
|
||||
expect(Schema::hasTable('users'))->toBeTrue();
|
||||
expect(Schema::hasTable('teams'))->toBeTrue();
|
||||
expect(Schema::hasTable('servers'))->toBeTrue();
|
||||
expect(Schema::hasTable('applications'))->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue