mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 07:50:35 -05:00
Merge branch 'main' into next
This commit is contained in:
@@ -50,12 +50,22 @@ class DeleteTeam
|
||||
->get()
|
||||
->each(function (User $member) use ($team): void {
|
||||
$member->teams()->detach($team);
|
||||
$member->clearStoredTeamIfMatches($team->id);
|
||||
DB::table('sessions')->where('user_id', $member->id)->delete();
|
||||
});
|
||||
|
||||
// The deleting owner is excluded from the loop above; clear their
|
||||
// stored team too so the deleted id is not restored on next login.
|
||||
$user->clearStoredTeamIfMatches($team->id);
|
||||
|
||||
$team->delete();
|
||||
|
||||
return $user->teams()->first();
|
||||
// Resolve the next active team the same way login does: the user's
|
||||
// stored choice when still valid, or their sole remaining team.
|
||||
// Returns null for a multi-team user whose active team was just
|
||||
// deleted, so refreshSession sends them to the selection screen
|
||||
// instead of silently dropping them into an arbitrary first team.
|
||||
return User::query()->find($user->id)?->resolveStoredTeam();
|
||||
});
|
||||
|
||||
Cache::forget("user:{$user->id}:team:{$team->id}");
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ScheduledJobDiagnostics extends Command
|
||||
@@ -203,7 +204,6 @@ class ScheduledJobDiagnostics extends Command
|
||||
}
|
||||
|
||||
$dedupKeys = [
|
||||
"sentinel-restart:{$server->id}" => '0 0 * * *',
|
||||
"server-patch-check:{$server->id}" => '0 0 * * 0',
|
||||
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
|
||||
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
|
||||
@@ -235,7 +235,7 @@ class ScheduledJobDiagnostics extends Command
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
private function getServers(?string $serverFilter): \Illuminate\Support\Collection
|
||||
private function getServers(?string $serverFilter): Collection
|
||||
{
|
||||
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4');
|
||||
|
||||
|
||||
@@ -1487,7 +1487,13 @@ class ApplicationsController extends Controller
|
||||
$application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
|
||||
$application->domain_port_overrides = $domainPortOverrides;
|
||||
}
|
||||
$repository_url_parsed = Url::fromString($request->git_repository);
|
||||
$gitRepository = $application->git_repository;
|
||||
$httpsRepository = scpStyleGitUrlToHttps($gitRepository);
|
||||
if (is_string($httpsRepository)) {
|
||||
$gitRepository = $httpsRepository;
|
||||
$application->git_repository = $httpsRepository;
|
||||
}
|
||||
$repository_url_parsed = Url::fromString($gitRepository);
|
||||
$git_host = $repository_url_parsed->getHost();
|
||||
if ($git_host === 'github.com') {
|
||||
$application->source_type = GithubApp::class;
|
||||
@@ -1649,11 +1655,7 @@ class ApplicationsController extends Controller
|
||||
return response()->json(['message' => 'Failed to generate Github App token.'], 400);
|
||||
}
|
||||
|
||||
$gitRepository = $request->git_repository;
|
||||
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
|
||||
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
|
||||
}
|
||||
$gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
|
||||
$gitRepository = gitRepositorySlug($request->git_repository);
|
||||
|
||||
// Use direct API call to verify repository access instead of loading all repositories
|
||||
// This is much faster and avoids timeouts for GitHub Apps with many repositories
|
||||
|
||||
@@ -12,7 +12,6 @@ use OpenApi\Attributes as OA;
|
||||
class ServerSentinelController extends Controller
|
||||
{
|
||||
private const ALLOWED_FIELDS = [
|
||||
'is_sentinel_enabled',
|
||||
'is_metrics_enabled',
|
||||
'is_sentinel_debug_enabled',
|
||||
'sentinel_token',
|
||||
@@ -43,7 +42,7 @@ class ServerSentinelController extends Controller
|
||||
{
|
||||
$settings = $server->settings;
|
||||
$payload = [
|
||||
'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled,
|
||||
'is_sentinel_enabled' => $server->isSentinelEnabled(),
|
||||
'is_metrics_enabled' => (bool) $settings->is_metrics_enabled,
|
||||
'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled,
|
||||
'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds,
|
||||
@@ -83,7 +82,7 @@ class ServerSentinelController extends Controller
|
||||
description: 'Sentinel settings.',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'),
|
||||
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
@@ -139,7 +138,6 @@ class ServerSentinelController extends Controller
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'sentinel_token', type: 'string'),
|
||||
@@ -186,7 +184,6 @@ class ServerSentinelController extends Controller
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'is_sentinel_enabled' => 'boolean',
|
||||
'is_metrics_enabled' => 'boolean',
|
||||
'is_sentinel_debug_enabled' => 'boolean',
|
||||
'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'],
|
||||
@@ -224,29 +221,12 @@ class ServerSentinelController extends Controller
|
||||
}
|
||||
|
||||
$settings = $server->settings;
|
||||
$enablingSentinel = $request->has('is_sentinel_enabled')
|
||||
&& $request->boolean('is_sentinel_enabled')
|
||||
&& ! $settings->is_sentinel_enabled;
|
||||
|
||||
if ($enablingSentinel && $server->isBuildServer()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
foreach (self::ALLOWED_FIELDS as $field) {
|
||||
if ($request->has($field)) {
|
||||
$settings->{$field} = $request->input($field);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel).
|
||||
if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) {
|
||||
$settings->is_metrics_enabled = false;
|
||||
$settings->is_sentinel_debug_enabled = false;
|
||||
}
|
||||
|
||||
$settings->save();
|
||||
|
||||
auditLog('api.server.sentinel.updated', [
|
||||
|
||||
@@ -24,6 +24,14 @@ class OauthController extends Controller
|
||||
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
|
||||
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
|
||||
|
||||
$team = $user->resolveStoredTeam();
|
||||
if (! $team && $user->teams()->count() === 0) {
|
||||
$team = $user->recreate_personal_team();
|
||||
}
|
||||
if ($team) {
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
} catch (\Exception $e) {
|
||||
$this->logCallbackFailure($provider, $e);
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Webhook\Concerns;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait MatchesManualWebhookApplications
|
||||
{
|
||||
@@ -79,12 +78,8 @@ trait MatchesManualWebhookApplications
|
||||
|
||||
if (is_array($parts) && isset($parts['scheme'])) {
|
||||
$path = data_get($parts, 'path');
|
||||
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
|
||||
$path = Str::after($gitRepository, ':');
|
||||
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
|
||||
// Strip the leading numeric port segment so the path matches the webhook
|
||||
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
|
||||
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
|
||||
} elseif (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
|
||||
$path = $scp['path'];
|
||||
} else {
|
||||
$path = $gitRepository;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,24 @@ class DecideWhatToDoWithUser
|
||||
}
|
||||
if (auth()?->user()?->currentTeam()) {
|
||||
refreshSession(auth()->user()->currentTeam());
|
||||
// A team is already active; the selection screen no longer applies.
|
||||
if ($request->routeIs('team.select')) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
} elseif (auth()?->user()?->teams?->count() > 0) {
|
||||
// User's session team is invalid (e.g., removed from team), switch to first available team
|
||||
refreshSession(auth()->user()->teams->first());
|
||||
// No active team in the session (fresh login or invalidated selection).
|
||||
// Restore the last active team, or the sole team of a single-team user.
|
||||
$resolvedTeam = auth()->user()->resolveStoredTeam();
|
||||
if ($resolvedTeam) {
|
||||
refreshSession($resolvedTeam);
|
||||
} elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) {
|
||||
// Ambiguous choice: let the user pick a team on the selection screen.
|
||||
// Livewire's update endpoint must pass through too, otherwise the
|
||||
// selection action's AJAX call is redirected to HTML and never runs.
|
||||
return $next($request);
|
||||
} else {
|
||||
return redirect()->route('team.select');
|
||||
}
|
||||
}
|
||||
if (! auth()->user() || ! isCloud()) {
|
||||
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {
|
||||
|
||||
@@ -166,14 +166,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
$isSentinelEnabled = $server->isSentinelEnabled();
|
||||
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
|
||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||
|
||||
if ($shouldRestartSentinel) {
|
||||
CheckAndStartSentinelJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
|
||||
// When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data
|
||||
if ($sentinelOutOfSync) {
|
||||
@@ -195,7 +187,6 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
|
||||
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,9 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
// Broadcast events to update UI
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
ServerReachabilityChanged::dispatch($this->server);
|
||||
if ($this->server->isSentinelEnabled()) {
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ValidateAndInstallServer: Exception occurred', [
|
||||
|
||||
@@ -33,7 +33,7 @@ class Index extends Component
|
||||
if (session('impersonating')) {
|
||||
session()->forget('impersonating');
|
||||
$user = User::find(0);
|
||||
$team_to_switch_to = $user->teams->first();
|
||||
$team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
|
||||
Auth::login($user);
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
@@ -69,7 +69,7 @@ class Index extends Component
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
$team_to_switch_to = $user->teams->first();
|
||||
$team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
|
||||
Auth::login($user);
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
|
||||
@@ -216,6 +216,14 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
|
||||
}
|
||||
|
||||
if (($scp = parseScpStyleGitUrl($this->repository_url)) !== null) {
|
||||
$this->git_host = $scp['host'];
|
||||
$this->git_repository = $this->repository_url;
|
||||
$this->git_source = 'other';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->repository_url_parsed = Url::fromString($this->repository_url);
|
||||
$this->git_host = $this->repository_url_parsed->getHost();
|
||||
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);
|
||||
|
||||
@@ -137,10 +137,9 @@ class PublicGitRepository extends Component
|
||||
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
|
||||
}
|
||||
|
||||
if (str($this->repository_url)->startsWith('git@')) {
|
||||
$github_instance = str($this->repository_url)->after('git@')->before(':');
|
||||
$repository = str($this->repository_url)->after(':')->before('.git');
|
||||
$this->repository_url = 'https://'.str($github_instance).'/'.$repository;
|
||||
$httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url);
|
||||
if (is_string($httpsRepositoryUrl)) {
|
||||
$this->repository_url = $httpsRepositoryUrl;
|
||||
}
|
||||
if (
|
||||
(str($this->repository_url)->startsWith('https://') ||
|
||||
|
||||
@@ -151,13 +151,18 @@ class ExecuteContainerCommand extends Component
|
||||
});
|
||||
|
||||
if ($this->containers->count() === 1) {
|
||||
$this->selected_container = data_get($this->containers->first(), 'container.Names');
|
||||
$this->selected_container = $this->containerTarget($this->containers->first());
|
||||
$this->connectToContainer();
|
||||
}
|
||||
|
||||
$this->containersLoaded = true;
|
||||
}
|
||||
|
||||
private function containerTarget(array $container): string
|
||||
{
|
||||
return data_get($container, 'server.uuid').':'.data_get($container, 'container.Names');
|
||||
}
|
||||
|
||||
public function updatedSelectedContainer()
|
||||
{
|
||||
if ($this->selected_container !== 'default') {
|
||||
@@ -202,12 +207,12 @@ class ExecuteContainerCommand extends Component
|
||||
try {
|
||||
$this->authorize('canAccessTerminal');
|
||||
// Validate container name format
|
||||
if (! ValidationPatterns::isValidContainerName($this->selected_container)) {
|
||||
if (! ValidationPatterns::isValidContainerName(str($this->selected_container)->after(':')->value())) {
|
||||
throw new \InvalidArgumentException('Invalid container name format');
|
||||
}
|
||||
|
||||
// Verify container exists in our allowed list
|
||||
$container = collect($this->containers)->firstWhere('container.Names', $this->selected_container);
|
||||
$container = $this->containers->first(fn ($candidate) => $this->containerTarget($candidate) === $this->selected_container);
|
||||
if (is_null($container)) {
|
||||
throw new \RuntimeException('Container not found.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class SelectTeam extends Component
|
||||
{
|
||||
// mount()/selectTeam() intentionally have no return type: Livewire's
|
||||
// redirect() returns a Redirector (not an Illuminate RedirectResponse),
|
||||
// matching the convention in sibling components such as SwitchTeam.
|
||||
public function mount()
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// A team is already active, or the user has at most one team: nothing to pick.
|
||||
if ($user->currentTeam() || $user->teams->count() <= 1) {
|
||||
$resolved = $user->resolveStoredTeam();
|
||||
if ($resolved) {
|
||||
refreshSession($resolved);
|
||||
}
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
public function selectTeam(int $teamId)
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (! $user->teams->contains('id', $teamId)) {
|
||||
return;
|
||||
}
|
||||
$team = Team::find($teamId);
|
||||
if (! $team) {
|
||||
return;
|
||||
}
|
||||
refreshSession($team);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.select-team', [
|
||||
'teams' => auth()->user()->teams,
|
||||
])->layout('layouts.simple');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Actions\Server\StopSentinel;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
@@ -25,8 +23,6 @@ class Sentinel extends Component
|
||||
#[Validate(['nullable', 'url'])]
|
||||
public ?string $sentinelCustomUrl = null;
|
||||
|
||||
public bool $isSentinelEnabled;
|
||||
|
||||
public bool $isSentinelDebugEnabled;
|
||||
|
||||
public ?string $sentinelCustomDockerImage = null;
|
||||
@@ -52,14 +48,12 @@ class Sentinel extends Component
|
||||
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
|
||||
$this->server->settings->sentinel_token = $this->sentinelToken;
|
||||
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
|
||||
$this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled;
|
||||
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
|
||||
$this->server->settings->save();
|
||||
} else {
|
||||
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
|
||||
$this->sentinelToken = $this->server->settings->sentinel_token;
|
||||
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
|
||||
$this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled;
|
||||
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
}
|
||||
@@ -88,33 +82,6 @@ class Sentinel extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSentinel(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if (! $this->isSentinelEnabled) {
|
||||
if ($this->server->isBuildServer()) {
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
|
||||
$this->isSentinelEnabled = true;
|
||||
} else {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
}
|
||||
$this->submit();
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateSentinelToken()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Server\Sentinel;
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\View\View;
|
||||
@@ -30,35 +29,6 @@ class Logs extends Component
|
||||
$this->authorize('viewSentinel', $this->server);
|
||||
}
|
||||
|
||||
public function enableSentinel(): void
|
||||
{
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
|
||||
try {
|
||||
$this->server->refresh();
|
||||
if ($this->server->isBuildServer()) {
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->server->isSwarm()) {
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->server->isSentinelEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
StartSentinel::run($this->server, true);
|
||||
$this->server->refresh();
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('success', 'Sentinel has been enabled.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.sentinel.logs');
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Actions\Server\StopSentinel;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\CloudProviderToken;
|
||||
@@ -67,8 +66,6 @@ class Show extends Component
|
||||
|
||||
public ?string $sentinelCustomUrl = null;
|
||||
|
||||
public bool $isSentinelEnabled;
|
||||
|
||||
public bool $isSentinelDebugEnabled;
|
||||
|
||||
public ?string $sentinelCustomDockerImage = null;
|
||||
@@ -161,7 +158,6 @@ class Show extends Component
|
||||
'sentinelMetricsHistoryDays' => 'required|integer|min:1',
|
||||
'sentinelPushIntervalSeconds' => 'required|integer|min:10',
|
||||
'sentinelCustomUrl' => 'nullable|url',
|
||||
'isSentinelEnabled' => 'required',
|
||||
'isSentinelDebugEnabled' => 'required',
|
||||
'serverTimezone' => 'required',
|
||||
];
|
||||
@@ -265,7 +261,6 @@ class Show extends Component
|
||||
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
|
||||
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
|
||||
$this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl;
|
||||
$this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled;
|
||||
$this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled;
|
||||
|
||||
if (! validate_timezone($this->serverTimezone)) {
|
||||
@@ -296,7 +291,6 @@ class Show extends Component
|
||||
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
|
||||
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
|
||||
$this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url;
|
||||
$this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled;
|
||||
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
|
||||
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
|
||||
$this->serverTimezone = $this->server->settings->server_timezone;
|
||||
@@ -425,10 +419,10 @@ class Show extends Component
|
||||
|
||||
return;
|
||||
}
|
||||
if ($value === true && $this->isSentinelEnabled) {
|
||||
$this->isSentinelEnabled = false;
|
||||
if ($value === true && $this->server->isSentinelEnabled()) {
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
$this->server->settings->is_sentinel_enabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
$this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
|
||||
}
|
||||
@@ -440,30 +434,6 @@ class Show extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsSentinelEnabled($value)
|
||||
{
|
||||
try {
|
||||
$this->authorize('manageSentinel', $this->server);
|
||||
if ($value === true) {
|
||||
if ($this->isBuildServer) {
|
||||
$this->isSentinelEnabled = false;
|
||||
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
|
||||
|
||||
return;
|
||||
}
|
||||
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
|
||||
StartSentinel::run($this->server, true, null, $customImage);
|
||||
} else {
|
||||
$this->isMetricsEnabled = false;
|
||||
$this->isSentinelDebugEnabled = false;
|
||||
StopSentinel::dispatch($this->server);
|
||||
}
|
||||
$this->submit();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerateSentinelToken()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Server;
|
||||
use App\Actions\Proxy\CheckProxy;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Events\ServerValidated;
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -275,6 +276,9 @@ class ValidateAndInstall extends Component
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('refreshBoardingIndex');
|
||||
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
|
||||
if ($this->server->isSentinelEnabled()) {
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
$this->dispatch('success', 'Server validated, proxy is starting in a moment.');
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
if (! $proxyShouldRun) {
|
||||
|
||||
@@ -92,6 +92,7 @@ class Member extends Component
|
||||
DB::transaction(function () use ($teamId): void {
|
||||
$this->member->teams()->detach($teamId);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
$this->member->clearStoredTeamIfMatches($teamId);
|
||||
});
|
||||
auditLog('ui.team_member.removed', [
|
||||
'team_id' => $teamId,
|
||||
|
||||
+25
-19
@@ -665,15 +665,13 @@ class Application extends BaseModel
|
||||
|
||||
return "{$this->source->html_url}/{$this->git_repository}/tree/{$this->git_branch}{$base_dir}";
|
||||
}
|
||||
// Convert the SSH URL to HTTPS URL
|
||||
if (strpos($this->git_repository, 'git@') === 0) {
|
||||
$git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository);
|
||||
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
if (str($this->git_repository)->contains('bitbucket')) {
|
||||
return "https://{$git_repository}/src/{$this->git_branch}{$base_dir}";
|
||||
return "{$httpsRepository}/src/{$this->git_branch}{$base_dir}";
|
||||
}
|
||||
|
||||
return "https://{$git_repository}/tree/{$this->git_branch}{$base_dir}";
|
||||
return "{$httpsRepository}/tree/{$this->git_branch}{$base_dir}";
|
||||
}
|
||||
|
||||
return $this->git_repository;
|
||||
@@ -688,11 +686,9 @@ class Application extends BaseModel
|
||||
if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) {
|
||||
return "{$this->source->html_url}/{$this->git_repository}/settings/hooks";
|
||||
}
|
||||
// Convert the SSH URL to HTTPS URL
|
||||
if (strpos($this->git_repository, 'git@') === 0) {
|
||||
$git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository);
|
||||
|
||||
return "https://{$git_repository}/settings/hooks";
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
return "{$httpsRepository}/settings/hooks";
|
||||
}
|
||||
|
||||
return $this->git_repository;
|
||||
@@ -707,11 +703,9 @@ class Application extends BaseModel
|
||||
if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) {
|
||||
return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}";
|
||||
}
|
||||
// Convert the SSH URL to HTTPS URL
|
||||
if (strpos($this->git_repository, 'git@') === 0) {
|
||||
$git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository);
|
||||
|
||||
return "https://{$git_repository}/commits/{$this->git_branch}";
|
||||
$httpsRepository = $this->httpsUrlFromScpStyleGitRepository();
|
||||
if (is_string($httpsRepository)) {
|
||||
return "{$httpsRepository}/commits/{$this->git_branch}";
|
||||
}
|
||||
|
||||
return $this->git_repository;
|
||||
@@ -730,8 +724,9 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
$git_repository = $this->git_repository;
|
||||
if (strpos($this->git_repository, 'git@') === 0) {
|
||||
$git_repository = preg_replace('/^git@([^:]+):/', 'https://$1/', $git_repository);
|
||||
$httpsRepository = scpStyleGitUrlToHttps($git_repository);
|
||||
if (is_string($httpsRepository)) {
|
||||
$git_repository = $httpsRepository;
|
||||
} elseif (str($this->git_repository)->startsWith('ssh://')) {
|
||||
$git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH);
|
||||
}
|
||||
@@ -748,6 +743,17 @@ class Application extends BaseModel
|
||||
return $url->__toString();
|
||||
}
|
||||
|
||||
private function httpsUrlFromScpStyleGitRepository(): ?string
|
||||
{
|
||||
$httpsRepository = scpStyleGitUrlToHttps($this->git_repository);
|
||||
|
||||
if (! is_string($httpsRepository)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::replaceEnd('.git', '', $httpsRepository);
|
||||
}
|
||||
|
||||
public function dockerfileLocation(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -1479,7 +1485,7 @@ class Application extends BaseModel
|
||||
// Check if .gitmodules file exists before running submodule commands
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
|
||||
if ($public) {
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
|
||||
$git_clone_command = "{$git_clone_command} sed -i \"s#[A-Za-z0-9._-]*@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
|
||||
}
|
||||
// Add shallow submodules flag if shallow clone is enabled
|
||||
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
|
||||
|
||||
@@ -973,12 +973,15 @@ $siteAddress {
|
||||
return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck()));
|
||||
}
|
||||
|
||||
public function isSentinelEnabled()
|
||||
public function isSentinelEnabled(): bool
|
||||
{
|
||||
return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && ! $this->isBuildServer();
|
||||
return ! $this->isBuildServer()
|
||||
&& ! $this->isSwarm()
|
||||
&& ! $this->isForceDisabled()
|
||||
&& ! $this->isTransferredAway();
|
||||
}
|
||||
|
||||
public function isMetricsEnabled()
|
||||
public function isMetricsEnabled(): bool
|
||||
{
|
||||
return $this->settings->is_metrics_enabled;
|
||||
}
|
||||
@@ -988,7 +991,7 @@ $siteAddress {
|
||||
return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false);
|
||||
}
|
||||
|
||||
public function isServerApiEnabled()
|
||||
public function isServerApiEnabled(): bool
|
||||
{
|
||||
return $this->settings->is_sentinel_enabled;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'current_team_id',
|
||||
'force_password_reset',
|
||||
'marketing_emails',
|
||||
'pending_email',
|
||||
@@ -67,6 +68,7 @@ class User extends Authenticatable implements SendsEmail
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'current_team_id' => 'integer',
|
||||
'email_verified_at' => 'datetime',
|
||||
'force_password_reset' => 'boolean',
|
||||
'show_boarding' => 'boolean',
|
||||
@@ -375,6 +377,54 @@ class User extends Authenticatable implements SendsEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the team to activate when the session has no current team
|
||||
* (fresh login or an invalidated session).
|
||||
*
|
||||
* Returns the user's last active team when they still belong to it, or the
|
||||
* sole team of a single-team user. Returns null when the choice is ambiguous
|
||||
* (more than one team and no valid stored preference) — the caller must then
|
||||
* prompt the user to pick a team instead of defaulting silently.
|
||||
*/
|
||||
public function resolveStoredTeam(): ?Team
|
||||
{
|
||||
if (! is_null($this->current_team_id)) {
|
||||
$storedTeam = $this->teams->firstWhere('id', $this->current_team_id);
|
||||
if ($storedTeam) {
|
||||
return $storedTeam;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->teams->count() === 1) {
|
||||
return $this->teams->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the persisted active team when it points to the given team.
|
||||
*
|
||||
* Called when the user is removed from a team (or the team is deleted) so a
|
||||
* stale current_team_id can never be trusted after the fact. Read paths
|
||||
* already re-validate membership; this is defense-in-depth that clears the
|
||||
* dangling value at the source event instead of relying on self-healing.
|
||||
*/
|
||||
public function clearStoredTeamIfMatches(int $teamId): void
|
||||
{
|
||||
// Atomic conditional update: only null the column when the database value
|
||||
// still points at this team, so a newer team selection made concurrently
|
||||
// (in another request) is preserved rather than clobbered.
|
||||
static::query()
|
||||
->whereKey($this->getKey())
|
||||
->where('current_team_id', $teamId)
|
||||
->update(['current_team_id' => null]);
|
||||
|
||||
if ($this->current_team_id === $teamId) {
|
||||
$this->current_team_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function role(): ?string
|
||||
{
|
||||
if (data_get($this, 'pivot')) {
|
||||
|
||||
@@ -90,14 +90,19 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
}
|
||||
$user->currentTeam = $invitation->team;
|
||||
$invitation->delete();
|
||||
session(['currentTeam' => $user->currentTeam]);
|
||||
} else {
|
||||
// Normal login - use personal team
|
||||
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
|
||||
if (! $user->currentTeam) {
|
||||
$user->currentTeam = $user->recreate_personal_team();
|
||||
// Restore the last active team; only fall back when unambiguous.
|
||||
$team = $user->resolveStoredTeam();
|
||||
if (! $team && $user->teams->isEmpty()) {
|
||||
$team = $user->recreate_personal_team();
|
||||
}
|
||||
if ($team) {
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
}
|
||||
// Otherwise (multiple teams, no stored choice) leave the session
|
||||
// team unset so the user is sent to the team-selection screen.
|
||||
}
|
||||
session(['currentTeam' => $user->currentTeam]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -77,15 +77,16 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
}
|
||||
|
||||
// Validate based on URL type
|
||||
if (str_starts_with($value, 'git@')) {
|
||||
if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) {
|
||||
if (! $this->allowSSH) {
|
||||
$fail('SSH URLs are not allowed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate SSH URL format (git@host:user/repo.git)
|
||||
if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) {
|
||||
// Validate scp-style SSH URL format (user@host:user/repo.git)
|
||||
$scp = parseScpStyleGitUrl($value);
|
||||
if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) {
|
||||
$fail('The :attribute is not a valid SSH repository URL.');
|
||||
|
||||
return;
|
||||
@@ -149,7 +150,7 @@ class ValidGitRepositoryUrl implements ValidationRule
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
$fail('The :attribute must start with https://, http://, git://, or git@.');
|
||||
$fail('The :attribute must start with https://, http://, git://, or be an SSH URL (user@host:path).');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class ServerTransferClaimer
|
||||
if ($rebindSentinel && $server->settings) {
|
||||
$server->settings->sentinel_custom_url = $instanceUrl;
|
||||
$server->settings->ensureValidSentinelToken();
|
||||
// Leave sentinel disabled until operator enables metrics; endpoint is ready.
|
||||
$server->settings->is_sentinel_enabled = true;
|
||||
$server->settings->save();
|
||||
$sentinelRebound = true;
|
||||
}
|
||||
|
||||
@@ -570,8 +570,11 @@ function refreshSession(?Team $team = null): void
|
||||
$team = Team::find($currentTeam->id);
|
||||
}
|
||||
if (! $team) {
|
||||
// Fall back to any team the user still belongs to.
|
||||
$team = User::query()->find(Auth::id())?->teams()->first();
|
||||
// Fall back to the user's resolvable team (stored choice, or their
|
||||
// sole team). Returns null for a multi-team user with no valid stored
|
||||
// choice, so an arbitrary first team is never silently persisted —
|
||||
// the user is sent to the selection screen instead.
|
||||
$team = User::query()->find(Auth::id())?->resolveStoredTeam();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,8 +584,13 @@ function refreshSession(?Team $team = null): void
|
||||
if (! $team) {
|
||||
// The user has no team left (e.g. just deleted their current team and
|
||||
// belongs to no other): clear the stale session reference instead of
|
||||
// dereferencing null.
|
||||
// dereferencing null, and drop the persisted choice so it is not
|
||||
// restored on next login.
|
||||
session()->forget('currentTeam');
|
||||
$user = Auth::user();
|
||||
if ($user && ! is_null($user->current_team_id)) {
|
||||
$user->forceFill(['current_team_id' => null])->saveQuietly();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -593,6 +601,15 @@ function refreshSession(?Team $team = null): void
|
||||
return $team;
|
||||
});
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
// Persist the active team so it can be restored after logout/login — but
|
||||
// never while an admin is impersonating, so viewing another user's account
|
||||
// does not overwrite that user's real last-active team.
|
||||
$user = Auth::user();
|
||||
if ($user && ! session('impersonating') && $user->current_team_id !== $team->id) {
|
||||
$user->current_team_id = $team->id;
|
||||
$user->saveQuietly();
|
||||
}
|
||||
}
|
||||
function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null)
|
||||
{
|
||||
@@ -4341,6 +4358,62 @@ NGINX;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an scp-style SSH Git URL (`user@host:path` or `user@host:port/path`).
|
||||
*
|
||||
* @return array{user: string, host: string, port: ?string, path: string}|null
|
||||
*/
|
||||
function parseScpStyleGitUrl(?string $gitRepository): ?array
|
||||
{
|
||||
if (! is_string($gitRepository) || $gitRepository === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>[^:]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim($matches['host']);
|
||||
$path = ltrim($matches['path'], '/');
|
||||
|
||||
if ($host === '' || $path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $matches['user'],
|
||||
'host' => $host,
|
||||
'port' => ($matches['port'] ?? '') === '' ? null : $matches['port'],
|
||||
'path' => $path,
|
||||
];
|
||||
}
|
||||
|
||||
function scpStyleGitUrlToHttps(?string $gitRepository): ?string
|
||||
{
|
||||
$parts = parseScpStyleGitUrl($gitRepository);
|
||||
|
||||
if ($parts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'https://'.$parts['host'].'/'.$parts['path'];
|
||||
}
|
||||
|
||||
function gitRepositorySlug(?string $gitRepository): string
|
||||
{
|
||||
if (! is_string($gitRepository) || $gitRepository === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
|
||||
$gitRepository = $scp['path'];
|
||||
} elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
|
||||
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
|
||||
}
|
||||
|
||||
return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
|
||||
}
|
||||
|
||||
function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array
|
||||
{
|
||||
$repository = $gitRepository;
|
||||
@@ -4351,7 +4424,6 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
|
||||
'repository' => $gitRepository,
|
||||
];
|
||||
$sshMatches = [];
|
||||
$matches = [];
|
||||
|
||||
// Let's try and parse the string to detect if it's a valid SSH string or not
|
||||
preg_match('/((.*?)\:\/\/)?(.*@.*:.*)/', $gitRepository, $sshMatches);
|
||||
@@ -4386,11 +4458,11 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|
|
||||
$providerInfo['port'] = (string) $parsedRepository['port'];
|
||||
}
|
||||
} else {
|
||||
preg_match('/^(?<host>[^:]+):(?<port>\d+)\/(?<path>.+)$/', $normalizedRepository, $matches);
|
||||
$scp = parseScpStyleGitUrl($normalizedRepository);
|
||||
|
||||
if (! empty($matches['port'])) {
|
||||
$providerInfo['port'] = $matches['port'];
|
||||
$repository = "{$matches['host']}:{$matches['path']}";
|
||||
if ($scp !== null && $scp['port'] !== null) {
|
||||
$providerInfo['port'] = $scp['port'];
|
||||
$repository = "{$scp['user']}@{$scp['host']}:{$scp['path']}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Last active team, restored on login. Nullable: no persisted choice yet.
|
||||
// Not a foreign key because team ids include the 0 sentinel and teams can
|
||||
// be deleted out from under a user; validity is checked against the user's
|
||||
// team membership at read time.
|
||||
$table->unsignedBigInteger('current_team_id')->nullable()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('current_team_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('server_settings')
|
||||
->where('is_sentinel_enabled', false)
|
||||
->where('is_build_server', false)
|
||||
->where('is_swarm_manager', false)
|
||||
->where('is_swarm_worker', false)
|
||||
->where('force_disabled', false)
|
||||
->where('is_reachable', true)
|
||||
->where('is_usable', true)
|
||||
->update(['is_sentinel_enabled' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Existing values cannot be distinguished from values enabled before this migration.
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -13,6 +14,10 @@ class SentinelSeeder extends Seeder
|
||||
Server::chunk(100, function ($servers) {
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
if ($server->isSentinelEnabled()) {
|
||||
$server->settings->is_sentinel_enabled = true;
|
||||
$server->settings->saveQuietly();
|
||||
}
|
||||
if (str($server->settings->sentinel_token)->isEmpty()) {
|
||||
$server->settings->generateSentinelToken(ignoreEvent: true);
|
||||
}
|
||||
@@ -25,11 +30,10 @@ class SentinelSeeder extends Seeder
|
||||
}
|
||||
|
||||
if (str($server->settings->sentinel_custom_url)->isEmpty()) {
|
||||
$url = $server->settings->generateSentinelUrl(ignoreEvent: true);
|
||||
if (str($url)->isEmpty()) {
|
||||
$server->settings->is_sentinel_enabled = false;
|
||||
$server->settings->save();
|
||||
}
|
||||
$server->settings->generateSentinelUrl(ignoreEvent: true);
|
||||
}
|
||||
if ($server->isFunctional() && $server->isSentinelEnabled() && filled($server->settings->sentinel_custom_url)) {
|
||||
CheckAndStartSentinelJob::dispatch($server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error seeding sentinel: '.$e->getMessage());
|
||||
|
||||
@@ -244,60 +244,28 @@
|
||||
];
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$activeMenuLabel = collect($groupedMenuItems)->flatMap(fn ($items) => $items)->firstWhere('active', true)['label'] ?? 'Settings';
|
||||
@endphp
|
||||
<aside @class([
|
||||
'application-settings-navigation min-w-0 xl:self-start',
|
||||
'is-flush' => $flush,
|
||||
])
|
||||
x-data="{
|
||||
menuOpen: false,
|
||||
desktop: window.matchMedia('(min-width: 1280px)').matches,
|
||||
init() {
|
||||
const mq = window.matchMedia('(min-width: 1280px)');
|
||||
mq.addEventListener('change', (e) => { this.desktop = e.matches; if (e.matches) { this.menuOpen = false; } });
|
||||
},
|
||||
}"
|
||||
x-on:click.outside="menuOpen = false"
|
||||
x-on:keydown.escape.window="menuOpen = false">
|
||||
{{-- Mobile disclosure: tap to reveal the full grouped nav; hidden at xl (the pinned rail). --}}
|
||||
<button type="button" x-show="!desktop" x-cloak x-on:click="menuOpen = !menuOpen"
|
||||
:aria-expanded="menuOpen" aria-label="Configuration menu"
|
||||
:class="menuOpen && 'ring-1 ring-black/10 dark:ring-white/15'"
|
||||
class="flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-neutral-200 bg-white px-3 text-[13px] font-medium text-black transition-transform duration-100 ease-out hover:bg-neutral-50 active:scale-[0.985] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg dark:hover:bg-white/[0.08]">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<x-reicon name="settings" class="size-4 shrink-0 text-nav-muted" />
|
||||
<span class="truncate">{{ $activeMenuLabel }}</span>
|
||||
</span>
|
||||
<svg class="size-3.5 shrink-0 text-nav-muted transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
:class="menuOpen && 'rotate-180'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<nav aria-label="Configuration sections" x-show="desktop || menuOpen" x-collapse.duration.200ms x-cloak
|
||||
class="mt-2 flex flex-col gap-0.5 rounded-xl border border-neutral-200 bg-white p-2 shadow-[var(--shadow-dropdown)] dark:border-white/[0.08] dark:bg-white/[0.03] xl:mt-0 xl:rounded-none xl:border-0 xl:bg-transparent xl:p-0 xl:shadow-none xl:dark:bg-transparent">
|
||||
])>
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
||||
@unless ($loop->first)
|
||||
<div class="my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
|
||||
@endunless
|
||||
<div class="nav-section">{{ $groupLabel }}</div>
|
||||
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
|
||||
@foreach ($groupItems as $menuItem)
|
||||
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
|
||||
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
|
||||
@if (filled($sections)) x-data="{ open: @js($menuItem['active']), activeSection: '' }" @endif
|
||||
class="relative">
|
||||
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
|
||||
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
|
||||
@class([
|
||||
'menu-item',
|
||||
'menu-item-active' => $menuItem['active'],
|
||||
'pr-9' => filled($sections),
|
||||
])
|
||||
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($menuItem['route'], $applicationRouteParameters) }}"
|
||||
x-on:click="menuOpen = false">
|
||||
>
|
||||
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
@if ($menuItem['badge'] ?? false)
|
||||
@@ -308,37 +276,23 @@
|
||||
@endif
|
||||
</a>
|
||||
@if (filled($sections))
|
||||
{{-- Expand/collapse the page's sub-sections. Active page opens by
|
||||
default; the label still navigates, the chevron only toggles. --}}
|
||||
<button type="button"
|
||||
class="absolute right-1 top-1 flex size-6 items-center justify-center rounded-md text-nav-muted transition-colors hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.06]"
|
||||
x-on:click.stop.prevent="open = !open" :aria-expanded="open"
|
||||
aria-label="Toggle {{ $menuItem['label'] }} sections">
|
||||
<svg class="size-3.5 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
:class="open && 'rotate-90'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m4.5 3 3 3-3 3" stroke="currentColor" stroke-width="1.25"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<div x-show="open" x-collapse.duration.200ms x-cloak
|
||||
class="nav-children flex flex-col gap-0.5 py-1">
|
||||
@foreach ($sections as $section)
|
||||
@if ($menuItem['active'])
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
x-on:click="menuOpen = false; activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@else
|
||||
<a class="menu-subitem"
|
||||
href="{{ route($menuItem['route'], $applicationRouteParameters) }}#{{ $section['id'] }}"
|
||||
{{ wireNavigate() }} x-on:click="menuOpen = false">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{ activeSection: '' }">
|
||||
@foreach ($sections as $section)
|
||||
@if ($menuItem['active'])
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
x-on:click="activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@else
|
||||
<a class="menu-subitem"
|
||||
href="{{ route($menuItem['route'], $applicationRouteParameters) }}#{{ $section['id'] }}"
|
||||
{{ wireNavigate() }}>
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<livewire:project.shared.configuration-checker :resource="$application" />
|
||||
<livewire:project.application.heading :application="$application" :wire:key="'application-heading-'.$currentRoute" />
|
||||
|
||||
<section class="application-settings-workspace w-full max-w-none">
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
|
||||
<x-application.configuration-sidebar :application="$application" :current-route="$currentRoute" />
|
||||
|
||||
|
||||
@@ -16,21 +16,13 @@
|
||||
domainSearch: '',
|
||||
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
|
||||
editingServiceLabel: @js($editingService ?? ''),
|
||||
editingDomainBaseline: null,
|
||||
get hasAddressChanges() {
|
||||
return this.modalOpen && this.editingDomainBaseline !== null
|
||||
&& JSON.stringify($wire.editingDomainParts) !== this.editingDomainBaseline
|
||||
&& !$wire.showPortWarningModal && !$wire.showDomainConflictModal;
|
||||
},
|
||||
openEditDomain() {
|
||||
this.editingDomainBaseline = JSON.stringify($wire.editingDomainParts);
|
||||
this.editingServiceLabel = $wire.editingService || '';
|
||||
this.modalOpen = true;
|
||||
this.$nextTick(() => document.getElementById('editingDomainParts-host')?.focus?.());
|
||||
},
|
||||
closeEditDomain() {
|
||||
this.modalOpen = false;
|
||||
this.editingDomainBaseline = null;
|
||||
this.editingServiceLabel = '';
|
||||
},
|
||||
matchesDomainSearch(value) {
|
||||
@@ -299,13 +291,11 @@
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
<form wire:submit="updateDomain" class="flex flex-col gap-4">
|
||||
<template x-if="modalOpen">
|
||||
<x-unsaved-bar action="updateDomain" dirty="hasAddressChanges"
|
||||
targets="updateDomain,confirmUpdateDomainDespiteDns" />
|
||||
</template>
|
||||
<div class="application-settings-section-body relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<form wire:submit="updateDomain" class="flex min-h-0 flex-1 flex-col">
|
||||
<div data-testid="domain-settings-scroll"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain pb-4"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
<div x-show="editingServiceLabel" x-cloak class="w-full">
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label>
|
||||
@@ -326,23 +316,18 @@
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">Continue</x-forms.button>
|
||||
@endif
|
||||
</form>
|
||||
@php
|
||||
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
|
||||
@endphp
|
||||
@if ($editingRow && ! $labelsAreWritable)
|
||||
@can('update', $application)
|
||||
@php
|
||||
$editingKey = hash('sha256', $editingRow['url'].'|'.($editingRow['service'] ?? ''));
|
||||
$editingRedirectKey = $isCompose ? $this->serviceRedirectWireKey($editingRow['service']) : null;
|
||||
$editingRedirectProperty = $isCompose ? 'serviceRedirects.'.$editingRedirectKey : 'redirect';
|
||||
@endphp
|
||||
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
|
||||
class="mt-4 grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
|
||||
<p class="sm:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">Indexing and redirect changes save automatically.</p>
|
||||
@php
|
||||
$editingRow = $editingIndex !== null ? ($domainRows[$editingIndex] ?? null) : null;
|
||||
@endphp
|
||||
@if ($editingRow && ! $labelsAreWritable)
|
||||
@can('update', $application)
|
||||
@php
|
||||
$editingKey = hash('sha256', $editingRow['url'].'|'.($editingRow['service'] ?? ''));
|
||||
$editingRedirectKey = $isCompose ? $this->serviceRedirectWireKey($editingRow['service']) : null;
|
||||
$editingRedirectProperty = $isCompose ? 'serviceRedirects.'.$editingRedirectKey : 'redirect';
|
||||
@endphp
|
||||
<div wire:key="editing-application-domain-settings-{{ $editingKey }}"
|
||||
class="grid grid-cols-1 gap-4 border-t border-neutral-200 pt-4 sm:grid-cols-2 dark:border-white/10">
|
||||
<x-forms.listbox id="application-domain-indexing-{{ $editingKey }}"
|
||||
label="Search engine indexing" :wire="false" preserveValue
|
||||
:value="$application->isDomainNoindexed($editingRow['url']) ? 'noindex' : 'index'"
|
||||
@@ -363,10 +348,26 @@
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div data-testid="domain-settings-footer"
|
||||
class="shrink-0 border-t border-neutral-200 pt-4 dark:border-white/10">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
@if ($editDomainDnsFailed)
|
||||
<x-forms.button type="button" isError wire:click="confirmUpdateDomainDespiteDns">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>
|
||||
Save
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,12 +26,11 @@
|
||||
@endphp
|
||||
<div>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
{{-- Identity row: name truncates, status + links stay pinned right. --}}
|
||||
<div class="flex w-full min-w-0 items-center gap-3">
|
||||
<h1 class="min-w-0 flex-1 truncate text-[22px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $application->name }}
|
||||
</h1>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div class="relative flex w-full min-w-0 items-center gap-2">
|
||||
<x-status-summary :status="$application->status" align="right" />
|
||||
<x-applications.links :application="$application" compact />
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
$consoleThemeNames = collect($consoleThemes)->pluck('name', 'key');
|
||||
$consoleThemeAccents = collect($consoleThemes)->pluck('accent', 'key');
|
||||
$containerOptions = $containers->map(fn ($container) => [
|
||||
'value' => data_get($container, 'container.Names'),
|
||||
'value' => data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'),
|
||||
'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'),
|
||||
])->values();
|
||||
@endphp
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</a>
|
||||
</x-slot:actions>
|
||||
<x-empty size="sm" title="Metrics are not enabled"
|
||||
description="Enable Sentinel and metrics for this server before collecting application usage data."
|
||||
description="Enable metrics for this server before collecting application usage data."
|
||||
icon-name="dashboard" />
|
||||
</x-application.settings-section>
|
||||
@elseif (!str($resource->status)->contains('running'))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-auth.shell title="Select a team"
|
||||
description="Choose the team you want to work in. Your choice is remembered for next time.">
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($teams as $team)
|
||||
<button type="button" wire:click="selectTeam({{ $team->id }})"
|
||||
class="group flex items-center gap-3 rounded-lg border border-neutral-200 px-3 py-2.5 text-left transition-colors hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.08] dark:hover:border-white/[0.16] dark:hover:bg-white/[0.04]">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-[13px] font-semibold text-neutral-600 dark:bg-white/[0.06] dark:text-fg">
|
||||
{{ strtoupper(mb_substr($team->name, 0, 1)) }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-black dark:text-fg">
|
||||
{{ $team->name }}
|
||||
</span>
|
||||
<x-reicon name="arrow-right" class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" />
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-auth.shell>
|
||||
@@ -293,14 +293,14 @@
|
||||
@else
|
||||
<x-application.settings-section id="server-metrics-overview-section" title="Metrics"
|
||||
helper="Inspect recent CPU and memory usage reported by Sentinel.">
|
||||
<x-empty size="sm" title="Sentinel is required"
|
||||
description="Enable Sentinel before collecting CPU and memory metrics for this server."
|
||||
<x-empty size="sm" title="Metrics unavailable"
|
||||
description="Sentinel metrics are unavailable on build and Swarm servers."
|
||||
icon-name="dashboard">
|
||||
<x-slot:contents>
|
||||
<a class="button"
|
||||
href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }}>
|
||||
Configure Sentinel
|
||||
View Sentinel
|
||||
<x-external-link />
|
||||
</a>
|
||||
</x-slot:contents>
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
<div class="application-settings-form flex w-full flex-col gap-6">
|
||||
<form wire:submit.prevent="submit" class="contents">
|
||||
@if ($isSentinelEnabled)
|
||||
{{-- Scope dirty tracking to savable form fields only. Without wire:target,
|
||||
Livewire compares the entire component snapshot — so dev-only x-init
|
||||
`$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly
|
||||
flashes this bar on every page open. --}}
|
||||
<x-unsaved-bar action="submit"
|
||||
targets="sentinelCustomUrl,sentinelToken" />
|
||||
@endif
|
||||
{{-- Scope dirty tracking to savable form fields only. Without wire:target,
|
||||
Livewire compares the entire component snapshot — so dev-only x-init
|
||||
`$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly
|
||||
flashes this bar on every page open. --}}
|
||||
<x-unsaved-bar action="submit"
|
||||
targets="sentinelCustomUrl,sentinelToken" />
|
||||
|
||||
<x-application.settings-section id="server-sentinel-overview-section" title="Sentinel"
|
||||
helper="Monitor server and container health while collecting historical metrics.">
|
||||
<x-slot:actions>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!$isSentinelEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted
|
||||
wire:click="toggleSentinel">
|
||||
Enable Sentinel
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
|
||||
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
|
||||
<x-forms.button wire:click="restartSentinel" canGate="update"
|
||||
:canResource="$server">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
{{ $server->isSentinelLive() ? 'Restart' : 'Sync' }}
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="update" :canResource="$server"
|
||||
wire:click="toggleSentinel">
|
||||
Disable
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
|
||||
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
|
||||
<x-forms.button wire:click="restartSentinel" canGate="update"
|
||||
:canResource="$server">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
{{ $server->isSentinelLive() ? 'Restart' : 'Sync' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($isSentinelEnabled && !$server->isSentinelLive())
|
||||
@if (!$server->isSentinelLive())
|
||||
<x-callout type="warning" title="Sentinel is out of sync">
|
||||
Sync Sentinel to apply its current configuration and restore health reporting.
|
||||
</x-callout>
|
||||
@elseif ($isSentinelEnabled)
|
||||
@else
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
|
||||
@@ -51,10 +38,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-empty size="sm" title="Sentinel is disabled"
|
||||
description="Enable Sentinel to collect metrics and monitor server and container health."
|
||||
icon-name="dashboard" />
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
|
||||
@@ -21,14 +21,8 @@
|
||||
displayName="Sentinel" :collapsible="false" />
|
||||
</div>
|
||||
@else
|
||||
<x-slot:actions>
|
||||
<x-forms.button canGate="manageSentinel" :canResource="$server" isHighlighted
|
||||
wire:click="enableSentinel">
|
||||
Enable Sentinel
|
||||
</x-forms.button>
|
||||
</x-slot:actions>
|
||||
<x-empty size="sm" title="Sentinel is disabled"
|
||||
description="Enable Sentinel to view its logs."
|
||||
<x-empty size="sm" title="Sentinel is unavailable"
|
||||
description="Sentinel does not run on build or Swarm servers."
|
||||
icon-name="dashboard" />
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -53,6 +53,7 @@ use App\Livewire\Security\CloudTokens;
|
||||
use App\Livewire\Security\IntegrationTokens;
|
||||
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
|
||||
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
|
||||
use App\Livewire\SelectTeam;
|
||||
use App\Livewire\Server\Advanced as ServerAdvanced;
|
||||
use App\Livewire\Server\Analytics\Show as ServerAnalytics;
|
||||
use App\Livewire\Server\CaCertificate\Show as CaCertificateShow;
|
||||
@@ -410,6 +411,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
});
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/select-team', SelectTeam::class)->name('team.select');
|
||||
Route::get('/sources', function () {
|
||||
$sources = currentTeam()->sources();
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Storage::fake('ssh-keys');
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->bearerToken = $this->user->createToken('public-ssh-url-api-test', ['*'])->plainTextToken;
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
test('public application api converts scp-style ssh urls to https', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->git_repository)->toBe('https://git.example.com/organization/repository.git');
|
||||
});
|
||||
@@ -12,6 +12,11 @@ use Illuminate\Support\Facades\Queue;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.store' => 'array',
|
||||
'cache.default' => 'array',
|
||||
'cache.stores.redis.driver' => 'array',
|
||||
]);
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
@@ -236,6 +241,17 @@ describe('Sentinel API', function () {
|
||||
->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH rejects disabling mandatory Sentinel', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
|
||||
'is_sentinel_enabled' => false,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('is_sentinel_enabled');
|
||||
|
||||
expect($this->server->fresh()->isSentinelEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('other-team sentinel endpoints return 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel")
|
||||
|
||||
@@ -2064,12 +2064,17 @@ it('uses concise search indexing headers in application and service domain table
|
||||
->toContain('<span>Search indexing</span>');
|
||||
});
|
||||
|
||||
it('shows save guidance in the application domain settings', function () {
|
||||
it('shows a form save button at the bottom of application domain settings', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('Indexing and redirect changes save automatically.')
|
||||
->toContain('<x-unsaved-bar action="updateDomain"');
|
||||
->not->toContain('Indexing and redirect changes save automatically.')
|
||||
->toContain('data-testid="domain-settings-scroll"')
|
||||
->toContain('data-testid="domain-settings-footer"')
|
||||
->toContain('class="shrink-0 border-t')
|
||||
->toContain('<x-forms.button type="submit" wire:target="updateDomain" isHighlighted>')
|
||||
->toContain('Save')
|
||||
->not->toContain('<x-unsaved-bar action="updateDomain"');
|
||||
});
|
||||
|
||||
it('does not render a last checked column in the domains table', function () {
|
||||
|
||||
@@ -69,6 +69,28 @@ it('applies http 1 transport to https fetches after clone', function () {
|
||||
->toContain("git -c http.version=HTTP/1.1 -c advice.detachedHead=false checkout 'abc123def456abc123def456abc123def456abc1'");
|
||||
});
|
||||
|
||||
it('rewrites generic ssh submodule remotes to https for public clones', function () {
|
||||
$application = applicationWithGitSettings(shallow: false);
|
||||
$application->settings->is_git_submodules_enabled = true;
|
||||
|
||||
$source = new GithubApp;
|
||||
$source->forceFill([
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'is_public' => true,
|
||||
]);
|
||||
$application->setRelation('source', $source);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-deployment',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
expect($result['commands'])
|
||||
->toContain('sed -i "s#[A-Za-z0-9._-]*@\(.*\):#https://\\1/#g"')
|
||||
->not->toContain('s#git@\(.*\):#https://\\1/#g');
|
||||
});
|
||||
|
||||
it('does not add http transport config to ssh deploy key clones', function () {
|
||||
$application = applicationWithGitSettings();
|
||||
$application->private_key_id = 1;
|
||||
|
||||
@@ -61,6 +61,14 @@ test('convertGitUrlsForSourceAndSshUrlWithCustomPort', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlWithCustomUsernameAndPort', function () {
|
||||
$result = convertGitUrl('custom-user@git.domain.com:766/group/project.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'custom-user@git.domain.com:group/project.git',
|
||||
'port' => '766',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
|
||||
$result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -8,7 +7,7 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not start Sentinel after it has been disabled', function () {
|
||||
it('treats Sentinel as enabled for regular servers even when the legacy flag and metrics are disabled', function () {
|
||||
DB::table('instance_settings')->insert(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
@@ -19,7 +18,38 @@ it('does not start Sentinel after it has been disabled', function () {
|
||||
'is_sentinel_enabled' => false,
|
||||
]);
|
||||
|
||||
(new CheckAndStartSentinelJob($server))->handle();
|
||||
expect($server->fresh()->isSentinelEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
expect((bool) $server->settings->fresh()->is_sentinel_enabled)->toBeFalse();
|
||||
it('does not enable Sentinel for excluded server types', function (array $settings, array $metadata = []) {
|
||||
DB::table('instance_settings')->insert(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $user->teams()->first()->id,
|
||||
'server_metadata' => $metadata,
|
||||
]);
|
||||
$server->settings->update(array_merge([
|
||||
'is_metrics_enabled' => false,
|
||||
'is_sentinel_enabled' => true,
|
||||
], $settings));
|
||||
|
||||
expect($server->fresh()->isSentinelEnabled())->toBeFalse();
|
||||
})->with([
|
||||
'build server' => [['is_build_server' => true]],
|
||||
'swarm manager' => [['is_swarm_manager' => true]],
|
||||
'swarm worker' => [['is_swarm_worker' => true]],
|
||||
'transferred server' => [[], ['transfer' => ['status' => 'transferred']]],
|
||||
'force-disabled server' => [['force_disabled' => true]],
|
||||
]);
|
||||
|
||||
it('keeps metrics optional while Sentinel remains enabled', function () {
|
||||
DB::table('instance_settings')->insert(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $user->teams()->first()->id,
|
||||
]);
|
||||
$server->settings->update(['is_metrics_enabled' => false]);
|
||||
|
||||
expect($server->fresh()->isSentinelEnabled())->toBeTrue()
|
||||
->and((bool) $server->settings->fresh()->is_metrics_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -10,25 +10,13 @@ it('keeps sentinel restarted events from re-syncing editable form fields', funct
|
||||
->not->toContain('$this->syncData();');
|
||||
});
|
||||
|
||||
it('dispatches a server navbar refresh after toggling sentinel', function () {
|
||||
it('does not expose a Sentinel disable action', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php'));
|
||||
|
||||
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)
|
||||
\}/s', $componentSource, $matches);
|
||||
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain("\$this->dispatch('refreshServerShow');");
|
||||
});
|
||||
|
||||
it('only marks sentinel enabled after startup succeeds', function () {
|
||||
$componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php'));
|
||||
|
||||
preg_match('/public function toggleSentinel\([^)]*\).*?\{(?<body>.*?)\n \}/s', $componentSource, $matches);
|
||||
$toggleBody = $matches['body'] ?? '';
|
||||
|
||||
expect(strpos($toggleBody, 'StartSentinel::run'))->toBeLessThan(
|
||||
strpos($toggleBody, '$this->isSentinelEnabled = true;')
|
||||
);
|
||||
expect($componentSource)->not->toContain('function toggleSentinel')
|
||||
->and($view)->not->toContain('Disable')
|
||||
->and($view)->not->toContain('Enable Sentinel');
|
||||
});
|
||||
|
||||
it('does not repeat a disabled status badge in the sentinel empty state', function () {
|
||||
@@ -45,3 +33,11 @@ it('tells the user that saving sentinel settings initiates a restart', function
|
||||
expect($matches['body'] ?? '')
|
||||
->toContain("\$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');");
|
||||
});
|
||||
|
||||
it('starts mandatory Sentinel after server validation succeeds', function () {
|
||||
$interactiveValidation = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php'));
|
||||
$queuedValidation = file_get_contents(app_path('Jobs/ValidateAndInstallServerJob.php'));
|
||||
|
||||
expect($interactiveValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);')
|
||||
->and($queuedValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);');
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Livewire\Project\Shared\GetLogs;
|
||||
use App\Livewire\Server\Sentinel\Logs;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -23,105 +21,36 @@ beforeEach(function () {
|
||||
$this->server = Server::factory()->create(['team_id' => $team->id]);
|
||||
});
|
||||
|
||||
it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) {
|
||||
it('shows Sentinel status and logs when the legacy flag and metrics are disabled', function (bool $recentHeartbeat) {
|
||||
$this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat);
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->assertSee('Sentinel is disabled')
|
||||
->assertSeeHtml('wire:click="enableSentinel"')
|
||||
->assertDontSee('Out of sync')
|
||||
->assertDontSee('In sync')
|
||||
->assertDontSeeLivewire(GetLogs::class);
|
||||
->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync')
|
||||
->assertDontSee('Enable Sentinel')
|
||||
->assertDontSee('Sentinel is disabled')
|
||||
->assertSeeLivewire(GetLogs::class);
|
||||
})->with([false, true]);
|
||||
|
||||
it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) {
|
||||
it('shows Sentinel status independently of optional metrics', function (bool $metricsEnabled, bool $recentHeartbeat) {
|
||||
$this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat);
|
||||
$this->server->settings()->update([
|
||||
'is_sentinel_enabled' => ! $metricsOnly,
|
||||
'is_metrics_enabled' => $metricsOnly,
|
||||
'is_build_server' => false,
|
||||
'is_sentinel_enabled' => false,
|
||||
'is_metrics_enabled' => $metricsEnabled,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->assertDontSee('Sentinel is disabled')
|
||||
->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync')
|
||||
->assertSeeLivewire(GetLogs::class);
|
||||
})->with([false, true])->with([false, true]);
|
||||
|
||||
it('enables sentinel from the logs page', function () {
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
|
||||
StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool {
|
||||
expect($server->id)->toBe($this->server->id);
|
||||
expect($restart)->toBeTrue();
|
||||
$server->settings->update(['is_sentinel_enabled' => true]);
|
||||
|
||||
return true;
|
||||
});
|
||||
it('does not offer Sentinel controls or logs on unsupported servers', function (string $setting) {
|
||||
$this->server->settings()->update([$setting => true]);
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->call('enableSentinel')
|
||||
->assertDontSee('Sentinel is disabled')
|
||||
->assertDontSee('Enable Sentinel')
|
||||
->assertSeeLivewire(GetLogs::class)
|
||||
->assertDispatched('refreshServerShow')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->server->fresh()->isSentinelEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps sentinel disabled when startup fails', function () {
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
|
||||
StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed'));
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->call('enableSentinel')
|
||||
->assertSee('Sentinel is disabled')
|
||||
->assertDontSeeLivewire(GetLogs::class)
|
||||
->assertDispatched('error')
|
||||
->assertNotDispatched('success');
|
||||
|
||||
expect($this->server->fresh()->isSentinelEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not enable sentinel on unsupported servers', function (string $setting) {
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]);
|
||||
StartSentinel::shouldRun()->never();
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->call('enableSentinel')
|
||||
->assertSee('Sentinel is disabled')
|
||||
->assertDispatched('error');
|
||||
->assertDontSeeLivewire(GetLogs::class);
|
||||
})->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']);
|
||||
|
||||
it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) {
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]);
|
||||
$user = User::factory()->create();
|
||||
if (! $crossTeam) {
|
||||
$this->server->team->members()->attach($user->id, ['role' => 'member']);
|
||||
}
|
||||
$this->actingAs($user);
|
||||
StartSentinel::shouldRun()->never();
|
||||
$component = new Logs;
|
||||
$component->server = $this->server->fresh();
|
||||
|
||||
expect(fn () => $component->enableSentinel())
|
||||
->toThrow(AuthorizationException::class);
|
||||
expect($this->server->fresh()->isSentinelEnabled())->toBeFalse();
|
||||
})->with([false, true]);
|
||||
|
||||
it('does not restart sentinel when it is already enabled', function () {
|
||||
$this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]);
|
||||
StartSentinel::shouldRun()->never();
|
||||
|
||||
Livewire::withQueryParams(['server_uuid' => $this->server->uuid])
|
||||
->test(Logs::class)
|
||||
->assertSeeLivewire(GetLogs::class)
|
||||
->call('enableSentinel')
|
||||
->assertNotDispatched('success');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
});
|
||||
|
||||
test('converts scp-style ssh urls with custom usernames to https', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('repository_url', 'custom-user@git.example.com:organization/repository.git')
|
||||
->call('loadBranch')
|
||||
->assertSet('repository_url', 'https://git.example.com/organization/repository.git')
|
||||
->assertSet('branchFound', true);
|
||||
});
|
||||
|
||||
test('strips custom ports when converting scp-style ssh urls to https', function () {
|
||||
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
|
||||
->set('repository_url', 'custom-user@git.example.com:2222/organization/repository.git')
|
||||
->call('loadBranch')
|
||||
->assertSet('repository_url', 'https://git.example.com/organization/repository.git')
|
||||
->assertSet('branchFound', true);
|
||||
});
|
||||
@@ -35,12 +35,12 @@ it('uses a single unified navbar for application, service, database, and server
|
||||
|
||||
it('uses interactive status summaries in mobile resource headings', function () {
|
||||
$headings = [
|
||||
resource_path('views/livewire/project/application/heading.blade.php') => '<x-status-summary :status="$application->status" />',
|
||||
resource_path('views/livewire/project/database/heading.blade.php') => '<x-status-summary :status="$database->status" title="Database status" />',
|
||||
resource_path('views/livewire/project/service/heading.blade.php') => '<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />',
|
||||
resource_path('views/livewire/project/application/heading.blade.php'),
|
||||
resource_path('views/livewire/project/database/heading.blade.php'),
|
||||
resource_path('views/livewire/project/service/heading.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($headings as $path => $statusSummary) {
|
||||
foreach ($headings as $path) {
|
||||
$mobileHeading = str(file_get_contents($path))
|
||||
->after('<div class="mb-3 w-full xl:hidden">')
|
||||
->before('<div class="w-full xl:hidden">')
|
||||
@@ -49,7 +49,7 @@ it('uses interactive status summaries in mobile resource headings', function ()
|
||||
expect($mobileHeading)
|
||||
->toContain('flex min-w-0 flex-col items-start gap-2')
|
||||
->toContain('min-w-0 max-w-full truncate')
|
||||
->toContain($statusSummary)
|
||||
->toContain('<x-status-summary')
|
||||
->not->toContain('<x-status-badge');
|
||||
}
|
||||
});
|
||||
@@ -525,15 +525,24 @@ it('welds the deployment log sidebar to the main sidebar', function () {
|
||||
->and($css)->toContain('position: fixed;');
|
||||
});
|
||||
|
||||
it('uses the same mobile heading gap on deployment pages as application settings', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/application/configuration.blade.php'));
|
||||
$deploymentIndex = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
|
||||
$deploymentShow = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
|
||||
it('uses the same mobile heading gap on application pages', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/application/backup/index.blade.php'),
|
||||
resource_path('views/livewire/project/application/backup/show.blade.php'),
|
||||
resource_path('views/livewire/project/application/deployment/show.blade.php'),
|
||||
resource_path('views/livewire/project/shared/logs.blade.php'),
|
||||
resource_path('views/livewire/project/shared/execute-container-command.blade.php'),
|
||||
];
|
||||
|
||||
expect($configuration)->toContain('application-settings-workspace mt-4')
|
||||
->and($deploymentIndex)->toContain("'mt-4 max-w-none lg:mt-0' => ! \$embedded")
|
||||
->and($deploymentShow)->toContain('application-settings-workspace mt-4')
|
||||
->toContain('lg:mt-0');
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))
|
||||
->toContain('application-settings-workspace mt-4')
|
||||
->toContain('lg:mt-0');
|
||||
}
|
||||
|
||||
expect(file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php')))
|
||||
->toContain("'mt-4 max-w-none lg:mt-0' => ! \$embedded");
|
||||
});
|
||||
|
||||
it('removes desktop top spacing from the deployment log viewer', function () {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('enables Sentinel only for existing active regular servers', function () {
|
||||
$user = User::factory()->create();
|
||||
$teamId = $user->teams()->first()->id;
|
||||
|
||||
$regularServer = Server::factory()->create(['team_id' => $teamId]);
|
||||
$regularServer->settings->update([
|
||||
'is_sentinel_enabled' => false,
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$buildServer = Server::factory()->create(['team_id' => $teamId]);
|
||||
$buildServer->settings->update([
|
||||
'is_sentinel_enabled' => false,
|
||||
'is_build_server' => true,
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$unvalidatedServer = Server::factory()->create(['team_id' => $teamId]);
|
||||
$unvalidatedServer->settings->update([
|
||||
'is_sentinel_enabled' => false,
|
||||
'is_reachable' => false,
|
||||
'is_usable' => false,
|
||||
]);
|
||||
|
||||
$migration = require database_path('migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php');
|
||||
$migration->up();
|
||||
|
||||
expect((bool) $regularServer->settings->fresh()->is_sentinel_enabled)->toBeTrue()
|
||||
->and((bool) $buildServer->settings->fresh()->is_sentinel_enabled)->toBeFalse()
|
||||
->and((bool) $unvalidatedServer->settings->fresh()->is_sentinel_enabled)->toBeFalse();
|
||||
});
|
||||
@@ -7,15 +7,15 @@ beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('catches delayed sentinel restart when job runs past midnight', function () {
|
||||
Cache::put('sentinel-restart:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
it('catches a delayed daily job when it runs past midnight', function () {
|
||||
Cache::put('daily-job:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400);
|
||||
|
||||
// Job runs 3 minutes late at 00:03
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC'));
|
||||
|
||||
// isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today
|
||||
// lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires
|
||||
$result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1');
|
||||
$result = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:1');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
@@ -63,26 +63,26 @@ it('daily cron fires after cache seed even when delayed past the minute', functi
|
||||
// Step 1: 15:00 — not due for midnight cron, but seeds cache
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC'));
|
||||
|
||||
$result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
|
||||
$result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:seed-test');
|
||||
expect($result1)->toBeFalse();
|
||||
|
||||
// Step 2: Next day at 00:05 — delayed 5 minutes past midnight
|
||||
// Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires
|
||||
Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC'));
|
||||
|
||||
$result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test');
|
||||
$result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:seed-test');
|
||||
expect($result2)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not double-dispatch within same cron window', function () {
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC'));
|
||||
|
||||
$first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
|
||||
$first = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:10');
|
||||
expect($first)->toBeTrue();
|
||||
|
||||
// Next minute — should NOT dispatch again
|
||||
Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC'));
|
||||
|
||||
$second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10');
|
||||
$second = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:10');
|
||||
expect($second)->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -76,6 +76,24 @@ it('groups application navigation by user workflow', function () {
|
||||
->toContain("'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone']");
|
||||
});
|
||||
|
||||
it('uses the same responsive settings grid for applications services and databases', function () {
|
||||
$sidebars = [
|
||||
resource_path('views/components/application/configuration-sidebar.blade.php'),
|
||||
resource_path('views/components/service/configuration-sidebar.blade.php'),
|
||||
resource_path('views/components/database/configuration-sidebar.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($sidebars as $sidebar) {
|
||||
expect(file_get_contents($sidebar))
|
||||
->toContain('grid grid-cols-2 gap-0.5')
|
||||
->toContain('sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1');
|
||||
}
|
||||
|
||||
expect(file_get_contents($sidebars[0]))
|
||||
->not->toContain('aria-label="Configuration menu"')
|
||||
->not->toContain('menuOpen');
|
||||
});
|
||||
|
||||
it('shows the database sidebar on backup pages', function () {
|
||||
$configuration = file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php'));
|
||||
$backups = file_get_contents(resource_path('views/livewire/project/database/backup/index.blade.php'));
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Team\DeleteTeam;
|
||||
use App\Livewire\SelectTeam;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a user that belongs to two teams (their auto-created personal team
|
||||
* plus a second team). Boarding is disabled so the middleware does not bounce
|
||||
* the request to the onboarding screen.
|
||||
*/
|
||||
function userWithTwoTeams(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$personal = $user->teams->first();
|
||||
$personal->update(['show_boarding' => false]);
|
||||
|
||||
$second = Team::factory()->create(['show_boarding' => false]);
|
||||
$user->teams()->attach($second, ['role' => 'owner']);
|
||||
$user->refresh();
|
||||
|
||||
return [$user, $personal, $second];
|
||||
}
|
||||
|
||||
it('resolves the stored team when the user still belongs to it', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
|
||||
expect($user->resolveStoredTeam()?->id)->toBe($second->id);
|
||||
});
|
||||
|
||||
it('resolves the only team for single-team users without a stored choice', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->teams->first()->update(['show_boarding' => false]);
|
||||
|
||||
expect($user->resolveStoredTeam()?->id)->toBe($user->teams->first()->id);
|
||||
});
|
||||
|
||||
it('returns null for multi-team users without a valid stored choice', function () {
|
||||
[$user] = userWithTwoTeams();
|
||||
|
||||
expect($user->resolveStoredTeam())->toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a stored team the user no longer belongs to', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => 99999]);
|
||||
|
||||
expect($user->resolveStoredTeam())->toBeNull();
|
||||
// still ambiguous (2 teams), so must pick again
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
$user->refresh();
|
||||
expect($user->resolveStoredTeam()?->id)->toBe($second->id);
|
||||
});
|
||||
|
||||
it('persists current_team_id when the active team changes via refreshSession', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$this->actingAs($user);
|
||||
|
||||
refreshSession($second);
|
||||
|
||||
expect($user->fresh()->current_team_id)->toBe($second->id)
|
||||
->and(data_get(session('currentTeam'), 'id'))->toBe($second->id);
|
||||
});
|
||||
|
||||
it('redirects a multi-team user with no stored team to the select screen', function () {
|
||||
[$user] = userWithTwoTeams();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/')
|
||||
->assertRedirect(route('team.select'));
|
||||
});
|
||||
|
||||
it('restores the stored team for a returning multi-team user', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
|
||||
$this->actingAs($user)->get('/');
|
||||
|
||||
expect(data_get(session('currentTeam'), 'id'))->toBe($second->id);
|
||||
});
|
||||
|
||||
it('does not send a single-team user to the select screen', function () {
|
||||
$user = User::factory()->create();
|
||||
$only = $user->teams->first();
|
||||
$only->update(['show_boarding' => false]);
|
||||
|
||||
// A single-team user who lands on the select screen is bounced straight to
|
||||
// the dashboard with their team activated, never shown a choice.
|
||||
$this->actingAs($user)
|
||||
->get(route('team.select'))
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect(data_get(session('currentTeam'), 'id'))->toBe($only->id);
|
||||
});
|
||||
|
||||
it('persists the choice and activates the team when selected on the screen', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(SelectTeam::class)
|
||||
->call('selectTeam', $second->id)
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
expect($user->fresh()->current_team_id)->toBe($second->id)
|
||||
->and(data_get(session('currentTeam'), 'id'))->toBe($second->id);
|
||||
});
|
||||
|
||||
it('lets the livewire update endpoint through for an ambiguous user', function () {
|
||||
[$user] = userWithTwoTeams();
|
||||
|
||||
// The selection action runs as a Livewire AJAX POST to /livewire/update.
|
||||
// The team gate must not hijack that request with a redirect to the
|
||||
// selection screen, or the click silently does nothing (HTML != JSON).
|
||||
$response = $this->actingAs($user)
|
||||
->withHeaders(['X-Livewire' => 'true'])
|
||||
->post('/livewire/update', []);
|
||||
|
||||
expect($response->headers->get('Location'))->not->toBe(route('team.select'));
|
||||
});
|
||||
|
||||
it('clears the stored team when the member is removed from it', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
|
||||
// Simulate the removal event (Team\Member::remove detaches then clears).
|
||||
$user->teams()->detach($second->id);
|
||||
$user->clearStoredTeamIfMatches($second->id);
|
||||
|
||||
expect($user->fresh()->current_team_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the stored team when the member is removed from a different team', function () {
|
||||
[$user, $personal, $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
|
||||
$user->teams()->detach($personal->id);
|
||||
$user->clearStoredTeamIfMatches($personal->id);
|
||||
|
||||
expect($user->fresh()->current_team_id)->toBe($second->id);
|
||||
});
|
||||
|
||||
it('clears the stored team for members when their team is deleted', function () {
|
||||
[$owner, , $shared] = userWithTwoTeams();
|
||||
$member = User::factory()->create();
|
||||
$member->teams()->attach($shared, ['role' => 'member']);
|
||||
$member->update(['current_team_id' => $shared->id]);
|
||||
|
||||
app(DeleteTeam::class)->handle($shared->fresh(), $owner);
|
||||
|
||||
expect($member->fresh()->current_team_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('clears the deleting owner stored team when they delete that team', function () {
|
||||
[$owner, $personal, $shared] = userWithTwoTeams();
|
||||
$owner->update(['current_team_id' => $shared->id]);
|
||||
|
||||
app(DeleteTeam::class)->handle($shared->fresh(), $owner);
|
||||
|
||||
expect($owner->fresh()->current_team_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('preserves a newer team selection when clearing a stale team', function () {
|
||||
[$user, $personal, $second] = userWithTwoTeams();
|
||||
// In-memory model still points at the team being removed ($second)...
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
// ...but a concurrent request already switched the stored choice to $personal.
|
||||
User::query()->whereKey($user->id)->update(['current_team_id' => $personal->id]);
|
||||
|
||||
$user->clearStoredTeamIfMatches($second->id);
|
||||
|
||||
// The atomic WHERE guard must not clobber the newer selection.
|
||||
expect($user->fresh()->current_team_id)->toBe($personal->id);
|
||||
});
|
||||
|
||||
it('returns the sole remaining team when the deleting owner has one team left', function () {
|
||||
[$owner, $personal, $shared] = userWithTwoTeams();
|
||||
$owner->update(['current_team_id' => $shared->id]);
|
||||
|
||||
$next = app(DeleteTeam::class)->handle($shared->fresh(), $owner);
|
||||
|
||||
expect($next?->id)->toBe($personal->id);
|
||||
});
|
||||
|
||||
it('returns null (picker) when the deleting owner still has multiple teams left', function () {
|
||||
[$owner, , $shared] = userWithTwoTeams();
|
||||
$third = Team::factory()->create(['show_boarding' => false]);
|
||||
$owner->teams()->attach($third, ['role' => 'owner']);
|
||||
$owner->update(['current_team_id' => $shared->id]);
|
||||
|
||||
// Deleting the active team leaves personal + third: ambiguous, so no team is
|
||||
// chosen silently and refreshSession(null) routes to the selection screen.
|
||||
$next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh());
|
||||
|
||||
expect($next)->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the active team when the deleted team was not the active one', function () {
|
||||
[$owner, $personal, $shared] = userWithTwoTeams();
|
||||
$third = Team::factory()->create(['show_boarding' => false]);
|
||||
$owner->teams()->attach($third, ['role' => 'owner']);
|
||||
$owner->update(['current_team_id' => $personal->id]);
|
||||
|
||||
// Deleting a non-active team must not move the owner off their active team.
|
||||
$next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh());
|
||||
|
||||
expect($next?->id)->toBe($personal->id);
|
||||
});
|
||||
|
||||
it('does not persist current_team_id while impersonating', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
$this->actingAs($user);
|
||||
session(['impersonating' => true]);
|
||||
|
||||
// Viewing a user's account switches the session team but must never
|
||||
// overwrite that user's stored last-active team.
|
||||
refreshSession($user->teams->first());
|
||||
|
||||
expect(data_get(session('currentTeam'), 'id'))->toBe($user->teams->first()->id)
|
||||
->and($user->fresh()->current_team_id)->toBe($second->id);
|
||||
});
|
||||
|
||||
it('bounces users who already have an active team away from the select screen', function () {
|
||||
[$user, , $second] = userWithTwoTeams();
|
||||
$user->update(['current_team_id' => $second->id]);
|
||||
refreshSession($second);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(SelectTeam::class)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
@@ -542,6 +542,52 @@ describe('Manual Webhook Repository Matching', function () {
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('github matches an ssh repository URL with a non-git username', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'custom-user@git.example.com:test-org/test-repo.git',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('github matches an ssh repository URL with a non-git username and custom port', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'custom-user@git.example.com:2222/test-org/test-repo.git',
|
||||
]);
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->not->toContain('No applications found');
|
||||
});
|
||||
|
||||
test('gitlab matches scp-style ssh repository URL with custom port', function () {
|
||||
$app = createApplicationWithWebhook(overrides: [
|
||||
'git_repository' => 'git@gitlab.example.com:2222/services/xyz.git',
|
||||
|
||||
@@ -17,6 +17,14 @@ it('generates commit links for direct repository remotes', function (string $rep
|
||||
'git@github.com:coollabsio/coolify.git',
|
||||
'https://github.com/coollabsio/coolify/commit/1234567890abcdef',
|
||||
],
|
||||
'SSH remote with custom username' => [
|
||||
'custom-user@git.example.com:coollabsio/coolify.git',
|
||||
'https://git.example.com/coollabsio/coolify/commit/1234567890abcdef',
|
||||
],
|
||||
'SSH remote with custom username and port' => [
|
||||
'custom-user@git.example.com:2222/coollabsio/coolify.git',
|
||||
'https://git.example.com/coollabsio/coolify/commit/1234567890abcdef',
|
||||
],
|
||||
'SSH URL' => [
|
||||
'ssh://git@gitlab.com/coollabsio/coolify.git',
|
||||
'https://gitlab.com/coollabsio/coolify/commit/1234567890abcdef',
|
||||
@@ -37,3 +45,34 @@ it('does not generate commit links from incomplete repository URLs', function (s
|
||||
'missing host' => 'https://',
|
||||
'missing scheme' => 'github.com/coollabsio/coolify',
|
||||
]);
|
||||
|
||||
it('converts scp-style remotes with generic usernames into https repository links', function (string $repository, string $expectedBranch, string $expectedCommits, string $expectedWebhook) {
|
||||
$application = new Application;
|
||||
$application->setRelation('source', null);
|
||||
$application->git_repository = $repository;
|
||||
$application->git_branch = 'main';
|
||||
$application->base_directory = '/';
|
||||
|
||||
expect($application->gitBranchLocation)->toBe($expectedBranch)
|
||||
->and($application->gitCommits)->toBe($expectedCommits)
|
||||
->and($application->gitWebhook)->toBe($expectedWebhook);
|
||||
})->with([
|
||||
'git username' => [
|
||||
'git@github.com:coollabsio/coolify.git',
|
||||
'https://github.com/coollabsio/coolify/tree/main/',
|
||||
'https://github.com/coollabsio/coolify/commits/main',
|
||||
'https://github.com/coollabsio/coolify/settings/hooks',
|
||||
],
|
||||
'custom username' => [
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
'https://git.example.com/organization/repository/tree/main/',
|
||||
'https://git.example.com/organization/repository/commits/main',
|
||||
'https://git.example.com/organization/repository/settings/hooks',
|
||||
],
|
||||
'custom username and port' => [
|
||||
'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'https://git.example.com/organization/repository/tree/main/',
|
||||
'https://git.example.com/organization/repository/commits/main',
|
||||
'https://git.example.com/organization/repository/settings/hooks',
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
it('parses scp-style ssh git urls including custom usernames and ports', function (string $url, array $expected) {
|
||||
expect(parseScpStyleGitUrl($url))->toBe($expected);
|
||||
})->with([
|
||||
'git username' => [
|
||||
'git@github.com:organization/repository.git',
|
||||
[
|
||||
'user' => 'git',
|
||||
'host' => 'github.com',
|
||||
'port' => null,
|
||||
'path' => 'organization/repository.git',
|
||||
],
|
||||
],
|
||||
'custom username' => [
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
[
|
||||
'user' => 'custom-user',
|
||||
'host' => 'git.example.com',
|
||||
'port' => null,
|
||||
'path' => 'organization/repository.git',
|
||||
],
|
||||
],
|
||||
'custom username and port' => [
|
||||
'custom-user@git.example.com:2222/organization/repository.git',
|
||||
[
|
||||
'user' => 'custom-user',
|
||||
'host' => 'git.example.com',
|
||||
'port' => '2222',
|
||||
'path' => 'organization/repository.git',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
it('converts scp-style ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) {
|
||||
expect(scpStyleGitUrlToHttps($url))->toBe($expected);
|
||||
})->with([
|
||||
'git username' => [
|
||||
'git@github.com:organization/repository.git',
|
||||
'https://github.com/organization/repository.git',
|
||||
],
|
||||
'custom username' => [
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
'https://git.example.com/organization/repository.git',
|
||||
],
|
||||
'custom username and port' => [
|
||||
'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'https://git.example.com/organization/repository.git',
|
||||
],
|
||||
]);
|
||||
|
||||
it('rejects non-scp-style git urls', function (string $url) {
|
||||
expect(parseScpStyleGitUrl($url))->toBeNull()
|
||||
->and(scpStyleGitUrlToHttps($url))->toBeNull();
|
||||
})->with([
|
||||
'https' => 'https://github.com/organization/repository.git',
|
||||
'email without path' => 'custom-user@git.example.com',
|
||||
'ssh scheme' => 'ssh://git@github.com/organization/repository.git',
|
||||
'empty' => '',
|
||||
]);
|
||||
|
||||
it('normalizes github app repository slugs from scp-style ssh urls', function (string $url, string $expected) {
|
||||
expect(gitRepositorySlug($url))->toBe($expected);
|
||||
})->with([
|
||||
'https' => ['https://github.com/organization/repository.git', 'organization/repository'],
|
||||
'owner/repo' => ['organization/repository', 'organization/repository'],
|
||||
'git username' => ['git@github.com:organization/repository.git', 'organization/repository'],
|
||||
'custom username' => ['custom-user@git.example.com:organization/repository.git', 'organization/repository'],
|
||||
'custom username and port' => ['custom-user@git.example.com:2222/organization/repository.git', 'organization/repository'],
|
||||
]);
|
||||
@@ -43,6 +43,15 @@ it('does not dispatch CheckAndStartSentinelJob hourly anymore', function () {
|
||||
Queue::assertNotPushed(CheckAndStartSentinelJob::class);
|
||||
});
|
||||
|
||||
it('does not schedule periodic Sentinel restart checks', function () {
|
||||
$root = dirname(__DIR__, 2);
|
||||
$manager = file_get_contents($root.'/app/Jobs/ServerManagerJob.php');
|
||||
$diagnostics = file_get_contents($root.'/app/Console/Commands/ScheduledJobDiagnostics.php');
|
||||
|
||||
expect($manager)->not->toContain('sentinel-restart:')
|
||||
->and($diagnostics)->not->toContain('sentinel-restart:');
|
||||
});
|
||||
|
||||
it('skips ServerConnectionCheckJob when sentinel is live', function () {
|
||||
$settings = Mockery::mock(InstanceSettings::class);
|
||||
$settings->instance_timezone = 'UTC';
|
||||
|
||||
@@ -107,6 +107,9 @@ it('validates SSH URLs when allowed', function () {
|
||||
'git@github.com:user/repo.git',
|
||||
'git@gitlab.com:user/repo.git',
|
||||
'git@bitbucket.org:user/repo.git',
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
'custom-user@git.example.com:2222/organization/repository.git',
|
||||
'enterprise-user@enterprise.ghe.com:organization/repository.git',
|
||||
];
|
||||
|
||||
foreach ($validUrls as $url) {
|
||||
@@ -115,12 +118,21 @@ it('validates SSH URLs when allowed', function () {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-SSH email-like repository URLs', function () {
|
||||
$rule = new ValidGitRepositoryUrl;
|
||||
|
||||
$validator = Validator::make(['url' => 'custom-user@git.example.com'], ['url' => $rule]);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects SSH URLs when not allowed', function () {
|
||||
$rule = new ValidGitRepositoryUrl(allowSSH: false);
|
||||
|
||||
$invalidUrls = [
|
||||
'git@github.com:user/repo.git',
|
||||
'git@gitlab.com:user/repo.git',
|
||||
'custom-user@git.example.com:organization/repository.git',
|
||||
];
|
||||
|
||||
foreach ($invalidUrls as $url) {
|
||||
|
||||
@@ -213,11 +213,15 @@ it('shows danger zone for application deletion', function () {
|
||||
->screenshot(filename: 'application-danger-zone');
|
||||
});
|
||||
|
||||
it('uses compact application domains with unified settings and a floating save bar', function () {
|
||||
it('uses compact application domains with unified settings and a form save button', function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
InstanceSettings::find(0)->update(['is_dns_validation_enabled' => false]);
|
||||
Cache::forget('instance_settings');
|
||||
$this->application->update(['fqdn' => 'https://first.example.com,https://second.example.com', 'redirect' => 'both']);
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://first.example.com,https://second.example.com',
|
||||
'ports_exposes' => '3000,8069',
|
||||
'redirect' => 'both',
|
||||
]);
|
||||
loginAndSkipBoarding();
|
||||
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application).'/domains';
|
||||
$page = visit($url);
|
||||
@@ -232,20 +236,22 @@ it('uses compact application domains with unified settings and a floating save b
|
||||
->click('[aria-label="Settings for https://first.example.com"]')
|
||||
->assertSee('Domain settings')
|
||||
->assertValue('#editingDomainParts-host', 'first.example.com')
|
||||
->assertMissing('.is-dirty [wire\\:click="updateDomain"]')
|
||||
->fill('#editingDomainParts-port', '8069')
|
||||
->fill('#editingDomainParts-path', '/blog')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->click('[id^="application-domain-indexing-"][id$="-trigger"]')
|
||||
->click('Noindex')
|
||||
->assertSee('Search engine indexing updated.')
|
||||
->assertVisible('.is-dirty:not(.is-saving) [wire\\:click="updateDomain"]')
|
||||
->screenshot(filename: 'application-domain-unified-settings')
|
||||
->click('[wire\\:click="updateDomain"]')
|
||||
->click('Save')
|
||||
->assertDontSee('Domain settings')
|
||||
->assertSee('https://first.example.com/blog')
|
||||
->assertSee('Internal port 8069')
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'application-domains-compact');
|
||||
|
||||
expect($this->application->fresh()->domain_port_overrides)
|
||||
->toHaveKey('https://first.example.com/blog', 8069);
|
||||
|
||||
$page->click('[aria-label="Settings for https://second.example.com"]')
|
||||
->fill('#editingDomainParts-path', '/discard')
|
||||
->click('Reset')
|
||||
|
||||
Reference in New Issue
Block a user