Merge branch 'next' into service/lobe-ai-chat

This commit is contained in:
Romain ROCHAS 2025-09-22 20:27:10 +02:00 committed by GitHub
commit ccd4e6e6d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1818 additions and 340 deletions

View file

@ -651,4 +651,8 @@ ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>
</laravel-boost-guidelines>
Random other things you should remember:
- App\Models\Application::team must return a relationship instance., always use team()

View file

@ -0,0 +1,35 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ApplicationConfigurationChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public function __construct($teamId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
}

View file

@ -3380,11 +3380,12 @@ private function validateDataApplications(Request $request, Server $server)
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$errors = [];
$fqdn = str($fqdn)->trim()->explode(',')->map(function ($domain) use (&$errors) {
$domain = trim($domain);
if (filter_var($domain, FILTER_VALIDATE_URL) === false) {
$errors[] = 'Invalid domain: '.$domain;
}
return str($domain)->trim()->lower();
return str($domain)->lower();
});
if (count($errors) > 0) {
return response()->json([

View file

@ -9,11 +9,15 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Enums\NewDatabaseTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DeleteResourceJob;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class DatabasesController extends Controller
@ -79,13 +83,88 @@ public function databases(Request $request)
foreach ($projects as $project) {
$databases = $databases->merge($project->databases());
}
$databases = $databases->map(function ($database) {
$databaseIds = $databases->pluck('id')->toArray();
$backupConfigs = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('latest_log')
->whereIn('database_id', $databaseIds)
->get()
->groupBy('database_id');
$databases = $databases->map(function ($database) use ($backupConfigs) {
$database->backup_configs = $backupConfigs->get($database->id, collect())->values();
return $this->removeSensitiveData($database);
});
return response()->json($databases);
}
#[OA\Get(
summary: 'Get',
description: 'Get backups details by database UUID.',
path: '/databases/{uuid}/backups',
operationId: 'get-database-backups-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all backups for a database',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
),
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function database_backup_details_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('view', $database);
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->with('executions')->where('database_id', $database->id)->get();
return response()->json($backupConfig);
}
#[OA\Get(
summary: 'Get',
description: 'Get database by UUID.',
@ -248,6 +327,7 @@ public function update_by_uuid(Request $request)
return invalidTokenResponse();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
@ -499,6 +579,7 @@ public function update_by_uuid(Request $request)
$whatToDoWithDatabaseProxy = 'start';
}
// Only update database fields, not backup configuration
$database->update($request->all());
if ($whatToDoWithDatabaseProxy === 'start') {
@ -512,6 +593,197 @@ public function update_by_uuid(Request $request)
]);
}
#[OA\Patch(
summary: 'Update',
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'update-database-backup',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
description: 'UUID of the backup configuration.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
requestBody: new OA\RequestBody(
description: 'Database backup configuration data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'save_s3' => ['type' => 'boolean', 'description' => 'Whether data is saved in s3 or not'],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID'],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to take a backup now or not'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled or not'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether all databases are dumped or not'],
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Database backup configuration updated',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function update_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'save_s3' => 'boolean',
'backup_now' => 'boolean|nullable',
'enabled' => 'boolean',
'dump_all' => 'boolean',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$uuid = $request->uuid;
removeUnnecessaryFieldsFromRequest($request);
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
}
$backupConfig = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backupConfig) {
return response()->json(['message' => 'Backup config not found.'], 404);
}
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
if (! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
}
$backupConfig->update($backupData);
if ($request->backup_now) {
dispatch(new DatabaseBackupJob($backupConfig));
}
return response()->json([
'message' => 'Database backup configuration updated',
]);
}
#[OA\Post(
summary: 'Create (PostgreSQL)',
description: 'Create a new PostgreSQL database.',
@ -1630,6 +1902,344 @@ public function delete_by_uuid(Request $request)
]);
}
#[OA\Delete(
summary: 'Delete backup configuration',
description: 'Deletes a backup configuration and all its executions.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}',
operationId: 'delete-backup-configuration-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete all backup files from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup configuration deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
]
)
),
]
)]
public function delete_backup_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
DB::beginTransaction();
// Get all executions for this backup configuration
$executions = $backup->executions()->get();
// Delete all execution files (locally and optionally from S3)
foreach ($executions as $execution) {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
}
// Delete the backup configuration itself
$backup->delete();
DB::commit();
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
}
}
#[OA\Delete(
summary: 'Delete backup execution',
description: 'Deletes a specific backup execution.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}',
operationId: 'delete-backup-execution-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'execution_uuid',
in: 'path',
required: true,
description: 'UUID of the backup execution to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'delete_s3',
in: 'query',
required: false,
description: 'Whether to delete the backup from S3',
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Backup execution deleted.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
]
)
),
new OA\Response(
response: 404,
description: 'Backup execution not found.',
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
]
)
),
]
)]
public function delete_execution_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate parameters
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
if (! $request->execution_uuid) {
return response()->json(['message' => 'Execution UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Find the specific execution
$execution = $backup->executions()->where('uuid', $request->execution_uuid)->first();
if (! $execution) {
return response()->json(['message' => 'Backup execution not found.'], 404);
}
$deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN);
try {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $database->destination->server);
if ($deleteS3 && $backup->s3) {
deleteBackupsS3($execution->filename, $backup->s3);
}
}
$execution->delete();
return response()->json([
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
}
}
#[OA\Get(
summary: 'List backup executions',
description: 'Get all executions for a specific backup configuration.',
path: '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions',
operationId: 'list-backup-executions',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
required: true,
description: 'UUID of the database',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'scheduled_backup_uuid',
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of backup executions',
content: new OA\JsonContent(
type: 'object',
properties: [
'executions' => new OA\Schema(
type: 'array',
items: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'size' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'message' => ['type' => 'string'],
'status' => ['type' => 'string'],
]
)
),
]
)
),
new OA\Response(
response: 404,
description: 'Backup configuration not found.',
),
]
)]
public function list_backup_executions(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate scheduled_backup_uuid is provided
if (! $request->scheduled_backup_uuid) {
return response()->json(['message' => 'Scheduled backup UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
// Find the backup configuration by its UUID
$backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId)->where('database_id', $database->id)
->where('uuid', $request->scheduled_backup_uuid)
->first();
if (! $backup) {
return response()->json(['message' => 'Backup configuration not found.'], 404);
}
// Get all executions for this backup configuration
$executions = $backup->executions()
->orderBy('created_at', 'desc')
->get()
->map(function ($execution) {
return [
'uuid' => $execution->uuid,
'filename' => $execution->filename,
'size' => $execution->size,
'created_at' => $execution->created_at->toIso8601String(),
'message' => $execution->message,
'status' => $execution->status,
];
});
return response()->json([
'executions' => $executions,
]);
}
#[OA\Get(
summary: 'Start',
description: 'Start database. `Post` request is also accepted.',

View file

@ -0,0 +1,661 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
class GithubController extends Controller
{
#[OA\Post(
summary: 'Create GitHub App',
description: 'Create a new GitHub app.',
path: '/github-apps',
operationId: 'create-github-app',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
requestBody: new OA\RequestBody(
description: 'GitHub app creation payload.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'GitHub app created successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'organization' => ['type' => 'string', 'nullable' => true],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'app_id' => ['type' => 'integer'],
'installation_id' => ['type' => 'integer'],
'client_id' => ['type' => 'string'],
'private_key_id' => ['type' => 'integer'],
'is_system_wide' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_github_app(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
'is_system_wide',
];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => 'required|string|url',
'html_url' => 'required|string|url',
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
'installation_id' => 'required|integer',
'client_id' => 'required|string|max:255',
'client_secret' => 'required|string',
'webhook_secret' => 'required|string',
'private_key_uuid' => 'required|string',
'is_system_wide' => '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);
}
try {
// Verify the private key belongs to the team
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
->where('team_id', $teamId)
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team.',
], 404);
}
$payload = [
'uuid' => Str::uuid(),
'name' => $request->input('name'),
'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'),
'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
'app_id' => $request->input('app_id'),
'installation_id' => $request->input('installation_id'),
'client_id' => $request->input('client_id'),
'client_secret' => $request->input('client_secret'),
'webhook_secret' => $request->input('webhook_secret'),
'private_key_id' => $privateKey->id,
'is_public' => false,
'team_id' => $teamId,
];
if (! isCloud()) {
$payload['is_system_wide'] = $request->input('is_system_wide', false);
}
$githubApp = GithubApp::create($payload);
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories',
summary: 'Load Repositories for a GitHub App',
description: 'Fetch repositories from GitHub for a given GitHub app.',
operationId: 'load-repositories',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Repositories loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'repositories' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_repositories($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$repositories = collect();
$page = 1;
$maxPages = 100; // Safety limit: max 10,000 repositories
while ($page <= $maxPages) {
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
if ($response->status() !== 200) {
return response()->json([
'message' => $response->json()['message'] ?? 'Failed to load repositories',
], $response->status());
}
$json = $response->json();
$repos = $json['repositories'] ?? [];
if (empty($repos)) {
break; // No more repositories to load
}
$repositories = $repositories->concat($repos);
$page++;
}
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Get(
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
summary: 'Load Branches for a GitHub Repository',
description: 'Fetch branches from GitHub for a given repository.',
operationId: 'load-branches',
tags: ['GitHub Apps'],
security: [
['bearerAuth' => []],
],
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
new OA\Parameter(
name: 'owner',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository owner'
),
new OA\Parameter(
name: 'repo',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string'),
description: 'Repository name'
),
],
responses: [
new OA\Response(
response: 200,
description: 'Branches loaded successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'branches' => new OA\Items(
type: 'array',
items: new OA\Schema(type: 'object')
),
]
)
)
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function load_branches($github_app_id, $owner, $repo)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
$token = generateGithubInstallationToken($githubApp);
$response = Http::GitHub($githubApp->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$owner}/{$repo}/branches");
if ($response->status() !== 200) {
return response()->json([
'message' => 'Error loading branches from GitHub.',
'error' => $response->json('message'),
], $response->status());
}
$branches = $response->json();
return response()->json([
'branches' => $branches,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
}
}
/**
* Update a GitHub app.
*/
#[OA\Patch(
path: '/github-apps/{github_app_id}',
operationId: 'updateGithubApp',
security: [
['api_token' => []],
],
tags: ['GitHub Apps'],
summary: 'Update GitHub App',
description: 'Update an existing GitHub app.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'GitHub app updated successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
public function update_github_app(Request $request, $github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Define allowed fields for update
$allowedFields = [
'name',
'organization',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'client_secret',
'webhook_secret',
'private_key_uuid',
];
if (! isCloud()) {
$allowedFields[] = 'is_system_wide';
}
$payload = $request->only($allowedFields);
// Validate the request
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string';
}
if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
$rules['api_url'] = 'url';
}
if (isset($payload['html_url'])) {
$rules['html_url'] = 'url';
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
}
if (isset($payload['custom_port'])) {
$rules['custom_port'] = 'integer|min:1|max:65535';
}
if (isset($payload['app_id'])) {
$rules['app_id'] = 'integer';
}
if (isset($payload['installation_id'])) {
$rules['installation_id'] = 'integer';
}
if (isset($payload['client_id'])) {
$rules['client_id'] = 'string';
}
if (isset($payload['client_secret'])) {
$rules['client_secret'] = 'string';
}
if (isset($payload['webhook_secret'])) {
$rules['webhook_secret'] = 'string';
}
if (isset($payload['private_key_uuid'])) {
$rules['private_key_uuid'] = 'string|uuid';
}
if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean';
}
$validator = customApiValidator($payload, $rules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation error',
'errors' => $validator->errors(),
], 422);
}
// Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId)
->where('uuid', $payload['private_key_uuid'])
->first();
if (! $privateKey) {
return response()->json([
'message' => 'Private key not found or does not belong to your team',
], 404);
}
unset($payload['private_key_uuid']);
$payload['private_key_id'] = $privateKey->id;
}
// Update the GitHub app
$githubApp->update($payload);
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
/**
* Delete a GitHub app.
*/
#[OA\Delete(
path: '/github-apps/{github_app_id}',
operationId: 'deleteGithubApp',
security: [
['api_token' => []],
],
tags: ['GitHub Apps'],
summary: 'Delete GitHub App',
description: 'Delete a GitHub app if it\'s not being used by any applications.',
parameters: [
new OA\Parameter(
name: 'github_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitHub App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'GitHub app deleted successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(
response: 409,
description: 'Conflict - GitHub app is in use',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
]
)
)
),
]
)]
public function delete_github_app($github_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$githubApp = GithubApp::where('id', $github_app_id)
->where('team_id', $teamId)
->firstOrFail();
// Check if the GitHub app is being used by any applications
if ($githubApp->applications->isNotEmpty()) {
$count = $githubApp->applications->count();
return response()->json([
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
], 409);
}
$githubApp->delete();
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
}
}
}

View file

@ -179,6 +179,8 @@ public function members_by_id(Request $request)
$members = $team->members;
$members->makeHidden([
'pivot',
'email_change_code',
'email_change_code_expires_at',
]);
return response()->json(
@ -264,6 +266,8 @@ public function current_team_members(Request $request)
$team = auth()->user()->currentTeam();
$team->members->makeHidden([
'pivot',
'email_change_code',
'email_change_code_expires_at',
]);
return response()->json(

View file

@ -5,6 +5,7 @@
use App\Actions\Docker\GetContainersStatus;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@ -147,6 +148,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private Collection $saved_outputs;
private ?string $secrets_hash_key = null;
private ?string $full_healthcheck_url = null;
private string $serverUser = 'root';
@ -2712,22 +2715,28 @@ private function generate_build_env_variables()
if ($this->application->build_pack === 'nixpacks') {
$variables = collect($this->nixpacks_plan_json->get('variables'));
} else {
// Generate environment variables for build process (filters by is_buildtime = true)
$this->generate_env_variables();
$variables = collect([])->merge($this->env_args);
}
// Check if build secrets are enabled and BuildKit is supported
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
// Fall back to traditional build args
$secrets_hash = '';
if ($variables->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($variables);
}
$this->build_args = $variables->map(function ($value, $key) {
$value = escapeshellarg($value);
return "--build-arg {$key}={$value}";
});
if ($secrets_hash) {
$this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
}
}
@ -2746,13 +2755,18 @@ private function generate_docker_env_flags_for_secrets()
return '';
}
return $variables
$secrets_hash = $this->generate_secrets_hash($variables);
$env_flags = $variables
->map(function ($env) {
$escaped_value = escapeshellarg($env->real_value);
return "-e {$env->key}={$escaped_value}";
})
->implode(' ');
$env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
return $env_flags;
}
private function generate_build_secrets(Collection $variables)
@ -2768,6 +2782,36 @@ private function generate_build_secrets(Collection $variables)
return "--secret id={$key},env={$key}";
})
->implode(' ');
$this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
}
private function generate_secrets_hash($variables)
{
if (! $this->secrets_hash_key) {
$this->secrets_hash_key = bin2hex(random_bytes(32));
}
if ($variables instanceof Collection) {
$secrets_string = $variables
->mapWithKeys(function ($value, $key) {
return [$key => $value];
})
->sortKeys()
->map(function ($value, $key) {
return "{$key}={$value}";
})
->implode('|');
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->real_value}";
})
->sort()
->implode('|');
}
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
private function add_build_env_variables_to_dockerfile()
@ -2809,6 +2853,12 @@ private function add_build_env_variables_to_dockerfile()
}
}
}
if ($envs->isNotEmpty()) {
$secrets_hash = $this->generate_secrets_hash($envs);
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
@ -2850,6 +2900,9 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
// Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@ -3186,6 +3239,9 @@ private function next(string $status)
queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
ray($this->application->team()->id);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
$this->deploy_to_additional_destinations();
}

View file

@ -547,9 +547,10 @@ public function submit($showToaster = true)
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');

View file

@ -143,7 +143,13 @@ public function loadBranches()
protected function loadBranchByPage()
{
$response = Http::withToken($this->token)->get("{$this->github_app->api_url}/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches?per_page=100&page={$this->page}");
$response = Http::GitHub($this->github_app->api_url, $this->token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
'per_page' => 100,
'page' => $this->page,
]);
$json = $response->json();
if ($response->status() !== 200) {
return $this->dispatch('error', $json['message']);

View file

@ -41,9 +41,10 @@ public function submit()
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);

View file

@ -149,9 +149,10 @@ public function submit()
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->trim()->lower();
return str($domain)->lower();
});
$this->application->fqdn = $this->application->fqdn->unique()->implode(',');
$warning = sslipDomainWarning($this->application->fqdn);

View file

@ -20,7 +20,15 @@ class ConfigurationChecker extends Component
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
protected $listeners = ['configurationChanged'];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged',
'configurationChanged' => 'configurationChanged',
];
}
public function mount()
{

View file

@ -8,7 +8,7 @@ class Metrics extends Component
{
public $resource;
public $chartId = 'container-cpu';
public $chartId = 'metrics';
public $data;

View file

@ -45,7 +45,7 @@ public function mount()
public function getConfigurationFilePathProperty()
{
return $this->server->proxyPath().'/docker-compose.yml';
return $this->server->proxyPath().'docker-compose.yml';
}
public function changeProxy()

View file

@ -10,6 +10,21 @@ class ScheduledDatabaseBackup extends BaseModel
{
protected $guarded = [];
public static function ownedByCurrentTeam()
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', currentTeam()->id)->orderBy('name');
}
public static function ownedByCurrentTeamAPI(int $teamId)
{
return ScheduledDatabaseBackup::whereRelation('team', 'id', $teamId)->orderBy('name');
}
public function team()
{
return $this->belongsTo(Team::class);
}
public function database(): MorphTo
{
return $this->morphTo();

View file

@ -31,7 +31,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$dangerousChars = [
';', '|', '&', '$', '`', '(', ')', '{', '}',
'[', ']', '<', '>', '\n', '\r', '\0', '"', "'",
'\\', '!', '?', '*', '~', '^', '%', '=', '+',
'\\', '!', '?', '*', '^', '%', '=', '+',
'#', // Comment character that could hide commands
];
@ -85,7 +85,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
// Validate SSH URL format (git@host:user/repo.git)
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) {
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) {
$fail('The :attribute is not a valid SSH repository URL.');
return;

View file

@ -135,7 +135,13 @@ function getPermissionsPath(GithubApp $source)
function loadRepositoryByPage(GithubApp $source, string $token, int $page)
{
$response = Http::withToken($token)->get("{$source->api_url}/installation/repositories?per_page=100&page={$page}");
$response = Http::GitHub($source->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get('/installation/repositories', [
'per_page' => 100,
'page' => $page,
]);
$json = $response->json();
if ($response->status() !== 200) {
return [

View file

@ -84,64 +84,6 @@ function () use ($source, $dest, $server) {
);
}
function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Generate unique filename for server transfer
$server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
// Transfer file to server
instant_scp($temp_file, $server_temp_file, $server, $throwError);
// Ensure parent directory exists in container, then copy file
$parent_dir = dirname($container_path);
$commands = [];
if ($parent_dir !== '.' && $parent_dir !== '/') {
$commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
}
$commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
$commands[] = "rm -f $server_temp_file"; // Cleanup server temp file
return instant_remote_process_with_timeout($commands, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
{
$temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
try {
// Write content to temporary file
file_put_contents($temp_file, $content);
// Ensure parent directory exists on server
$parent_dir = dirname($server_path);
if ($parent_dir !== '.' && $parent_dir !== '/') {
instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
}
// Transfer file directly to server destination
return instant_scp($temp_file, $server_path, $server, $throwError);
} finally {
// Always cleanup local temp file
if (file_exists($temp_file)) {
unlink($temp_file);
}
}
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;

View file

@ -634,10 +634,14 @@ function getTopLevelNetworks(Service|Application $resource)
$definedNetwork = collect([$resource->uuid]);
$services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) {
$serviceNetworks = collect(data_get($service, 'networks', []));
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false;
$networkMode = data_get($service, 'network_mode');
// Only add 'networks' key if 'network_mode' is not 'host'
if (! $hasHostNetworkMode) {
$hasValidNetworkMode =
$networkMode === 'host' ||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
// Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:'
if (! $hasValidNetworkMode) {
// Collect/create/update networks
if ($serviceNetworks->count() > 0) {
foreach ($serviceNetworks as $networkName => $networkDetails) {
@ -1272,7 +1276,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceNetworks = collect(data_get($service, 'networks', []));
$serviceVariables = collect(data_get($service, 'environment', []));
$serviceLabels = collect(data_get($service, 'labels', []));
$hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false;
$networkMode = data_get($service, 'network_mode');
$hasValidNetworkMode =
$networkMode === 'host' ||
(is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:')));
if ($serviceLabels->count() > 0) {
$removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
@ -1383,7 +1392,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$savedService->ports = $collectedPorts->implode(',');
$savedService->save();
if (! $hasHostNetworkMode) {
if (! $hasValidNetworkMode) {
// Add Coolify specific networks
$definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) {
return $value == $definedNetwork;

View file

@ -6,10 +6,31 @@ @utility apexcharts-tooltip-title {
@apply hidden!;
}
@utility apexcharts-grid-borders {
@apply dark:hidden!;
}
@utility apexcharts-xaxistooltip {
@apply hidden!;
}
@utility apexcharts-tooltip-custom {
@apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm;
min-width: 160px;
}
@utility apexcharts-tooltip-custom-value {
@apply text-neutral-700 dark:text-neutral-300 mb-1;
}
@utility apexcharts-tooltip-value-bold {
@apply font-bold text-black dark:text-white;
}
@utility apexcharts-tooltip-custom-title {
@apply text-xs text-neutral-500 dark:text-neutral-400 font-medium;
}
@utility input-sticky {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300;
}

View file

@ -11,6 +11,7 @@
'content' => null,
'checkboxes' => [],
'actions' => [],
'warningMessage' => null,
'confirmWithText' => true,
'confirmationText' => 'Confirm Deletion',
'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below',
@ -228,7 +229,7 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
<div x-show="step === 2">
<div class="p-4 mb-4 text-white border-l-4 border-red-500 bg-error" role="alert">
<p class="font-bold">Warning</p>
<p>This operation is permanent and cannot be undone. Please think again before proceeding!
<p>{!! $warningMessage ?: 'This operation is permanent and cannot be undone. Please think again before proceeding!' !!}
</p>
</div>
<div class="mb-4">The following actions will be performed:</div>

View file

@ -35,9 +35,9 @@
@endphp
<title>{{ $name }}{{ $title ?? 'Coolify' }}</title>
@env('local')
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/x-icon" />
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/png" />
@else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/x-icon" />
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/js/app.js', 'resources/css/app.css'])
@ -138,7 +138,8 @@
}
}
let theme = localStorage.theme
let baseColor = '#FCD452'
let cpuColor = '#1e90ff'
let ramColor = '#00ced1'
let textColor = '#ffffff'
let editorBackground = '#181818'
let editorTheme = 'blackboard'
@ -149,12 +150,14 @@ function checkTheme() {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
if (theme == 'dark') {
baseColor = '#FCD452'
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#ffffff'
editorBackground = '#181818'
editorTheme = 'blackboard'
} else {
baseColor = 'black'
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#000000'
editorBackground = '#ffffff'
editorTheme = null

View file

@ -113,7 +113,7 @@ class="flex flex-col-reverse w-full p-2 px-4 mt-4 overflow-y-auto bg-white dark:
])>
<span x-show="showTimestamps" class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
<span @class([
'text-coollabs dark:text-warning' => $line['hidden'],
'text-success dark:text-warning' => $line['hidden'],
'text-red-500' => $line['stderr'],
'font-bold' => isset($line['command']) && $line['command'],
'whitespace-pre-wrap',

View file

@ -7,7 +7,7 @@
@if ($isLocked)
<div class="flex flex-1 w-full gap-2">
<x-forms.input disabled id="key" />
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg class="icon my-1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6z" />
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4" />

View file

@ -1,21 +1,20 @@
<div>
<div class="flex items-center gap-2 ">
<div class="flex items-center gap-2">
<h2>Metrics</h2>
</div>
<div class="pb-4">Basic metrics for your container.</div>
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
@elseif(!$resource->destination->server->isMetricsEnabled())
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div> Go to <a class="underline dark:text-white"
href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to
enable
it.</div>
@else
@if (!str($resource->status)->contains('running'))
<div class="alert alert-warning">Metrics are only available when this resource is running!</div>
<div class="pb-4">Basic metrics for your application container.</div>
<div>
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
@elseif(!$resource->destination->server->isMetricsEnabled())
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to enable it.</div>
@else
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
@if (!str($resource->status)->contains('running'))
<div class="alert alert-warning">Metrics are only available when the application container is running!</div>
@else
<div>
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
<option value="5">5 minutes (live)</option>
<option value="10">10 minutes (live)</option>
<option value="30">30 minutes</option>
@ -26,7 +25,7 @@
</x-forms.select>
<div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()"
class="pt-5">
<h4>CPU (%)</h4>
<h4>CPU Usage</h4>
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script>
@ -34,6 +33,7 @@ class="pt-5">
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
@ -52,7 +52,7 @@ class="pt-5">
},
},
animations: {
enabled: false,
enabled: true,
},
},
fill: {
@ -68,74 +68,90 @@ class="pt-5">
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [baseColor],
xaxis: {
type: 'datetime',
},
series: [{
name: "CPU %",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}
},
legend: {
show: false
}
grid: {
show: true,
borderColor: '',
},
colors: [cpuColor],
xaxis: {
type: 'datetime',
},
series: [{
name: "CPU %",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [baseColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
</script>
<h3>Memory (MB)</h3>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
@ -143,6 +159,7 @@ class="pt-5">
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
@ -161,7 +178,7 @@ class="pt-5">
},
},
animations: {
enabled: false,
enabled: true,
},
},
fill: {
@ -177,81 +194,99 @@ class="pt-5">
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [baseColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (MB)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}
},
legend: {
show: false
}
grid: {
show: true,
borderColor: '',
},
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (MB)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [baseColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' MB';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
</script>
</div>
</div>
@endif
@endif
</div>
</div>

View file

@ -7,7 +7,7 @@
<x-server.sidebar :server="$server" activeMenu="metrics" />
<div class="w-full">
<h2>Metrics</h2>
<div class="pb-4">Basic metrics for your container.</div>
<div class="pb-4">Basic metrics for your server.</div>
@if ($server->isMetricsEnabled())
<div @if ($poll) wire:poll.5000ms='pollData' @endif x-init="$wire.loadData()">
<x-forms.select label="Interval" wire:change="setInterval" id="interval">
@ -19,7 +19,7 @@
<option value="10080">1 week</option>
<option value="43200">30 days</option>
</x-forms.select>
<h4 class="pt-4">CPU (%)</h4>
<h4 class="pt-4">CPU Usage</h4>
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script>
@ -27,6 +27,7 @@
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
@ -45,7 +46,7 @@
},
},
animations: {
enabled: false,
enabled: true,
},
},
fill: {
@ -61,16 +62,16 @@
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [baseColor],
xaxis: {
type: 'datetime',
},
series: [{
name: 'CPU %',
grid: {
show: true,
borderColor: '',
},
colors: [cpuColor],
xaxis: {
type: 'datetime',
},
series: [{
name: 'CPU %',
data: []
}],
noData: {
@ -79,12 +80,27 @@
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
@ -95,11 +111,11 @@
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [baseColor],
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
@ -109,15 +125,18 @@
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
@ -130,7 +149,7 @@
</script>
<div>
<h4>Memory (%)</h4>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
@ -138,6 +157,7 @@
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
@ -156,7 +176,7 @@
},
},
animations: {
enabled: false,
enabled: true,
},
},
fill: {
@ -172,15 +192,15 @@
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [baseColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
grid: {
show: true,
borderColor: '',
},
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
@ -196,12 +216,27 @@
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
@ -212,11 +247,11 @@
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [baseColor],
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
@ -226,16 +261,19 @@
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {

View file

@ -7,23 +7,23 @@
<div class="flex items-center gap-2">
<h2>Configuration</h2>
@if ($server->proxy->status === 'exited' || $server->proxy->status === 'removing')
<x-forms.button canGate="update" :canResource="$server" wire:click.prevent="changeProxy">Switch
Proxy</x-forms.button>
@can('update', $server)
<x-modal-confirmation title="Confirm Proxy Switching?" buttonTitle="Switch Proxy"
submitAction="changeProxy" :actions="['Custom proxy configurations may be reset to their default settings.']"
warningMessage="This operation may cause issues. Please refer to the guide <a href='https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies' target='_blank' class='underline text-white'>switching between proxies</a> before proceeding!"
step2ButtonText="Switch Proxy" :confirmWithText="false" :confirmWithPassword="false">
</x-modal-confirmation>
@endcan
@else
<x-forms.button canGate="update" :canResource="$server" disabled
wire:click.prevent="changeProxy">Switch Proxy</x-forms.button>
<x-forms.button canGate="update" :canResource="$server"
wire:click="$dispatch('error', 'Currently running proxy must be stopped before switching proxy')">Switch
Proxy</x-forms.button>
@endif
<x-forms.button canGate="update" :canResource="$server" type="submit">Save</x-forms.button>
</div>
<div class="pb-4 "> <svg class="inline-flex w-6 h-6 mr-2 dark:text-warning" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
</svg>Before switching proxies, please read <a class="underline dark:text-white"
href="https://coolify.io/docs/knowledge-base/server/proxies#switch-between-proxies">this</a>.
</div>
<div class="subtitle">Configure your proxy settings and advanced options.</div>
<h3>Advanced</h3>
<div class="pb-4 w-96">
<div class="pb-6 w-96">
<x-forms.checkbox canGate="update" :canResource="$server"
helper="If set, all resources will only have docker container labels for {{ str($server->proxyType())->title() }}.<br>For applications, labels needs to be regenerated manually. <br>Resources needs to be restarted."
id="server.settings.generate_exact_labels"
@ -36,10 +36,31 @@
id="redirectUrl" label="Redirect to (optional)" />
@endif
</div>
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<h3>Traefik</h3>
@elseif ($server->proxyType() === 'CADDY')
<h3>Caddy</h3>
@php
$proxyTitle =
$server->proxyType() === ProxyTypes::TRAEFIK->value
? 'Traefik (Coolify Proxy)'
: 'Caddy (Coolify Proxy)';
@endphp
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value || $server->proxyType() === 'CADDY')
<div class="flex items-center gap-2">
<h3>{{ $proxyTitle }}</h3>
@if ($proxySettings)
@can('update', $server)
<x-modal-confirmation title="Reset Proxy Configuration?"
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
:actions="[
'Reset proxy configuration to default settings',
'All custom configurations will be lost',
'Custom ports and entrypoints will be removed',
]" confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the server name below"
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
:confirmWithPassword="false" :confirmWithText="true">
</x-modal-confirmation>
@endcan
@endif
</div>
@endif
@if (
$server->proxy->last_applied_settings &&
@ -53,25 +74,11 @@
</div>
<div wire:loading.remove wire:target="loadProxyConfiguration">
@if ($proxySettings)
<div class="flex flex-col gap-2 pt-4">
<div class="flex flex-col gap-2 pt-2">
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
monacoEditorLanguage="yaml"
label="Configuration file ({{ $this->configurationFilePath }})" name="proxySettings"
id="proxySettings" rows="30" />
@can('update', $server)
<x-modal-confirmation title="Reset Proxy Configuration?"
buttonTitle="Reset configuration to default" isErrorButton
submitAction="resetProxyConfiguration" :actions="[
'Reset proxy configuration to default settings',
'All custom configurations will be lost',
'Custom ports and entrypoints will be removed',
]"
confirmationText="{{ $server->name }}"
confirmationLabel="Please confirm by entering the server name below"
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
:confirmWithPassword="false" :confirmWithText="true">
</x-modal-confirmation>
@endcan
label="Configuration file ( {{ $this->configurationFilePath }} )"
name="proxySettings" id="proxySettings" rows="30" />
</div>
@endif
</div>

View file

@ -3,6 +3,7 @@
use App\Http\Controllers\Api\ApplicationsController;
use App\Http\Controllers\Api\DatabasesController;
use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@ -23,6 +24,7 @@
});
Route::post('/feedback', [OtherController::class, 'feedback']);
Route::group([
'middleware' => ['auth:sanctum', 'api.ability:write'],
'prefix' => 'v1',
@ -102,6 +104,12 @@
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
Route::patch('/github-apps/{github_app_id}', [GithubController::class, 'update_github_app'])->middleware(['api.ability:write']);
Route::delete('/github-apps/{github_app_id}', [GithubController::class, 'delete_github_app'])->middleware(['api.ability:write']);
Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']);
Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']);
Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']);
Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']);
Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']);
@ -113,8 +121,13 @@
Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']);
Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']);
Route::get('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions', [DatabasesController::class, 'list_backup_executions'])->middleware(['api.ability:read']);
Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);