diff --git a/app/Http/Controllers/api/v1/ServerPoolController.php b/app/Http/Controllers/api/v1/ServerPoolController.php index 87e1172271..2b8a40af26 100644 --- a/app/Http/Controllers/api/v1/ServerPoolController.php +++ b/app/Http/Controllers/api/v1/ServerPoolController.php @@ -34,11 +34,12 @@ public function __construct() public function index(ServerPoolIndexRequest $request) { $additionalMeta = []; - $resource = ServerPool::withCount('servers'); + $resource = ServerPool::withCount(['servers', 'backupServers']); // Sort by column, fallback/default is id $sortBy = match ($request->query('sort_by')) { 'servers_count' => 'servers_count', + 'backup_servers_count' => 'backup_servers_count', 'name' => 'LOWER(name)', default => 'id', }; @@ -89,6 +90,7 @@ public function update(ServerPoolRequest $request, ServerPool $serverPool) $serverPool->name = $request->name; $serverPool->save(); $serverPool->servers()->sync($request->servers); + $serverPool->backupServers()->sync($request->backup_servers); return (new ServerPoolResource($serverPool))->withServers(); } @@ -105,6 +107,7 @@ public function store(ServerPoolRequest $request) $serverPool->name = $request->name; $serverPool->save(); $serverPool->servers()->sync($request->servers); + $serverPool->backupServers()->sync($request->backup_servers); return (new ServerPoolResource($serverPool))->withServers(); } diff --git a/app/Http/Requests/ServerPoolRequest.php b/app/Http/Requests/ServerPoolRequest.php index 30ebfd2aa1..ac709c74de 100644 --- a/app/Http/Requests/ServerPoolRequest.php +++ b/app/Http/Requests/ServerPoolRequest.php @@ -15,11 +15,16 @@ public function rules() 'name' => ['required', 'string', 'max:255', Rule::unique('server_pools', 'name')], 'description' => ['nullable', 'string', 'max:255'], 'servers' => 'array', + // Backup servers are optional + // but if provided, they must be distinct and exist in the database + 'backup_servers' => 'array', 'servers.*' => ['distinct', 'integer', 'exists:App\Models\Server,id'], + 'backup_servers.*' => ['distinct', 'integer', 'exists:App\Models\Server,id'], ]; if ($this->serverPool) { $rules['name'] = ['required', 'string', 'max:255', Rule::unique('server_pools', 'name')->ignore($this->serverPool->id)]; + } return $rules; diff --git a/app/Http/Resources/ServerPoolResource.php b/app/Http/Resources/ServerPoolResource.php index 52cf49343d..06421c2d08 100644 --- a/app/Http/Resources/ServerPoolResource.php +++ b/app/Http/Resources/ServerPoolResource.php @@ -42,6 +42,10 @@ public function toArray($request) 'servers' => $this->when($this->withServers, function () { return ServerResource::collection($this->servers); }), + 'backup_servers_count' => $this->backupServers()->count(), + 'backup_servers' => $this->when($this->withServers, function () { + return ServerResource::collection($this->backupServers); + }), 'model_name' => $this->model_name, 'updated_at' => $this->updated_at, ]; diff --git a/app/Models/ServerPool.php b/app/Models/ServerPool.php index 79b790a884..abf2c32ce1 100644 --- a/app/Models/ServerPool.php +++ b/app/Models/ServerPool.php @@ -28,6 +28,14 @@ public function servers(): BelongsToMany return $this->belongsToMany(Server::class); } + /** + * Backup-Servers that are part of this server pool + */ + public function backupServers(): BelongsToMany + { + return $this->belongsToMany(Server::class, 'backup_server_server_pool'); + } + /** * RoomTypes that are using this server pool * diff --git a/app/Services/LoadBalancingService.php b/app/Services/LoadBalancingService.php index 62b4025d70..d04cab2a6e 100644 --- a/app/Services/LoadBalancingService.php +++ b/app/Services/LoadBalancingService.php @@ -12,19 +12,40 @@ class LoadBalancingService { private $servers; + private $backupServers; + public function setServerPool(ServerPool $serverPool) { $this->servers = $serverPool->servers; + $this->backupServers = $serverPool->backupServers; return $this; } /** * Find server in the pool with the lowest usage + * + * @return array{server:? Server, isPrefered:bool} */ - public function getLowestUsageServer(): ?Server + public function getLowestUsageServer(): array { - return $this->servers + $preferredServer = $this->servers + ->where('status', ServerStatus::ENABLED) + ->where('recover_count', '>=', config('bigbluebutton.server_online_threshold')) + ->where('error_count', '=', 0) + ->whereNotNull('load') + ->sortBy(function (Server $server) { + return $server->load / $server->strength; + }) + ->first(); + + // Check if the preferred server is available + if ($preferredServer) { + return ['server' => $preferredServer, 'isPreferred' => true]; + } + + // If no server is found, check backup servers + $backupServer = $this->backupServers ->where('status', ServerStatus::ENABLED) ->where('recover_count', '>=', config('bigbluebutton.server_online_threshold')) ->where('error_count', '=', 0) @@ -33,5 +54,7 @@ public function getLowestUsageServer(): ?Server return $server->load / $server->strength; }) ->first(); + + return ['server' => $backupServer, 'isPreferred' => false]; } } diff --git a/app/Services/MeetingService.php b/app/Services/MeetingService.php index 45822b46fd..e78d26381c 100644 --- a/app/Services/MeetingService.php +++ b/app/Services/MeetingService.php @@ -69,7 +69,7 @@ public function getCallbackUrl(): string /** * Start meeting with the properties saved for this meeting and room */ - public function start(): ?CreateMeetingResponse + public function start(bool $isPreferredServer): ?CreateMeetingResponse { // Set meeting parameters $meetingParams = new CreateMeetingParameters($this->meeting->id, $this->meeting->room->name); @@ -107,6 +107,12 @@ public function start(): ?CreateMeetingResponse $meetingParams->addMeta('bbb-origin', 'PILOS'); $meetingParams->addMeta('pilos-sub-spool-dir', config('recording.spool-sub-directory')); + /* @TODO, optionally show banner on backup-server + if (! $isPreferredServer) { + $meetingParams->setBannerText('Test'); + $meetingParams->setBannerColor('#DF2721'); + }*/ + // get files that should be used in this meeting and add links to the files $files = $this->meeting->room->files()->where('use_in_meeting', true)->orderBy('default', 'desc')->get(); foreach ($files as $file) { diff --git a/app/Services/RoomService.php b/app/Services/RoomService.php index ac0b095fd4..424a3d399f 100644 --- a/app/Services/RoomService.php +++ b/app/Services/RoomService.php @@ -63,10 +63,15 @@ public function start(): MeetingService // Basic load balancing: get server with the lowest usage $loadBalancingService = new LoadBalancingService; - $server = $loadBalancingService + ['server' => $server, 'isPreferred' => $isPreferredServer] = $loadBalancingService ->setServerPool($this->room->roomType->serverPool) ->getLowestUsageServer(); + // No preferred Server found, but backup server found + if (! $isPreferredServer && $server != null) { + Log::warning('No preferred server found! Fallback to backup server', ['room' => $this->room->getLogLabel()]); + } + // If no server found, throw error if ($server == null) { $lock->release(); @@ -84,20 +89,21 @@ public function start(): MeetingService $meeting->save(); $meetingService = new MeetingService($meeting); + $serverType = $isPreferredServer ? 'server' : 'backup server'; - Log::info('Starting new meeting for room {room} on server {server}', ['room' => $this->room->getLogLabel(), 'server' => $server->getLogLabel()]); - if (! $meetingService->start()) { + Log::info('Starting new meeting for room {room} on {serverType} {server}', ['room' => $this->room->getLogLabel(), 'serverType' => $serverType, 'server' => $server->getLogLabel()]); + if (! $meetingService->start($isPreferredServer)) { // Creating Meeting failed, remove meeting $meeting->forceDelete(); $lock->release(); - Log::error('Failed to start meeting for room {room} on server {server}', ['room' => $this->room->getLogLabel(), 'server' => $server->getLogLabel()]); + Log::error('Failed to start meeting for room {room} on {serverType} {server}', ['room' => $this->room->getLogLabel(), 'serverType' => $serverType, 'server' => $server->getLogLabel()]); Counter::get('room_start_errors_total')->inc('start_failed'); abort(CustomStatusCodes::ROOM_START_FAILED->value, __('app.errors.room_start')); } Counter::get('room_started_total')->inc(); - Log::info('Successfully started new meeting for room {room} on server {server}', ['room' => $this->room->getLogLabel(), 'server' => $server->getLogLabel()]); + Log::info('Successfully started new meeting for room {room} on {serverType} {server}', ['room' => $this->room->getLogLabel(), 'server' => $server->getLogLabel()]); // Set start time after successful api call, prevents server poller from ending a meeting that has been started // but the api call has not been completed yet therefore the meeting will not be found on the server diff --git a/database/migrations/2025_06_05_075646_create_backup_server_server_pool_table.php b/database/migrations/2025_06_05_075646_create_backup_server_server_pool_table.php new file mode 100644 index 0000000000..cf10ff2624 --- /dev/null +++ b/database/migrations/2025_06_05_075646_create_backup_server_server_pool_table.php @@ -0,0 +1,29 @@ +foreignId('server_id')->constrained()->onDelete('cascade'); + $table->foreignId('server_pool_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_server_server_pool'); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 776656a683..9a484570a1 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -94,8 +94,10 @@ 'view_all' => 'Show all rooms', ], 'server_pools' => [ + 'backup_servers' => 'Backup servers', 'create' => 'Create server pools', 'delete' => 'Delete server pools', + 'select_server' => 'Select server', 'title' => 'Server pools', 'update' => 'Edit server pools', 'view' => 'Show server pools', @@ -191,6 +193,8 @@ 'view' => 'Detailed information for the room type :name', ], 'server_pools' => [ + 'backup_servers' => 'Backup servers', + 'backup_server_count' => 'Number of backup servers', 'delete' => [ 'confirm' => 'Do you really want to delete the server pool :name?', 'failed' => 'Server pool can\'t be deleted because the following room types still use it:', diff --git a/resources/js/views/AdminServerPoolsIndex.vue b/resources/js/views/AdminServerPoolsIndex.vue index aec4ae7b16..65fe60397f 100644 --- a/resources/js/views/AdminServerPoolsIndex.vue +++ b/resources/js/views/AdminServerPoolsIndex.vue @@ -99,6 +99,11 @@ field="servers_count" sortable > + + + + {{ $t("admin.server_pools.backup_servers") }} + + + + + {{ $t("admin.servers.no_data") }} + + + {{ option.name }} + + + + {{ option.name }} + + + + + + + + + + + + { serversMultiselectRef.value.deactivate(); + backupServersMultiselectRef.value.deactivate(); serversLoadingError.value = true; api.error(error); }) @@ -363,6 +458,9 @@ function saveServerPool() { }; config.data.servers = config.data.servers.map((server) => server.id); + config.data.backup_servers = config.data.backup_servers.map( + (backupServer) => backupServer.id, + ); api .call(