chore: merge main into next (#12382)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2026-10-29 21:10:39 +01:00
committed by GitHub
co-authored by Andras Bacsai Claude Opus 5.5
parent 340de2684f
commit b85a9197a1
89 changed files with 3988 additions and 605 deletions
+6
View File
@@ -156,3 +156,9 @@
## Validate input safely
- In PCRE, `$` also matches before a final `\n`. In validation regexes, use `\z` or reject control characters first, or a trailing newline passes.
## Application queries carry hidden counts
- `Application` has a global scope `withRelations` that adds two `withCount` subqueries and `applications.*`, also to `pluck('id')` and eager loads. For ID lookups and lean eager loads, call `withoutGlobalScope('withRelations')`.
## Measure database reads end to end
- On PostgreSQL 15, `EXPLAIN (ANALYZE, BUFFERS)` does not detoast or send the selected columns, so a wide `jsonb`/`json`/`text` column does not show in it. Time the full fetch (`psql \timing` with `-o /dev/null`, or PDO `fetchAll`) as well before you blame or rule out an index.
@@ -23,6 +23,7 @@ class ResetUserPassword implements ResetsUserPasswords
$user->fill([
'password' => Hash::make($input['password']),
'force_password_reset' => false,
])->save();
$user->deleteAllSessions();
}
@@ -222,6 +222,19 @@ class ApplicationsController extends Controller
], 422);
}
private function sharedImageNameValidationResponse(Application $application): ?JsonResponse
{
$otherApplication = $application->otherApplicationBuildingSameImage();
if (! $otherApplication) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_registry_image_name' => [$application->sharedImageNameMessage($otherApplication)]],
], 422);
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
@@ -1687,6 +1700,9 @@ class ApplicationsController extends Controller
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
if (isset($isStatic)) {
@@ -1947,6 +1963,9 @@ class ApplicationsController extends Controller
$application->source_id = $githubApp->id;
$application->repository_project_id = $repository_project_id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -2176,6 +2195,9 @@ class ApplicationsController extends Controller
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -2324,6 +2346,9 @@ class ApplicationsController extends Controller
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -3564,6 +3589,10 @@ class ApplicationsController extends Controller
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
}
$requestedImage = (clone $application)->fill($request->only(['docker_registry_image_name', 'build_pack']));
if ($requestedImage->isDirty(['docker_registry_image_name', 'build_pack']) && $imageNameValidation = $this->sharedImageNameValidationResponse($requestedImage)) {
return $imageNameValidation;
}
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
return $internalNameValidation;
}
@@ -1487,7 +1487,6 @@ class ServicesController extends Controller
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'],
@@ -1631,7 +1630,6 @@ class ServicesController extends Controller
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'],
@@ -1764,7 +1762,6 @@ class ServicesController extends Controller
properties: [
'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'],
'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'],
'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'],
'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'],
'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'],
'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'],
@@ -35,7 +35,13 @@ trait MatchesManualWebhookApplications
*/
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
{
return $query->get()
// Every repository form that manualWebhookRepositoryMatches() accepts
// contains the repository name, so the database discards unrelated
// applications that share the branch. The full match stays in PHP.
$pattern = '%'.str_replace('_', '\\_', mb_strtolower($fullName)).'%';
return $query->whereRaw("LOWER(git_repository) LIKE ? ESCAPE '\\'", [$pattern])
->get()
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
->values();
}
+12
View File
@@ -370,6 +370,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
try {
$this->validateDeploymentEnvironmentVariableKeys();
$this->ensureRegistryImageForMultipleServers();
$this->ensureRegistryImageIsNotShared();
$this->warnAboutVolumesOnMultipleServers();
$this->warnAboutMixedProxiesOnMultipleServers();
} catch (Exception $e) {
@@ -2590,6 +2591,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers');
}
/**
* Two applications that build the same Docker image name overwrite each other's image tags.
*/
private function ensureRegistryImageIsNotShared(): void
{
$otherApplication = $this->application->otherApplicationBuildingSameImage();
if ($otherApplication) {
throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication));
}
}
/**
* Build-time names go into shell and Docker build commands, so they must be valid when the
* deployment builds an image ($buildsImage, checked right before the build steps). Restarts and
+23 -19
View File
@@ -13,6 +13,7 @@ use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
@@ -43,6 +44,8 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public const INTERRUPTED_MESSAGE = 'Docker cleanup was interrupted: its worker stopped before the cleanup finished.';
public const INTERRUPTED_EXECUTIONS_CHUNK_SIZE = 100;
/**
* Shares the per-server lock with queued CleanupDocker runs, so two cleanups never run on
* one server at once. A scheduled run that finds the lock held marks its occurrence
@@ -254,31 +257,32 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
*/
public static function failInterruptedExecutions(): void
{
$executions = DockerCleanupExecution::query()
DockerCleanupExecution::query()
->select(['id', 'server_id'])
->with('server.team')
->where('status', 'running')
->whereNull('finished_at')
->where('created_at', '<', now()->subSeconds(CleanupDocker::overlapLockExpiresAfter()))
->get();
->chunkById(self::INTERRUPTED_EXECUTIONS_CHUNK_SIZE, function (Collection $executions): void {
foreach ($executions as $execution) {
$updated = DockerCleanupExecution::query()
->whereKey($execution->id)
->where('status', 'running')
->whereNull('finished_at')
->update([
'status' => 'failed',
'message' => self::INTERRUPTED_MESSAGE,
'finished_at' => Carbon::now()->toImmutable(),
]);
foreach ($executions as $execution) {
$updated = DockerCleanupExecution::query()
->whereKey($execution->id)
->where('status', 'running')
->whereNull('finished_at')
->update([
'status' => 'failed',
'message' => self::INTERRUPTED_MESSAGE,
'finished_at' => Carbon::now()->toImmutable(),
]);
if ($updated === 0) {
continue;
}
if ($updated === 0) {
continue;
}
event(new DockerCleanupDone($execution->refresh()));
$execution->server?->team?->notify(new DockerCleanupFailed($execution->server, 'Docker cleanup job failed with the following error: '.self::INTERRUPTED_MESSAGE));
}
event(new DockerCleanupDone($execution->refresh()));
$execution->server?->team?->notify(new DockerCleanupFailed($execution->server, 'Docker cleanup job failed with the following error: '.self::INTERRUPTED_MESSAGE));
}
});
}
public function failed(?\Throwable $exception): void
+19 -5
View File
@@ -43,11 +43,7 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
public function handle(): void
{
// Disabling keeps running jobs, so a disabled server is reconciled until its last runner is done.
Server::query()
->where(fn (Builder $query) => $query
->whereHas('githubRunnerConfig', fn (Builder $config) => $config->where('is_enabled', true))
->orWhereHas('githubRunnerExecutions', fn (Builder $execution) => $execution->whereIn('status', GithubRunnerStatus::occupying())))
$this->serversToReconcile()
->with('settings')
->get()
->filter(fn (Server $server) => $server->isFunctional())
@@ -57,6 +53,24 @@ class ReconcileGithubRunnersJob implements ShouldBeUnique, ShouldQueue
$this->reconcileQueued();
}
/**
* Servers with enabled runners or with executions that still occupy a runner. Disabling keeps
* running jobs, so a disabled server is reconciled until its last runner is done. The server ids
* come from one UNION: an OR of two EXISTS subqueries makes Postgres estimate a per-server scan
* of the executions and spend most of the query time on JIT compilation.
*
* @return Builder<Server>
*/
public function serversToReconcile(): Builder
{
return Server::query()->whereIn('id', GithubRunnerConfig::query()
->select('server_id')
->where('is_enabled', true)
->union(GithubRunnerExecution::query()
->select('server_id')
->whereIn('status', GithubRunnerStatus::occupying())));
}
/**
* Removes executions that still occupy a runner of a deleted server (for example a server deleted
* without model events), because they block deleting the GitHub App.
+2 -1
View File
@@ -17,6 +17,7 @@ use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -741,7 +742,7 @@ class ScheduledJobManager implements ShouldQueue
private function getServersForCleanupQuery(): Builder
{
$query = Server::with('settings')
$query = Server::with(['settings' => fn (HasOne $query) => $query->withoutPatchCheckResult()])
->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
+67 -53
View File
@@ -4,10 +4,10 @@ namespace App\Jobs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
@@ -37,6 +37,11 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
*/
private const CLOUD_PROVIDER_STATUS_CHECK_CRON = '*/5 * * * *';
/**
* Number of servers loaded and processed at a time.
*/
public const SERVER_BATCH_SIZE = 500;
/**
* Create a new job instance.
*/
@@ -59,43 +64,55 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
$this->instanceTimezone = config('app.timezone');
}
// Get all servers to process
$servers = $this->getServers();
// Decide once per run: these cron keys are global, so a second call in the same run returns false.
$runCloudProviderStatusChecks = shouldRunCronNow(self::CLOUD_PROVIDER_STATUS_CHECK_CRON, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime);
$runConnectionChecks = shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime);
// Provider state checks run independently so slow APIs cannot block SSH checks.
$this->dispatchCloudProviderStatusChecks($servers);
$relations = ['settings' => fn (HasOne $query) => $query->withoutPatchCheckResult(), 'cloudProviderToken'];
$servers = $servers
->reject(fn (Server $server) => $server->hasPlaceholderIp())
->values();
foreach ($this->getServerIds()->chunk(self::SERVER_BATCH_SIZE) as $serverIds) {
$servers = Server::with($relations)->whereKey($serverIds)->orderBy('id')->get();
// Dispatch ServerConnectionCheck for all servers efficiently
$this->dispatchConnectionChecks($servers);
// Provider state checks run independently so slow APIs cannot block SSH checks.
if ($runCloudProviderStatusChecks) {
$this->dispatchCloudProviderStatusChecks($servers);
}
// Process server-specific scheduled tasks
$this->processScheduledTasks($servers);
$servers = $servers
->reject(fn (Server $server) => $server->hasPlaceholderIp())
->values();
if ($runConnectionChecks) {
$this->dispatchConnectionChecks($servers);
}
// Process server-specific scheduled tasks
$this->processScheduledTasks($servers);
}
}
private function getServers(): Collection
/**
* Selects only the IDs, so the paid-subscription filter runs once and the full server rows are loaded
* in batches by primary key. Loading every server in one query kept all of them in worker memory.
* toBase() skips model hydration, which an Eloquent pluck of the cast id column would do for every row.
*
* @return Collection<int, int>
*/
private function getServerIds(): Collection
{
$allServers = Server::with(['settings', 'cloudProviderToken']);
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get();
return $servers->merge($own)->unique('id')->values();
} else {
return $allServers->get();
if (! isCloud()) {
return Server::query()->orderBy('id')->toBase()->pluck('id');
}
return Server::query()->whereRelation('team.subscription', 'stripe_invoice_paid', true)->toBase()->pluck('id')
->merge(Server::query()->where('team_id', 0)->toBase()->pluck('id'))
->unique()
->sort()
->values();
}
private function dispatchCloudProviderStatusChecks(Collection $servers): void
{
if (! shouldRunCronNow(self::CLOUD_PROVIDER_STATUS_CHECK_CRON, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) {
return;
}
$servers->each(function (Server $server) {
$hasCloudResource = $server->hetzner_server_id
|| $server->vultr_instance_id
@@ -110,34 +127,31 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
private function dispatchConnectionChecks(Collection $servers): void
{
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) {
try {
if ($server->hasPlaceholderIp()) {
return;
}
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity.
// Never skip while the server is marked unreachable or unusable: the heartbeat does not
// restore these flags, so deployments would fail with "Server is not functional" forever.
if ($server->isSentinelEnabled() && $server->isSentinelLive()
&& $server->settings->is_reachable && $server->settings->is_usable) {
return;
}
if ($this->shouldSkipDueToBackoff($server)) {
return;
}
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => get_class($e).': '.$e->getMessage(),
]);
$servers->each(function (Server $server) {
try {
if ($server->hasPlaceholderIp()) {
return;
}
});
}
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity.
// Never skip while the server is marked unreachable or unusable: the heartbeat does not
// restore these flags, so deployments would fail with "Server is not functional" forever.
if ($server->isSentinelEnabled() && $server->isSentinelLive()
&& $server->settings->is_reachable && $server->settings->is_usable) {
return;
}
if ($this->shouldSkipDueToBackoff($server)) {
return;
}
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => get_class($e).': '.$e->getMessage(),
]);
}
});
}
private function processScheduledTasks(Collection $servers): void
+109
View File
@@ -16,12 +16,25 @@ class Dashboard extends Component
{
use RemembersCanvasView;
/** The dashboard shows only the first projects and servers of the sorted lists. */
public const ITEM_LIMIT = 8;
private const PROJECT_SORT_COLUMNS = ['name', 'resources', 'description'];
private const SERVER_SORT_COLUMNS = ['name', 'description', 'status'];
public Collection $projects;
public Collection $servers;
public Collection $privateKeys;
#[Locked]
public string $projectSort = 'name-asc';
#[Locked]
public string $serverSort = 'name-asc';
/**
* Members limited to some projects do not see team-level sections such as servers.
*/
@@ -60,11 +73,107 @@ class Dashboard extends Component
public function render()
{
$dashboardProjects = $this->sortedProjects()->take(self::ITEM_LIMIT);
$dashboardServers = $this->sortedServers()->take(self::ITEM_LIMIT);
return view('livewire.dashboard', [
'pendingInvitations' => $this->pendingInvitations(),
'dashboardProjects' => $dashboardProjects,
'dashboardServers' => $dashboardServers,
'projectResourceCounts' => $dashboardProjects->mapWithKeys(fn (Project $project) => [$project->id => $this->projectResourceCount($project)]),
'serverStatuses' => $dashboardServers->mapWithKeys(fn (Server $server) => [$server->id => $this->serverStatus($server)]),
]);
}
public function setProjectSort(string $sort): void
{
if ($this->isValidSort($sort, self::PROJECT_SORT_COLUMNS)) {
$this->projectSort = $sort;
}
}
public function setServerSort(string $sort): void
{
if ($this->isValidSort($sort, self::SERVER_SORT_COLUMNS)) {
$this->serverSort = $sort;
}
}
private function projectResourceCount(Project $project): int
{
return $project->applications_count + $project->services_count + $project->postgresqls_count
+ $project->redis_count + $project->keydbs_count + $project->dragonflies_count
+ $project->clickhouses_count + $project->mongodbs_count + $project->mysqls_count
+ $project->mariadbs_count + $project->sqlites_count;
}
/**
* @return array{0: string, 1: 'success'|'warning'|'error'} label and badge type
*/
private function serverStatus(Server $server): array
{
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
return match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
};
}
/**
* @return Collection<int, Project>
*/
private function sortedProjects(): Collection
{
return $this->sortItems($this->projects, $this->projectSort, fn (Project $project, string $column): int|string => match ($column) {
'resources' => $this->projectResourceCount($project),
'description' => (string) $project->description,
default => $project->name,
});
}
/**
* @return Collection<int, Server>
*/
private function sortedServers(): Collection
{
return $this->sortItems($this->servers, $this->serverSort, fn (Server $server, string $column): string => match ($column) {
'description' => (string) $server->description,
'status' => $this->serverStatus($server)[0],
default => $server->name,
});
}
/**
* Sorts by the column of a `column-direction` sort value, with the name as tie-breaker.
*
* @param callable(mixed, string): (int|string) $value
*/
private function sortItems(Collection $items, string $sort, callable $value): Collection
{
[$column, $direction] = explode('-', $sort, 2);
return $items
->sortBy(fn ($item) => $item->name, SORT_NATURAL | SORT_FLAG_CASE)
->sortBy(fn ($item) => $value($item, $column), SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc')
->values();
}
/**
* @param list<string> $columns
*/
private function isValidSort(string $sort, array $columns): bool
{
[$column, $direction] = array_pad(explode('-', $sort, 2), 2, null);
return in_array($column, $columns, true) && in_array($direction, ['asc', 'desc'], true);
}
/**
* Unexpired invitations for the user's email to teams the user has not joined yet.
*
+52 -14
View File
@@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -28,6 +29,18 @@ class ActiveDeployments extends Component
'finished_at',
];
private const ACTIVE_STATUSES = [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
];
/**
* Not dehydrated, so it is loaded once per request and shared by both lists.
*
* @var Collection<int, string>|null
*/
private ?Collection $teamApplicationIds = null;
/**
* Polling target. The lists are loaded again on every render.
*/
@@ -37,10 +50,8 @@ class ActiveDeployments extends Component
public function activeDeployments(): Collection
{
return $this->deploymentsQuery()
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->whereIn('application_id', $this->teamApplicationIds())
->whereIn('status', self::ACTIVE_STATUSES)
->orderBy('status')
->orderBy('id')
->limit(5)
@@ -50,22 +61,49 @@ class ActiveDeployments extends Component
#[Computed]
public function recentDeployments(): Collection
{
return $this->deploymentsQuery()
->whereNotIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
if (DB::connection()->getDriverName() !== 'pgsql') {
return $this->deploymentsQuery()
->whereIn('application_id', $this->teamApplicationIds())
->whereNotIn('status', self::ACTIVE_STATUSES)
->orderByDesc('id')
->limit(5)
->get(self::COLUMNS);
}
// With "application_id IN (...) ORDER BY id DESC" PostgreSQL scans the primary key backwards and can
// read most of the table for a team without recent deployments. Take the five newest of each
// application from idx_deployment_queues_app_id instead; the five newest of the team are among them.
$newestPerApplication = ApplicationDeploymentQueue::query()
->select(self::COLUMNS)
->whereColumn('application_id', 'team_applications.id')
->whereNotIn('status', self::ACTIVE_STATUSES)
->orderByDesc('id')
->limit(5);
return $this->deploymentsQuery()
->fromRaw('unnest(?::varchar[]) as team_applications(id)', ['{'.$this->teamApplicationIds()->implode(',').'}'])
->joinLateral($newestPerApplication, 'application_deployment_queues')
->orderByDesc('application_deployment_queues.id')
->limit(5)
->get(self::COLUMNS);
->get(array_map(fn (string $column) => "application_deployment_queues.{$column}", self::COLUMNS));
}
private function deploymentsQuery(): Builder
{
return ApplicationDeploymentQueue::query()
->with(['application.environment.project'])
->ownedByTeam(currentTeam()->id)
->visibleToCurrentActor();
return ApplicationDeploymentQueue::query()->with([
'application' => fn ($query) => $query->withoutGlobalScope('withRelations')->select(['id', 'environment_id']),
'application.environment.project',
]);
}
/**
* The Application access grant scope also limits the IDs to the applications the current actor can see.
*
* @return Collection<int, string>
*/
private function teamApplicationIds(): Collection
{
return $this->teamApplicationIds ??= ApplicationDeploymentQueue::applicationIdsOwnedByTeam(currentTeam()->id);
}
public function render()
@@ -871,6 +871,15 @@ class General extends Component
$this->syncData(toModel: true);
$otherApplication = $this->application->isDirty(['docker_registry_image_name', 'build_pack'])
? $this->application->otherApplicationBuildingSameImage()
: null;
if ($otherApplication) {
$this->addError('dockerRegistryImageName', $this->application->sharedImageNameMessage($otherApplication));
return;
}
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
}
+1 -1
View File
@@ -61,7 +61,7 @@ class Heading extends Component
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
];
$lastDeployment = $this->application->get_last_successful_deployment();
$lastDeployment = $this->application->get_last_successful_deployment(['commit', 'commit_message']);
$this->lastDeploymentInfo = data_get_str($lastDeployment, 'commit')->limit(7).' '.data_get($lastDeployment, 'commit_message');
$this->lastDeploymentLink = $this->application->gitCommitLink(data_get($lastDeployment, 'commit'));
$this->loadComposeOnOpen = $this->activeRouteName === 'project.application.configuration'
@@ -9,6 +9,7 @@ use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Component;
class General extends Component
@@ -35,6 +36,7 @@ class General extends Component
public ?string $postgresConf = null;
#[Locked]
public ?array $initScripts = null;
public string $image;
@@ -265,11 +267,19 @@ class General extends Component
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
// Only an unchanged filename of an existing script skips validation, so legacy rows stay editable.
if (! $oldScript || $oldScript['filename'] !== $script['filename']) {
try {
validateFilenameSafe((string) data_get($script, 'filename'), 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
}
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
// New filename is user-supplied — must be safe before accepting the rename.
validateFilenameSafe($script['filename'], 'init script filename');
// Old filename may be a legacy value written before this validation existed.
// basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
// any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
@@ -990,10 +990,27 @@ class All extends Component
return $variables
->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key))
->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))
->map(fn ($item) => $this->composeDefaultHint($item).formatEnvVariableForDevView($item, $canViewValues))
->join("\n");
}
/**
* An empty value makes the deployment use the Compose default, so show that default above the line.
*/
private function composeDefaultHint(EnvironmentVariable $variable): string
{
if (filled($variable->value) || ! $this->showsHardcodedEnvironmentVariables()) {
return '';
}
$default = $this->composeInfoFor($variable->key)['default'] ?? null;
if (blank($default) || str_contains($default, "\n")) {
return '';
}
return "# Compose default: {$default}\n";
}
public function switch()
{
$this->authorize('view', $this->resource);
@@ -142,7 +142,6 @@ class Show extends Component
$this->private_key->updatePrivateKey([
'private_key' => formatPrivateKey($this->private_key->private_key),
]);
refresh_server_connection($this->private_key);
$this->dispatch('success', 'Private key updated.');
if ($this->modalMode) {
$this->dispatch('privateKeyUpdated');
+55
View File
@@ -32,6 +32,11 @@ class DockerImages extends Component
public int $perPage = 10;
/**
* Column sort such as "size_desc". Empty keeps the order of `docker image ls` (newest first).
*/
public string $sort = '';
public function updatedSearch(): void
{
$this->resetPage();
@@ -43,6 +48,32 @@ class DockerImages extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
if (! array_key_exists(str($sort)->beforeLast('_')->value(), $this->sortColumns())
|| ! in_array(str($sort)->afterLast('_')->value(), ['asc', 'desc'], true)) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
/**
* @return array<string, \Closure(array): mixed>
*/
private function sortColumns(): array
{
return [
'repository' => fn (array $image) => $image['repository'].':'.$image['tag'],
'tag' => fn (array $image) => $image['tag'],
'id' => fn (array $image) => $image['short_id'],
'created' => fn (array $image) => $image['created_at'] ?? 0,
'size' => fn (array $image) => $image['size_bytes'] ?? 0,
'used' => fn (array $image) => implode(', ', $image['containers']),
];
}
public function mount(string $server_uuid)
{
try {
@@ -161,11 +192,27 @@ class DockerImages extends Component
// removed by ID, Docker refuses with "image is referenced in multiple repositories".
'reference' => $isDangling ? $id : "{$repository}:{$tag}",
'size' => data_get($image, 'Size'),
'size_bytes' => $this->sizeToBytes((string) data_get($image, 'Size')),
'created' => data_get($image, 'CreatedSince'),
// Docker prints "2024-05-01 10:00:00 +0000 UTC"; the trailing zone name breaks strtotime().
'created_at' => strtotime(str((string) data_get($image, 'CreatedAt'))->replaceMatches('/ [A-Z]+$/', '')->value()) ?: 0,
'containers' => $containersByImage[$id] ?? [],
];
}
/**
* Docker prints sizes with decimal units, for example "512kB", "52.5MB" or "1.2GB".
*/
private function sizeToBytes(string $size): int
{
if (! preg_match('/^([\d.]+)\s*([kKMGTP]?)B$/', trim($size), $matches)) {
return 0;
}
$exponent = strpos('KMGTP', strtoupper($matches[2]));
return (int) round((float) $matches[1] * 1000 ** ($matches[2] === '' ? 0 : $exponent + 1));
}
public function render()
{
$search = trim($this->search);
@@ -175,6 +222,14 @@ class DockerImages extends Component
->contains($search, ignoreCase: true));
}
$column = $this->sortColumns()[str($this->sort)->beforeLast('_')->value()] ?? null;
if ($column) {
$images = $images->sortBy(
fn (array $image) => [$column($image), $image['reference']],
descending: str($this->sort)->endsWith('_desc'),
);
}
$this->perPage = max(1, min(100, $this->perPage));
$lastPage = max(1, (int) ceil($images->count() / $this->perPage));
$page = max(1, min((int) $this->getPage(), $lastPage));
+44 -3
View File
@@ -22,6 +22,8 @@ class Resources extends Component
public string $search = '';
public string $sort = 'name_asc';
public function updatedSearch(): void
{
$this->resetPage();
@@ -33,6 +35,38 @@ class Resources extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
if (! preg_match('/^(\w+)_(asc|desc)$/', $sort, $matches) || ! array_key_exists($matches[1], $this->sortColumns())) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
/**
* @return array<string, \Closure(mixed): string>
*/
private function sortColumns(): array
{
if ($this->activeTab === 'managed') {
return [
'name' => fn ($resource): string => (string) data_get($resource, 'name'),
'project' => fn ($resource): string => (string) data_get($resource->project(), 'name'),
'environment' => fn ($resource): string => (string) data_get($resource, 'environment.name'),
'type' => fn ($resource): string => (string) $resource->type(),
'status' => fn ($resource): string => (string) data_get($resource, 'status'),
];
}
return [
'name' => fn ($container): string => (string) data_get($container, 'Names'),
'image' => fn ($container): string => (string) data_get($container, 'Image'),
'status' => fn ($container): string => (string) data_get($container, 'State'),
];
}
public ?Server $server = null;
public $parameters = [];
@@ -115,6 +149,7 @@ class Resources extends Component
try {
if ($this->activeTab !== 'managed') {
$this->search = '';
$this->sort = 'name_asc';
$this->resetPage();
}
$this->activeTab = 'managed';
@@ -128,6 +163,7 @@ class Resources extends Component
{
if ($this->activeTab !== 'unmanaged') {
$this->search = '';
$this->sort = 'name_asc';
$this->resetPage();
}
$this->activeTab = 'unmanaged';
@@ -156,9 +192,14 @@ class Resources extends Component
public function render()
{
$resources = $this->activeTab === 'managed'
? $this->server->definedResources()->sortBy('name', SORT_NATURAL)
: collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL);
$columns = $this->sortColumns();
[$column, $direction] = explode('_', $this->sort, 2) + [1 => 'asc'];
$sortValue = $columns[$column] ?? $columns['name'];
$resources = ($this->activeTab === 'managed'
? $this->server->definedResources()
: collect($this->unmanagedContainers))
->sortBy($columns['name'], SORT_NATURAL | SORT_FLAG_CASE)
->sortBy($sortValue, SORT_NATURAL | SORT_FLAG_CASE, $direction === 'desc');
$search = trim($this->search);
if ($search !== '') {
$nameKey = $this->activeTab === 'managed' ? 'name' : 'Names';
+36
View File
@@ -42,6 +42,10 @@ class Show extends Component
#[Locked]
public ?array $deploymentsPerTagPerServer = null;
/** Sort of the active deployments table, such as `resource-asc`; null keeps them grouped by server. */
#[Locked]
public ?string $deploymentSort = null;
/** The "Connect to resources" modal on small screens, where the tags canvas is not shown. */
public bool $connectResourcesOpen = false;
@@ -103,6 +107,37 @@ class Show extends Component
}
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['resource-asc', 'resource-desc', 'server-asc', 'server-desc', 'status-asc', 'status-desc'], true)) {
return;
}
$this->deploymentSort = $sort;
}
/**
* The active deployments as one list, in the order of the chosen table sort.
*
* @return Collection<int, array<string, mixed>>
*/
private function activeDeployments(): Collection
{
$deployments = collect($this->deploymentsPerTagPerServer ?? [])->flatten(1);
if ($this->deploymentSort === null) {
return $deployments->values();
}
[$column, $direction] = explode('-', $this->deploymentSort, 2);
$field = ['resource' => 'application_name', 'server' => 'server_name', 'status' => 'status'][$column];
$order = $direction === 'desc' ? -1 : 1;
return $deployments
->sort(fn (array $left, array $right): int => ($order * strcasecmp((string) $left[$field], (string) $right[$field]))
?: $left['id'] <=> $right['id'])
->values();
}
public function redeployAll()
{
try {
@@ -193,6 +228,7 @@ class Show extends Component
];
})->values()->toArray(),
'connectableResources' => $this->connectableResources(),
'activeDeployments' => $this->activeDeployments(),
]);
}
+28
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
use App\Models\AuditEvent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithPagination;
@@ -20,6 +21,17 @@ class AuditLog extends Component
public int $perPage = 25;
#[Locked]
public string $sort = 'time-desc';
/** @var array<string, string> sort value prefix => audit_events column */
private const SORT_COLUMNS = [
'time' => 'created_at',
'actor' => 'actor_name',
'activity' => 'description',
'source' => 'source',
];
public function boot(): void
{
$team = currentTeam();
@@ -42,6 +54,18 @@ class AuditLog extends Component
$this->resetPage();
}
public function setSort(string $sort): void
{
[$column, $direction] = array_pad(explode('-', $sort, 2), 2, null);
if (! array_key_exists($column, self::SORT_COLUMNS) || ! in_array($direction, ['asc', 'desc'], true)) {
return;
}
$this->sort = $sort;
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(10, min(100, $this->perPage));
@@ -67,6 +91,10 @@ class AuditLog extends Component
$events = AuditEvent::query()
->visibleToTeam($teamId, $canViewInstanceEvents)
->filtered($search, $this->action, $this->source)
->when($this->sort !== 'time-desc', function ($query): void {
[$column, $direction] = explode('-', $this->sort, 2);
$query->orderBy(self::SORT_COLUMNS[$column], $direction);
})
->latestFirst()
->paginate($this->perPage);
+43
View File
@@ -2,10 +2,13 @@
namespace App\Livewire\Team;
use App\Enums\Role;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Invitations extends Component
@@ -14,6 +17,10 @@ class Invitations extends Component
public $invitations;
/** Sort value such as `email-asc`; null keeps the loaded order. */
#[Locked]
public ?string $sort = null;
protected $listeners = ['refreshInvitations'];
public function deleteInvitation(int $invitation_id)
@@ -43,6 +50,42 @@ class Invitations extends Component
}
}
public function setSort(string $sort): void
{
if (! in_array($sort, ['email-asc', 'email-desc', 'via-asc', 'via-desc', 'role-asc', 'role-desc'], true)) {
return;
}
$this->sort = $sort;
}
public function render()
{
return view('livewire.team.invitations', [
'sortedInvitations' => $this->sortedInvitations(),
]);
}
private function sortedInvitations(): Collection
{
$invitations = collect($this->invitations);
if ($this->sort === null) {
return $invitations;
}
[$column, $direction] = explode('-', $this->sort, 2);
$sortValue = fn (TeamInvitation $invitation): int|string => $column === 'role'
? (Role::tryFrom((string) $invitation->role)?->rank() ?? 0)
: strtolower((string) $invitation->{$column});
$order = $direction === 'desc' ? -1 : 1;
return $invitations
->sort(fn (TeamInvitation $left, TeamInvitation $right): int => ($order * ($sortValue($left) <=> $sortValue($right)))
?: strcasecmp((string) $left->email, (string) $right->email))
->values();
}
public function refreshInvitations()
{
$this->invitations = TeamInvitation::ownedByCurrentTeam()->get();
+22 -15
View File
@@ -70,21 +70,6 @@ class InviteLink extends Component
if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
}
$uuid = new_public_id(32);
$link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
$user = User::whereEmail($this->email)->first();
if (is_null($user)) {
$password = Str::password();
$user = User::create([
'name' => str($this->email)->before('@'),
'email' => $this->email,
'password' => Hash::make($password),
'force_password_reset' => true,
]);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = $this->invitationUrl('auth.link', ['token' => $token]);
}
$invitation = TeamInvitation::ownedByCurrentTeam()->whereEmail($this->email)->first();
if (! is_null($invitation)) {
$invitationValid = $invitation->isValid();
@@ -95,6 +80,28 @@ class InviteLink extends Component
}
}
// Look up the user after the expired invitation is removed, because that can delete an unused account.
$uuid = new_public_id(32);
$link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
$user = User::whereEmail($this->email)->first();
// A user who never set a password cannot log in, so they get a magic link with a new temporary password.
if (is_null($user) || $user->isUnusedInvitedAccount()) {
$password = Str::password();
if (is_null($user)) {
$user = User::create([
'name' => str($this->email)->before('@'),
'email' => $this->email,
'password' => Hash::make($password),
'force_password_reset' => true,
]);
} else {
$user->forceFill(['password' => Hash::make($password)])->save();
}
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = $this->invitationUrl('auth.link', ['token' => $token]);
}
$invitation = TeamInvitation::firstOrCreate([
'team_id' => currentTeam()->id,
'uuid' => $uuid,
+33 -17
View File
@@ -712,13 +712,13 @@ class Application extends BaseModel
return false;
}
public function is_github_based(): bool
/**
* Watch paths filter Git webhook deployments. Deploy-key applications have no
* source but receive manual webhooks, so a source is not required.
*/
public function supportsWatchPaths(): bool
{
if (data_get($this, 'source')) {
return true;
}
return false;
return $this->git_based() && ! $this->is_public_repository();
}
public function isGithubAppSource(): bool
@@ -1113,6 +1113,30 @@ class Application extends BaseModel
return data_get($this, 'environment.project.team');
}
/**
* Another application of the team that builds and pushes an image with the same Docker image name.
* Both push the same tags (the commit SHA, `latest`, `pr-<id>`), so one application can run the image of the other.
*/
public function otherApplicationBuildingSameImage(): ?Application
{
$buildPacksWithoutPushedImage = ['dockerimage', 'dockercompose'];
if (blank($this->docker_registry_image_name) || in_array($this->build_pack, $buildPacksWithoutPushedImage, true)) {
return null;
}
return Application::query()
->whereRelation('environment.project.team', 'id', $this->team()->id)
->when($this->exists, fn ($query) => $query->whereKeyNot($this->id))
->where('docker_registry_image_name', $this->docker_registry_image_name)
->whereNotIn('build_pack', $buildPacksWithoutPushedImage)
->first();
}
public function sharedImageNameMessage(Application $otherApplication): string
{
return "The application {$otherApplication->name} also builds the Docker image {$this->docker_registry_image_name}. Both push the same image tags, so one application can run the image of the other. Set a different Docker image name for each application in the General settings.";
}
public function serviceType()
{
$found = str(collect(SPECIFIC_SERVICES)->filter(function ($service) {
@@ -1222,15 +1246,7 @@ class Application extends BaseModel
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
");
->where('is_preview', true);
}
public function runtime_environment_variables_preview()
@@ -1299,9 +1315,9 @@ class Application extends BaseModel
return false;
}
public function get_last_successful_deployment()
public function get_last_successful_deployment(array $columns = ['*'])
{
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first($columns);
}
public function get_last_days_deployments()
+15 -4
View File
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use OpenApi\Attributes as OA;
@@ -181,13 +182,23 @@ class ApplicationDeploymentQueue extends Model
*/
public function scopeOwnedByTeam(Builder $query, int $teamId): Builder
{
// application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
$applicationIds = Application::withTrashed()
return $query->whereIn('application_id', self::applicationIdsOwnedByTeam($teamId));
}
/**
* IDs of the team's applications, deleted ones included, as strings to match application_id.
*
* application_id is varchar; whereHas joins to applications.id (bigint) and breaks on PostgreSQL.
*
* @return Collection<int, string>
*/
public static function applicationIdsOwnedByTeam(int $teamId): Collection
{
return Application::withTrashed()
->withoutGlobalScope('withRelations')
->whereRelation('environment.project.team', 'id', $teamId)
->pluck('id')
->map(fn ($id) => (string) $id);
return $query->whereIn('application_id', $applicationIds);
}
public function server(): Attribute
+16
View File
@@ -5,10 +5,12 @@ namespace App\Models;
use App\Enums\ServerRole;
use App\Enums\TrafficIpMode;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -231,6 +233,9 @@ class ServerSetting extends Model
'geoip_maxmind_license_key',
];
/** @var list<string>|null */
private static ?array $columnsWithoutPatchCheckResult = null;
protected static function booted()
{
static::creating(function ($setting) {
@@ -465,6 +470,17 @@ class ServerSetting extends Model
);
}
/**
* Selects every column except patch_check_result, which holds the full update list of the last patch
* check (often tens of kilobytes). Use it for instance-wide server loops that do not read the result.
*/
public function scopeWithoutPatchCheckResult(Builder $query): Builder
{
self::$columnsWithoutPatchCheckResult ??= array_values(array_diff(Schema::getColumnListing($this->getTable()), ['patch_check_result']));
return $query->select(self::$columnsWithoutPatchCheckResult);
}
/**
* Summary of the saved patch check, or null when the server was never checked.
* Results saved before security and reboot detection count as 0 and false.
+13 -11
View File
@@ -219,19 +219,21 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
*/
public function deleteIfNotVerifiedAndForcePasswordReset(): void
{
if ($this->hasVerifiedEmail() || $this->force_password_reset !== true) {
return;
if ($this->isUnusedInvitedAccount()) {
$this->delete();
}
}
if (TeamInvitation::whereEmail($this->email)->exists()) {
return;
}
if ($this->belongsToNonPersonalTeam()) {
return;
}
$this->delete();
/**
* Whether an invitation created this account and nobody uses it: the owner never set a password,
* it has no pending invitations, and it belongs to no team other than its own personal team.
*/
public function isUnusedInvitedAccount(): bool
{
return ! $this->hasVerifiedEmail()
&& $this->force_password_reset === true
&& ! TeamInvitation::whereEmail($this->email)->exists()
&& ! $this->belongsToNonPersonalTeam();
}
/**
+2 -1
View File
@@ -109,7 +109,8 @@ class TerminalSessionService
{
return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
'if [ -n "$SHELL" ] && [ -x "$SHELL" ] && [ "${SHELL##*/}" != nologin ] && [ "${SHELL##*/}" != false ]; then exec $SHELL; '.
'elif command -v bash >/dev/null 2>&1; then exec bash; else sh; fi';
}
private function cacheKey(string $token): string
+7 -4
View File
@@ -3,20 +3,23 @@
namespace App\Traits;
use App\Actions\User\RevokeUserTeamTokens;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
trait DeletesUserSessions
{
/**
* Delete all sessions for the current user.
* Delete all sessions for this user.
* This will force the user to log in again on all devices.
*/
public function deleteAllSessions(): void
{
// Invalidate the current session
Session::invalidate();
Session::regenerateToken();
// Invalidate the current session only when it belongs to this user, not to an admin who changes it
if (Auth::id() === $this->id) {
Session::invalidate();
Session::regenerateToken();
}
DB::table('sessions')->where('user_id', $this->id)->delete();
RevokeUserTeamTokens::forUser($this->id);
}
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public $withinTransaction = false;
/**
* Supports DockerCleanupJob::failInterruptedExecutions(), which runs on every scheduler tick.
* Only unfinished executions are indexed, so the index stays small on large histories.
*/
public function up(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_docker_cleanup_executions_unfinished_status_created ON docker_cleanup_executions (status, created_at) WHERE finished_at IS NULL');
return;
}
if (! Schema::hasIndex('docker_cleanup_executions', 'idx_docker_cleanup_executions_unfinished_status_created')) {
Schema::table('docker_cleanup_executions', function (Blueprint $table) {
$table->index(['status', 'created_at'], 'idx_docker_cleanup_executions_unfinished_status_created');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_docker_cleanup_executions_unfinished_status_created');
return;
}
if (Schema::hasIndex('docker_cleanup_executions', 'idx_docker_cleanup_executions_unfinished_status_created')) {
Schema::table('docker_cleanup_executions', function (Blueprint $table) {
$table->dropIndex('idx_docker_cleanup_executions_unfinished_status_created');
});
}
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_github_runner_executions_status_server_id ON github_runner_executions (status, server_id)');
return;
}
if (! Schema::hasIndex('github_runner_executions', 'idx_github_runner_executions_status_server_id')) {
Schema::table('github_runner_executions', function (Blueprint $table) {
$table->index(['status', 'server_id'], 'idx_github_runner_executions_status_server_id');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_github_runner_executions_status_server_id');
return;
}
if (Schema::hasIndex('github_runner_executions', 'idx_github_runner_executions_status_server_id')) {
Schema::table('github_runner_executions', function (Blueprint $table) {
$table->dropIndex('idx_github_runner_executions_status_server_id');
});
}
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deployment_queues_app_id ON application_deployment_queues (application_id, id)');
return;
}
if (! Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->index(['application_id', 'id'], 'idx_deployment_queues_app_id');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_deployment_queues_app_id');
return;
}
if (Schema::hasIndex('application_deployment_queues', 'idx_deployment_queues_app_id')) {
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->dropIndex('idx_deployment_queues_app_id');
});
}
}
};
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public $withinTransaction = false;
/**
* Supports PrivateKey::servers() lookups (isInUse(), refresh_server_connection()), which always exclude
* soft-deleted servers. No query reads servers by private key with trashed rows, so only active servers are indexed.
*/
public function up(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_servers_private_key_id_active ON servers (private_key_id) WHERE deleted_at IS NULL');
return;
}
if (! Schema::hasIndex('servers', 'idx_servers_private_key_id_active')) {
Schema::table('servers', function (Blueprint $table) {
$table->index('private_key_id', 'idx_servers_private_key_id_active');
});
}
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_servers_private_key_id_active');
return;
}
if (Schema::hasIndex('servers', 'idx_servers_private_key_id_active')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropIndex('idx_servers_private_key_id_active');
});
}
}
};
-18
View File
@@ -21012,10 +21012,6 @@
"type": "string",
"description": "The value of the environment variable."
},
"is_preview": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -21107,10 +21103,6 @@
"type": "string",
"description": "The value of the environment variable."
},
"is_preview": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -21201,10 +21193,6 @@
"type": "string",
"description": "The value of the environment variable."
},
"is_preview": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is used in preview deployments."
},
"is_literal": {
"type": "boolean",
"description": "The flag to indicate if the environment variable is a literal, nothing espaced."
@@ -26011,9 +25999,6 @@
"is_logdrain_custom_enabled": {
"type": "boolean"
},
"is_logdrain_highlight_enabled": {
"type": "boolean"
},
"is_logdrain_newrelic_enabled": {
"type": "boolean"
},
@@ -26097,9 +26082,6 @@
"logdrain_custom_config_parser": {
"type": "string"
},
"logdrain_highlight_project_id": {
"type": "string"
},
"logdrain_newrelic_base_uri": {
"type": "string"
},
+1 -11
View File
@@ -13237,9 +13237,6 @@ paths:
value:
type: string
description: 'The value of the environment variable.'
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -13300,9 +13297,6 @@ paths:
value:
type: string
description: 'The value of the environment variable.'
is_preview:
type: boolean
description: 'The flag to indicate if the environment variable is used in preview deployments.'
is_literal:
type: boolean
description: 'The flag to indicate if the environment variable is a literal, nothing espaced.'
@@ -13357,7 +13351,7 @@ paths:
properties:
data:
type: array
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object }
items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.' } }, type: object }
type: object
responses:
'201':
@@ -16650,8 +16644,6 @@ components:
type: boolean
is_logdrain_custom_enabled:
type: boolean
is_logdrain_highlight_enabled:
type: boolean
is_logdrain_newrelic_enabled:
type: boolean
is_metrics_enabled:
@@ -16709,8 +16701,6 @@ components:
type: string
logdrain_custom_config_parser:
type: string
logdrain_highlight_project_id:
type: string
logdrain_newrelic_base_uri:
type: string
logdrain_newrelic_license_key:
+38
View File
@@ -205,7 +205,45 @@ set_env_var() {
fi
}
# Quote a value so that Docker Compose and phpdotenv both read it back unchanged (same as install.sh).
quote_env_value() {
local value="$1"
case "$value" in
*\'*)
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//\$/\\\$}
printf '"%s"' "$value"
;;
*)
printf "'%s'" "$value"
;;
esac
}
# Installers before 4.5.1 (and manual edits) wrote root user credentials with spaces without quotes.
# phpdotenv cannot parse such a line, so the recreated Coolify container returns HTTP 500 for every page.
# Only quote lines that phpdotenv rejects: unquoted values that contain whitespace but no comment.
quote_root_user_env_values() {
local key value
for key in ROOT_USERNAME ROOT_USER_EMAIL ROOT_USER_PASSWORD; do
value=$(grep -m1 "^${key}=" "$ENV_FILE" | cut -d '=' -f2-) || continue
# phpdotenv ignores whitespace around an unquoted value (also the \r of CRLF line endings)
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
case "$value" in
\"* | \'* | *[[:space:]]\#*) continue ;;
*[[:space:]]*) ;;
*) continue ;;
esac
# awk reads the value from the environment, so characters such as | & \ in it stay literal
KEY="$key" VALUE="$(quote_env_value "$value")" awk '!done && index($0, ENVIRON["KEY"] "=") == 1 { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"]; done = 1 } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
log "Quoted ${key} (its value has spaces)"
done
}
log "Checking environment variables..."
quote_root_user_env_values
set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="130" height="130" viewBox="0 0 130 130">
<title>Hi.Events Icon - Light</title>
<!-- Icon centered -->
<g transform="translate(65, 65)">
<g transform="rotate(-6)">
<!-- Ticket stub shape -->
<path d="M 20 -28 L -54 -28 Q -56 -28 -56 -26 L -56 26 Q -56 28 -54 28 L 20 28"
fill="none" stroke="#18181b" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 30 -28 L 54 -28 Q 56 -28 56 -26 L 56 26 Q 56 28 54 28 L 30 28"
fill="none" stroke="#18181b" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Soundwave bars -->
<line x1="-38" y1="-8" x2="-38" y2="8" stroke="#cd58dd" stroke-width="4" stroke-linecap="round"/>
<line x1="-26" y1="-16" x2="-26" y2="16" stroke="#cd58dd" stroke-width="4" stroke-linecap="round"/>
<line x1="-14" y1="-16" x2="-14" y2="16" stroke="#cd58dd" stroke-width="4" stroke-linecap="round"/>
<line x1="-2" y1="-8" x2="-2" y2="8" stroke="#cd58dd" stroke-width="4" stroke-linecap="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+93 -2
View File
@@ -3045,6 +3045,53 @@ input[type="search"]::-webkit-search-results-decoration {
color: var(--color-fg-faint);
}
.table-sort-header {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
justify-self: start;
gap: 0.25rem;
border-radius: 4px;
font: inherit;
color: inherit;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: color 150ms;
}
.table-sort-header:hover,
.table-sort-header.is-sorted {
color: #000;
}
.dark .table-sort-header:hover,
.dark .table-sort-header.is-sorted {
color: var(--color-fg);
}
.table-sort-header:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--color-accent, currentColor);
}
.table-sort-header-icon {
width: 0.75rem;
height: 0.75rem;
flex-shrink: 0;
opacity: 0;
transition: opacity 150ms, transform 150ms;
}
.table-sort-header:hover .table-sort-header-icon {
opacity: 0.5;
}
.table-sort-header-icon.is-visible {
opacity: 1;
}
.data-table > .env-table-item {
border-bottom: 1px solid var(--coollabs-fill);
}
@@ -3070,6 +3117,38 @@ input[type="search"]::-webkit-search-results-decoration {
background: rgba(255, 255, 255, 0.02);
}
/* A table cell value that filters the table by that value on click. */
.table-filter-value {
position: relative;
z-index: 1;
max-width: 100%;
pointer-events: auto;
cursor: pointer;
text-align: left;
text-decoration-line: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
transition: color 0.15s, border-color 0.15s, text-decoration-color 0.15s;
}
.table-filter-value:hover {
color: var(--color-black);
text-decoration-color: currentColor;
}
.dark .table-filter-value:hover {
color: var(--color-white);
}
.table-filter-value.table-filter-badge:hover {
text-decoration-color: transparent;
border-color: var(--color-neutral-400);
}
.dark .table-filter-value.table-filter-badge:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.env-table-grid {
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
}
@@ -3421,7 +3500,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.backup-table-grid {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 5rem;
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 6rem;
min-width: 50rem;
}
@@ -4195,6 +4274,18 @@ html[data-theme="custom"] .logs-viewer-timestamp {
width: auto;
}
/* Mobile: search on its own row, cancel/force-start + menus below it */
@media (max-width: 639.98px) {
.logs-viewer-end:not(.runtime-logs-viewer-end) {
flex-wrap: wrap;
}
.logs-viewer-end:not(.runtime-logs-viewer-end) > .logs-viewer-search {
order: -1;
flex: 1 1 100%;
}
}
.runtime-logs-viewer-end {
align-items: stretch;
flex-direction: column;
@@ -4316,7 +4407,7 @@ html[data-theme="custom"] .logs-viewer-timestamp {
/* Desktop: [actions + status] ... [cancel + search] */
.logs-viewer-toolbar-controls {
flex-direction: row;
flex-wrap: nowrap;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
@@ -3,6 +3,8 @@
<div class="flex flex-col gap-4" x-data="{
showRecovery: false,
submitting: false,
code: '',
codeFocused: false,
handleSubmit(event) {
if (this.submitting) {
event.preventDefault();
@@ -12,6 +14,7 @@
},
submitAuthenticatorCode(event) {
event.target.value = event.target.value.replace(/\D/g, '').slice(0, 6);
this.code = event.target.value;
if (event.target.value.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
@@ -43,11 +46,23 @@
@csrf
<div x-show="!showRecovery" class="flex flex-col gap-3">
<input x-ref="authenticatorCode" type="text" name="code" inputmode="numeric"
pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus
aria-label="Two-factor authentication code" :disabled="showRecovery"
@input="submitAuthenticatorCode($event)"
class="mx-auto h-14 w-64 rounded-md border border-neutral-300 bg-white px-4 text-center text-xl font-semibold tracking-[0.5em] text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white" />
<div class="relative mx-auto flex gap-2">
@for ($i = 0; $i < 6; $i++)
<div aria-hidden="true" data-code-digit
class="flex h-14 w-11 items-center justify-center rounded-md border bg-white text-xl font-semibold text-neutral-900 transition-colors dark:bg-coolgray-100 dark:text-white"
:class="codeFocused && (code.length === {{ $i }} || (code.length === 6 && {{ $i }} === 5))
? 'border-coollabs ring-1 ring-coollabs'
: 'border-neutral-300 dark:border-white/10'"
x-text="code[{{ $i }}] ?? ''"></div>
@endfor
<input x-ref="authenticatorCode" type="text" name="code" inputmode="numeric"
pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus
aria-label="Two-factor authentication code" :disabled="showRecovery"
x-init="codeFocused = document.activeElement === $el"
@input="submitAuthenticatorCode($event)" @focus="codeFocused = true"
@blur="codeFocused = false"
class="absolute inset-0 h-full w-full cursor-text rounded-md border-0 bg-transparent text-transparent caret-transparent opacity-0 focus:outline-none focus:ring-0" />
</div>
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())">
Use a recovery code
@@ -0,0 +1,71 @@
{{--
A column header that sorts its table. The first click sorts by `first`, the next
click flips the direction. An arrow shows the active direction.
Alpine tables set `model` (the sort variable in the enclosing x-data scope) and an
optional `then` expression that runs after the change (for example `page = 1`).
Livewire tables set `action` (a component method that takes the sort value) and
`current` (the current server-side sort value). While that request runs, the
clicked header shows a spinner.
@param string $label
@param string $asc sort value for ascending order
@param string $desc sort value for descending order
@param string $first 'asc' or 'desc': the direction of the first click
--}}
@props([
'label',
'asc',
'desc',
'first' => 'asc',
'model' => 'sortBy',
'then' => null,
'action' => null,
'current' => null,
])
@php
$firstValue = $first === 'desc' ? $desc : $asc;
$otherValue = $first === 'desc' ? $asc : $desc;
$ariaBase = 'Sort by '.strtolower($label);
@endphp
@if ($action)
@php
$direction = match ($current) {
$asc => 'asc',
$desc => 'desc',
default => null,
};
$nextValue = $current === $firstValue ? $otherValue : $firstValue;
$call = $action.'('.\Illuminate\Support\Js::from($nextValue).')';
@endphp
<button type="button" wire:click="{{ $call }}" wire:loading.attr="aria-busy" wire:target="{{ $call }}"
aria-label="{{ $ariaBase }}{{ $direction ? ', sorted '.($direction === 'asc' ? 'ascending' : 'descending') : '' }}"
{{ $attributes->class(['table-sort-header group', 'is-sorted' => $direction]) }}>
<span class="truncate">{{ $label }}</span>
<svg @class(['table-sort-header-icon', 'rotate-180' => $direction === 'desc', 'is-visible' => $direction]) viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"
wire:loading.remove wire:target="{{ $call }}">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
</svg>
<svg class="size-3 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"
wire:loading wire:target="{{ $call }}">
<circle class="opacity-20" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
</button>
@else
<button type="button"
x-on:click="{{ $model }} = ({{ $model }} === @js($firstValue) ? @js($otherValue) : @js($firstValue)){{ $then ? '; '.$then : '' }}"
x-bind:aria-label="@js($ariaBase) + ({{ $model }} === @js($asc) ? ', sorted ascending' : ({{ $model }} === @js($desc) ? ', sorted descending' : ''))"
x-bind:class="{ 'is-sorted': [@js($asc), @js($desc)].includes({{ $model }}) }"
{{ $attributes->class(['table-sort-header group']) }}>
<span class="truncate">{{ $label }}</span>
<svg class="table-sort-header-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
aria-hidden="true"
x-bind:class="{ 'is-visible': [@js($asc), @js($desc)].includes({{ $model }}), 'rotate-180': {{ $model }} === @js($desc) }">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 15l6-6 6 6" />
</svg>
</button>
@endif
+21 -37
View File
@@ -8,36 +8,8 @@
@endif
@php
$dashboardItemLimit = 8;
$dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$dashboardItemLimit = \App\Livewire\Dashboard::ITEM_LIMIT;
$hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled());
$projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
$project->sqlites_count,
])->sum()]);
$serverStatuses = $dashboardServers->mapWithKeys(function ($server) {
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
return [$server->id => match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
}];
});
$iconButton = 'card-icon-button flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black';
// Same look as $iconButton, sized to the 20px section title it sits next to.
$titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
@@ -188,11 +160,17 @@
@endforeach
</div>
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div x-show="viewMode === 'table'" class="{{ $tableShell }} transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setProjectSort">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableHead }}">
<div>Project</div>
<div>Contents</div>
<div class="hidden @2xl:block">Description</div>
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc"
action="setProjectSort" :current="$projectSort" />
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc"
action="setProjectSort" :current="$projectSort" title="Sort by resource count" />
<div class="hidden @2xl:block">
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc"
action="setProjectSort" :current="$projectSort" />
</div>
<div></div>
</div>
@foreach ($dashboardProjects as $project)
@@ -368,14 +346,20 @@
? 'grid-cols-[minmax(0,1fr)_9.5rem] @xl:grid-cols-[minmax(0,1fr)_9rem_9.5rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9rem_9.5rem]'
: 'grid-cols-[minmax(0,1fr)_9.5rem] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem]';
@endphp
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div x-show="viewMode === 'table'" class="{{ $tableShell }} transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setServerSort">
<div class="grid {{ $serverTableColumns }} gap-4 @2xl:min-w-[480px] {{ $tableHead }}">
<div>Server</div>
<div class="hidden @2xl:block">Description</div>
<x-table.sort-header label="Server" asc="name-asc" desc="name-desc"
action="setServerSort" :current="$serverSort" />
<div class="hidden @2xl:block">
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc"
action="setServerSort" :current="$serverSort" />
</div>
@if ($showServerUsage)
<div class="hidden @xl:block">Usage</div>
@endif
<div>Status</div>
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc"
action="setServerSort" :current="$serverSort" />
</div>
@foreach ($dashboardServers as $server)
@php
@@ -39,192 +39,197 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
icon-name="network" />
</x-application.settings-section>
@else
@if ($live)
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
{{-- Keyed so the lazy-load morph replaces the placeholder skeleton, which has the same
sections, instead of patching it in place: Alpine does not start the x-data
components (Locations toggle and globe, table pagers) on reused skeleton nodes. --}}
<div wire:key="analytics-loaded" class="flex flex-col gap-6">
@if ($live)
<div wire:poll.60s="loadData" class="hidden"></div>
@endif
<x-application.settings-section id="analytics-range-section" title="Analytics"
helper="Inspect traffic statistics reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<div class="relative w-full transition-opacity sm:w-44"
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="setRange">
<x-loading compact aria-label="Loading analytics" />
<x-application.settings-section id="analytics-range-section" title="Analytics"
helper="Inspect traffic statistics reported by Sentinel.">
<x-slot:actions>
<div class="flex items-center gap-2">
<div class="relative w-full transition-opacity sm:w-44"
wire:loading.class="pointer-events-none opacity-60" wire:target="setRange">
<x-forms.listbox id="range" onChange="setRange" :onChangeArgs="[]" :options="$rangeListboxOptions" />
<div class="absolute inset-0 hidden items-center justify-center rounded-lg bg-white/70 dark:bg-base/70"
wire:loading.flex wire:target="setRange">
<x-loading compact aria-label="Loading analytics" />
</div>
</div>
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
</div>
</x-slot:actions>
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-5 dark:bg-white/[0.07]">
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($overview['requests'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-requests',
'initial' => $this->requestsSpark(),
'colorVar' => '--chart-status-3xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'requestsSpark',
])
</div>
</div>
<x-live-toggle :active="$live" storageKey="traffic-live" title="Turn live refresh on or off (updates every 60 seconds)" />
</div>
</x-slot:actions>
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-neutral-200 sm:grid-cols-3 lg:grid-cols-5 dark:bg-white/[0.07]">
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Requests</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['requests'] ?? 0) }} requests">{{ compactNumber($overview['requests'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-requests',
'initial' => $this->requestsSpark(),
'colorVar' => '--chart-status-3xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'requestsSpark',
])
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
Unique visitors
@if ($uniquesApproximate)
{!! $approxBadge('Summed across services; visitors of more than one service may be double-counted.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($overview['uniqueVisitors'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-visitors',
'initial' => $this->uniquesSpark(),
'colorVar' => '--chart-status-2xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'uniquesSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-bandwidth',
'initial' => $this->bandwidthSpark(),
'colorVar' => '--chart-spark-bandwidth',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'bandwidthSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ $this->errorRate() }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-errors',
'initial' => $this->errorsSpark(),
'colorVar' => '--chart-status-5xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'errorsSpark',
])
</div>
</div>
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
p95 latency
@if ($latencyApproximate)
{!! $approxBadge('Highest p95 latency across services; not a true merged percentile.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-latency',
'initial' => $this->latencySpark(),
'colorVar' => '--chart-status-4xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'latencySpark',
])
</div>
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
Unique visitors
@if ($uniquesApproximate)
{!! $approxBadge('Summed across services; visitors of more than one service may be double-counted.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg" title="{{ number_format($overview['uniqueVisitors'] ?? 0) }} unique visitors">{{ compactNumber($overview['uniqueVisitors'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-visitors',
'initial' => $this->uniquesSpark(),
'colorVar' => '--chart-status-2xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'uniquesSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Bandwidth</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatBytes($this->bandwidthBytes()) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-bandwidth',
'initial' => $this->bandwidthSpark(),
'colorVar' => '--chart-spark-bandwidth',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'bandwidthSpark',
])
</div>
</div>
<div class="flex flex-col bg-[var(--coollabs-base)] px-4 py-3">
<span class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">Error rate</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ $this->errorRate() }}%</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-errors',
'initial' => $this->errorsSpark(),
'colorVar' => '--chart-status-5xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'errorsSpark',
])
</div>
</div>
<div class="col-span-2 flex flex-col bg-[var(--coollabs-base)] px-4 py-3 sm:col-span-1">
<span class="flex items-center text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
p95 latency
@if ($latencyApproximate)
{!! $approxBadge('Highest p95 latency across services; not a true merged percentile.') !!}
@endif
</span>
<span class="mt-1 text-xl font-semibold text-black tabular-nums dark:text-fg">{{ formatLatency($overview['latencyP95'] ?? 0) }}</span>
<div class="mt-auto pt-3">
@include('livewire.traffic._sparkline', [
'id' => $chartId.'-spark-latency',
'initial' => $this->latencySpark(),
'colorVar' => '--chart-status-4xx',
'event' => 'refreshChartData-'.$chartId.'-status',
'key' => 'latencySpark',
])
</div>
</div>
</div>
</x-application.settings-section>
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
helper="Total request volume over time for the selected range.">
@include('livewire.traffic._requests-chart')
</x-application.settings-section>
<x-application.settings-section id="analytics-status-codes-section" title="Status codes"
helper="Share of responses by HTTP status class for the selected range.">
@include('livewire.traffic._status-codes')
</x-application.settings-section>
<x-application.settings-section id="analytics-paths-section" title="Top paths"
helper="Most requested paths for the selected range." flush>
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
</x-application.settings-section>
@include('livewire.traffic._geo', [
'countries' => data_get($breakdowns, 'country', []),
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
'attribution' => $attribution,
])
{{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<x-application.settings-section id="analytics-device-section" title="Requests by device type"
helper="Share of requests by client device class for the selected range.">
@php $deviceChart = $this->deviceChartData(); @endphp
@include('livewire.traffic._device-chart', [
'labels' => $deviceChart['labels'],
'series' => $deviceChart['series'],
])
</x-application.settings-section>
@include('livewire.traffic._breakdown-section', [
'dimension' => 'protocol',
'label' => 'Top HTTP versions',
'rows' => data_get($breakdowns, 'protocol', []),
'helper' => 'Request volume by negotiated HTTP protocol version.',
])
</div>
<x-application.settings-section id="analytics-requests-section" title="Requests" flush
helper="Total request volume over time for the selected range.">
@include('livewire.traffic._requests-chart')
</x-application.settings-section>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@include('livewire.traffic._breakdown-section', [
'dimension' => 'cache',
'label' => 'Top cache statuses',
'rows' => data_get($breakdowns, 'cache', []),
'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, …) by request count.',
<x-application.settings-section id="analytics-status-codes-section" title="Status codes"
helper="Share of responses by HTTP status class for the selected range.">
@include('livewire.traffic._status-codes')
</x-application.settings-section>
<x-application.settings-section id="analytics-paths-section" title="Top paths"
helper="Most requested paths for the selected range." flush>
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
</x-application.settings-section>
@include('livewire.traffic._geo', [
'countries' => data_get($breakdowns, 'country', []),
'cities' => trafficCityRows(data_get($breakdowns, 'city', [])),
'attribution' => $attribution,
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'status',
'label' => 'Top status codes',
'rows' => data_get($breakdowns, 'status', []),
'helper' => 'Most frequent HTTP response status codes for the selected range.',
])
</div>
{{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<x-application.settings-section id="analytics-device-section" title="Requests by device type"
helper="Share of requests by client device class for the selected range.">
@php $deviceChart = $this->deviceChartData(); @endphp
@include('livewire.traffic._device-chart', [
'labels' => $deviceChart['labels'],
'series' => $deviceChart['series'],
])
</x-application.settings-section>
{{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($dimensionLabels as $dimension => $label)
@include('livewire.traffic._breakdown-section', [
'dimension' => $dimension,
'label' => $label,
'rows' => data_get($breakdowns, $dimension, []),
'dimension' => 'protocol',
'label' => 'Top HTTP versions',
'rows' => data_get($breakdowns, 'protocol', []),
'helper' => 'Request volume by negotiated HTTP protocol version.',
])
@endforeach
@include('livewire.traffic._breakdown-section', [
'dimension' => 'agent',
'label' => 'AI agents & bots',
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@include('livewire.traffic._breakdown-section', [
'dimension' => 'useragent',
'label' => 'Top user agents',
'rows' => data_get($breakdowns, 'useragent', []),
'helper' => 'Most frequent raw User-Agent strings for the selected range.',
])
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@include('livewire.traffic._breakdown-section', [
'dimension' => 'cache',
'label' => 'Top cache statuses',
'rows' => data_get($breakdowns, 'cache', []),
'helper' => 'Reverse-proxy cache outcome (hit, miss, bypass, …) by request count.',
])
@include('livewire.traffic._breakdown-section', [
'dimension' => 'status',
'label' => 'Top status codes',
'rows' => data_get($breakdowns, 'status', []),
'helper' => 'Most frequent HTTP response status codes for the selected range.',
])
</div>
{{-- Referrers / browsers / OS / AI agents / IPs, two per row. --}}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@foreach ($dimensionLabels as $dimension => $label)
@include('livewire.traffic._breakdown-section', [
'dimension' => $dimension,
'label' => $label,
'rows' => data_get($breakdowns, $dimension, []),
])
@endforeach
@include('livewire.traffic._breakdown-section', [
'dimension' => 'agent',
'label' => 'AI agents & bots',
'rows' => data_get($breakdowns, 'agent', []),
'helper' => 'Bot and AI-crawler traffic (GPTBot, ClaudeBot, Googlebot, …) by request count.',
])
@php($ipModeEnum = \App\Enums\TrafficIpMode::tryFrom($ipMode) ?? \App\Enums\TrafficIpMode::Full)
@if ($ipModeEnum !== \App\Enums\TrafficIpMode::Off)
@include('livewire.traffic._breakdown-section', [
'dimension' => 'ip',
'label' => $ipModeEnum->breakdownLabel(),
'rows' => data_get($breakdowns, 'ip', []),
'helper' => $ipModeEnum->breakdownHelper(),
])
@endif
</div>
{{-- User agents (full width — raw UA strings are long). --}}
@include('livewire.traffic._breakdown-section', [
'dimension' => 'useragent',
'label' => 'Top user agents',
'rows' => data_get($breakdowns, 'useragent', []),
'helper' => 'Most frequent raw User-Agent strings for the selected range.',
])
</div>
@endif
</div>
@@ -9,6 +9,15 @@
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latestExecution?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->count() : -1,
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
'executions' => $backup->executions_count,
'createdAt' => $backup->created_at?->timestamp ?? 0,
])->values()),
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
@@ -30,11 +39,25 @@
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
return matchesSearch && matchesType;
});
const [column, direction] = this.sortBy.split('_');
const sortValues = {
target: (backup) => backup.name,
type: (backup) => backup.type,
schedule: (backup) => backup.frequency,
status: (backup) => backup.status,
s3: (backup) => backup.s3,
lastrun: (backup) => backup.lastRunAt,
executions: (backup) => backup.executions,
};
const sortValue = sortValues[column] || sortValues.target;
const order = direction === 'desc' ? -1 : 1;
return filtered.sort((left, right) => {
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
return left.name.localeCompare(right.name);
const leftValue = sortValue(left);
const rightValue = sortValue(right);
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
left.name.localeCompare(right.name);
});
},
isVisible(id) {
@@ -168,13 +191,14 @@
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<span class="text-right">Executions</span>
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
<x-table.sort-header label="Executions" asc="executions_asc" desc="executions_desc" first="desc"
class="justify-self-end" />
</div>
@foreach ($backups as $backup)
@@ -205,7 +229,12 @@
title="{{ $backup->targetName() }}">
{{ $backup->targetName() }}
</span>
<span>{{ $backup->targetType() }}</span>
<span class="min-w-0">
<span role="button" tabindex="0" class="table-filter-value"
title="Filter by {{ $backup->targetType() }}"
x-on:click.prevent.stop="typeFilter = typeFilter === @js(strtolower($backup->targetType())) ? 'all' : @js(strtolower($backup->targetType()))"
x-on:keydown.enter.prevent.stop="$el.click()">{{ $backup->targetType() }}</span>
</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span title="{{ $backup->save_s3 ? ($s3StorageNames->isNotEmpty() ? $s3StorageNames->join(', ') : 'S3 storage unavailable') : 'S3 storage is not configured' }}">
@@ -183,7 +183,7 @@
@if ($deployments->isNotEmpty())
<div class="data-table relative w-full transition-opacity"
wire:loading.class="opacity-50 pointer-events-none"
wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter">
wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter,setDeploymentSort">
<x-table.loading id="deployment-table-filter-loading"
target="toggleDeploymentFilter,clearFilter,setPullRequestFilter"
text="Filtering deployments..." class="rounded-lg" />
@@ -202,7 +202,8 @@
<span>Source</span>
<span>Commit</span>
@unless ($embedded)
<span>Started</span>
<x-table.sort-header label="Started" asc="oldest" desc="newest" first="desc"
action="setDeploymentSort" :current="$deploymentSort" />
@endunless
<span>Duration</span>
<span>Server</span>
@@ -236,6 +237,15 @@
(bool) data_get($deployment, 'is_api') => 'API',
default => 'Manual',
};
$sourceKey = match (true) {
$pullRequestId > 0 => 'pull-request',
(bool) data_get($deployment, 'is_webhook') => 'webhook',
(bool) data_get($deployment, 'rollback') => 'rollback',
(bool) data_get($deployment, 'is_api') => 'api',
default => 'manual',
};
$serverId = data_get($deployment, 'server_id');
$serverName = data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-');
$duration = match ($deploymentStatus) {
'queued' => 'Waiting',
'in_progress' => calculateDuration(data_get($deployment, 'created_at'), now()),
@@ -264,9 +274,21 @@
x-bind:aria-busy="selecting === '{{ data_get($deployment, 'deployment_uuid') }}'"
@endif>
@unless ($embedded)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}><x-status-badge :status="$statusLabel" :type="$statusType" /></a>
<span class="min-w-0">
<x-status-badge as="button" :status="$statusLabel" :type="$statusType"
class="table-filter-value table-filter-badge" title="Filter by {{ $statusLabel }}"
wire:click="toggleDeploymentFilter('status:{{ $deploymentStatus }}')" />
</span>
@endunless
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}>{{ $sourceLabel }}</a>
@if ($embedded)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}>{{ $sourceLabel }}</a>
@else
<span class="min-w-0">
<button type="button" class="table-filter-value truncate"
title="Filter by {{ $sourceLabel }}"
wire:click="toggleDeploymentFilter('source:{{ $sourceKey }}')">{{ $sourceLabel }}</button>
</span>
@endif
<span class="min-w-0">
@if (data_get($deployment, 'commit'))
<span class="flex min-w-0 items-center gap-2">
@@ -285,7 +307,7 @@
@endif
@if ($embedded)
<span class="mt-0.5 hidden truncate text-xs text-neutral-500 group-hover:text-neutral-950 dark:text-fg-faint dark:group-hover:text-white 2xl:block">
{{ $sourceLabel }} · <span class="tabular-nums">{{ $duration }}</span> · {{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }}
{{ $sourceLabel }} · <span class="tabular-nums">{{ $duration }}</span> · {{ $serverName }}
</span>
@endif
</span>
@@ -295,9 +317,15 @@
</a>
@endunless
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="tabular-nums">{{ $duration }}</a>
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="truncate">
{{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }}
</a>
@if (! $embedded && $serverId)
<span class="min-w-0">
<button type="button" class="table-filter-value truncate"
title="Filter by {{ $serverName }}"
wire:click="toggleDeploymentFilter('server:{{ (int) $serverId }}')">{{ $serverName }}</button>
</span>
@else
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!} class="truncate">{{ $serverName }}</a>
@endif
@if ($embedded)
<a href="{{ $deploymentUrl }}" {!! $deploymentLinkAttributes !!}
class="flex items-center justify-end gap-2 pr-1 tabular-nums"
@@ -249,7 +249,7 @@
</div>
@endif
</div>
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
@if ($this->application->supportsWatchPaths())
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Enter one pattern per line. Commas do not separate patterns. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
@@ -272,7 +272,7 @@
@endif
@endif
</x-forms.repository-paths>
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
@if ($this->application->supportsWatchPaths())
<div class="pb-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Enter one pattern per line. Commas do not separate patterns. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
@@ -142,10 +142,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="projects-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Project</div>
<div>Contents</div>
<div>Created</div>
<div class="project-description">Description</div>
<x-table.sort-header label="Project" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Contents" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Created" asc="created-asc" desc="created-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
class="project-description" />
<div></div>
</div>
@@ -233,11 +234,11 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
{
value: 'environments',
value: 'environments-desc',
label: 'Most environments'
},
],
@@ -252,21 +253,23 @@
return !query || searchable.includes(query);
});
return projects.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'environments') {
return second.environmentCount - first.environmentCount ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (project) => project.name,
resources: (project) => Number(project.resourceCount),
environments: (project) => Number(project.environmentCount),
created: (project) => Date.parse(project.createdAt) || 0,
description: (project) => project.description || '',
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return first.name.localeCompare(second.name);
});
return projects.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredProjects.length / this.pageSize));
@@ -167,11 +167,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="environment-resource-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Resource</div>
<div class="resource-type">Type</div>
<div>Status</div>
<div class="resource-domain">Domain</div>
<div class="resource-server">Server</div>
<x-table.sort-header label="Resource" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Type" asc="type-asc" desc="type-desc" then="page = 1" class="resource-type" />
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc" then="page = 1" />
<x-table.sort-header label="Domain" asc="domain-asc" desc="domain-desc" then="page = 1" class="resource-domain" />
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc" then="page = 1" class="resource-server" />
<div class="resource-tags">Tags</div>
</div>
@@ -206,11 +206,17 @@
</div>
</div>
<div class="resource-type truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="item.typeLabel"></div>
<div class="resource-type min-w-0">
<button type="button"
class="table-filter-value truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-on:click="toggleFilter('typeFilters', item.type)"
x-bind:title="`Filter by ${item.typeLabel}`" x-text="item.typeLabel"></button>
</div>
<div>
<x-status-badge dynamic x-bind:title="statusTitle(item)">
<div class="min-w-0">
<x-status-badge dynamic as="button" class="table-filter-value table-filter-badge"
x-on:click="toggleFilter('statusFilters', statusState(item))"
x-bind:title="`${statusTitle(item)} · Filter by ${statusLabel(item)}`">
<span class="size-1.5 shrink-0 rounded-full"
x-bind:class="statusDotClass(item)"></span>
<span class="truncate" x-text="statusLabel(item)"></span>
@@ -226,8 +232,13 @@
<span x-show="!item.fqdn" class="text-[12px] text-neutral-400 dark:text-fg-faint">-</span>
</div>
<div class="resource-server truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="item.destination?.server?.name || 'Unknown'"></div>
<div class="resource-server min-w-0">
<button type="button"
class="table-filter-value truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-on:click="toggleFilter('serverFilters', item.destination?.server?.name || 'Unknown')"
x-bind:title="`Filter by ${item.destination?.server?.name || 'Unknown'}`"
x-text="item.destination?.server?.name || 'Unknown'"></button>
</div>
<div class="resource-tags flex min-w-0 items-center gap-1 overflow-hidden">
<template x-for="tag in item.tags.slice(0, 2)" :key="tag.id">
@@ -401,11 +412,11 @@
label: 'Name Z–A'
},
{
value: 'type',
value: 'type-asc',
label: 'Resource type'
},
{
value: 'status',
value: 'status-asc',
label: 'Status'
},
],
@@ -432,21 +443,20 @@
(!query || searchable.includes(query));
});
return items.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'type') {
return first.typeLabel.localeCompare(second.typeLabel) ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'status') {
return this.statusLabel(first).localeCompare(this.statusLabel(second)) ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (item) => item.name,
type: (item) => item.typeLabel,
status: (item) => this.statusLabel(item),
domain: (item) => this.displayDomain(item.fqdn) || '',
server: (item) => item.destination?.server?.name || 'Unknown',
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
return first.name.localeCompare(second.name);
});
return items.sort((first, second) =>
order * (sortValue(first).localeCompare(sortValue(second)) ||
first.name.localeCompare(second.name)));
},
uniqueOptions(options) {
return [...new Map(options
@@ -9,12 +9,28 @@
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latestExecution?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
'lastRunAt' => $backup->latestExecution?->finished_at?->timestamp ?? ($backup->latestExecution?->status === 'running' ? now()->timestamp : 0),
'createdAt' => $backup->created_at?->timestamp ?? 0,
])->concat($databaseBackups->map(fn ($backup) => [
'id' => 'database:'.$backup->id,
'name' => strtolower($backup->database->human_name ?: $backup->database->name),
'type' => 'database',
'frequency' => strtolower($backup->frequency),
'status' => match ($backup->latest_log?->status) {
'running' => 'in progress',
'success' => 'success',
'failed' => 'failed',
default => $backup->enabled ? 'waiting' : 'disabled',
},
's3' => $backup->save_s3 ? ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId)->count() : -1,
'lastRunAt' => $backup->latest_log?->finished_at?->timestamp ?? ($backup->latest_log?->status === 'running' ? now()->timestamp : 0),
'createdAt' => $backup->created_at?->timestamp ?? 0,
]))->values()),
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
@@ -36,11 +52,24 @@
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
return matchesSearch && matchesType;
});
const [column, direction] = this.sortBy.split('_');
const sortValues = {
target: (backup) => backup.name,
type: (backup) => backup.type,
schedule: (backup) => backup.frequency,
status: (backup) => backup.status,
s3: (backup) => backup.s3,
lastrun: (backup) => backup.lastRunAt,
};
const sortValue = sortValues[column] || sortValues.target;
const order = direction === 'desc' ? -1 : 1;
return filtered.sort((left, right) => {
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
return left.name.localeCompare(right.name);
const leftValue = sortValue(left);
const rightValue = sortValue(right);
return order * (typeof leftValue === 'number' ? leftValue - rightValue : leftValue.localeCompare(rightValue)) ||
left.name.localeCompare(right.name);
});
},
isVisible(id) {
@@ -241,12 +270,12 @@
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="flex min-w-[64rem] flex-col">
<div class="data-table-header backup-table-grid service-backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<x-table.sort-header label="Target" asc="target_asc" desc="target_desc" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" />
<x-table.sort-header label="Schedule" asc="schedule_asc" desc="schedule_desc" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" />
<x-table.sort-header label="S3" asc="s3_asc" desc="s3_desc" />
<x-table.sort-header label="Last run" asc="lastrun_asc" desc="lastrun_desc" first="desc" />
<span class="text-right">Actions</span>
</div>
@@ -279,7 +308,12 @@
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg">
{{ $databaseBackup->database->human_name ?: $databaseBackup->database->name }}
</span>
<span>Database</span>
<span class="min-w-0">
<button type="button" class="table-filter-value"
title="Filter by Database"
x-on:click.stop="typeFilter = typeFilter === 'database' ? 'all' : 'database'"
x-on:keydown.enter.stop>Database</button>
</span>
<span>{{ $databaseBackup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
@@ -333,7 +367,12 @@
title="{{ $backup->targetName() }}">
{{ $backup->targetName() }}
</span>
<span>{{ $backup->targetType() }}</span>
<span class="min-w-0">
<button type="button" class="table-filter-value"
title="Filter by {{ $backup->targetType() }}"
x-on:click.stop="typeFilter = typeFilter === @js(strtolower($backup->targetType())) ? 'all' : @js(strtolower($backup->targetType()))"
x-on:keydown.enter.stop>{{ $backup->targetType() }}</button>
</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
@@ -222,7 +222,8 @@
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<span>Name</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc"
action="setTableSort" :current="$tableSort" />
<span class="text-center">Managed</span>
@if ($showEnvironmentType)
<span>Type</span>
+26 -18
View File
@@ -148,9 +148,11 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="environments-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Environment</div>
<div class="environment-resource-count">Resources</div>
<div class="environment-description">Description</div>
<x-table.sort-header label="Environment" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1"
class="environment-resource-count" />
<x-table.sort-header label="Description" asc="description-asc" desc="description-desc" then="page = 1"
class="environment-description" />
<div></div>
</div>
@@ -230,7 +232,7 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
],
@@ -245,21 +247,27 @@
return !query || searchable.includes(query);
});
return environments.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'name-asc') {
return first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (environment) => environment.name,
resources: (environment) => Number(environment.resourceCount),
description: (environment) => environment.description || '',
};
const sortValue = sortValues[column];
// Default: the server order (production, staging, development, then by name).
return 0;
});
// Default: the server order (production, staging, development, then by name).
if (!sortValue) {
return environments;
}
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return environments.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredEnvironments.length / this.pageSize));
@@ -48,14 +48,20 @@
: 'Images pulled or built on this server will appear here.'" icon-name="layers" />
</div>
@else
<div class="data-table">
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none" wire:target="setSort">
<div class="data-table-header server-images-table-grid">
<span>Repository</span>
<span>Tag</span>
<span>Image ID</span>
<span>Created</span>
<span>Size</span>
<span>Used by</span>
<x-table.sort-header label="Repository" asc="repository_asc" desc="repository_desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Tag" asc="tag_asc" desc="tag_desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Image ID" asc="id_asc" desc="id_desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Created" asc="created_asc" desc="created_desc" first="desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Size" asc="size_asc" desc="size_desc" first="desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Used by" asc="used_asc" desc="used_desc" action="setSort"
:current="$sort" />
<span>Actions</span>
</div>
@foreach ($visibleImages as $image)
@@ -54,13 +54,13 @@
wire:loading.attr="inert" wire:target="search">
@if ($activeTab === 'managed')
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none" wire:target="setSort">
<div class="data-table-header server-resources-managed-table-grid">
<span>Name</span>
<span>Project</span>
<span>Environment</span>
<span>Type</span>
<span>Status</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Project" asc="project_asc" desc="project_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Environment" asc="environment_asc" desc="environment_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Type" asc="type_asc" desc="type_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
</div>
@foreach ($resources as $resource)
@php($resourceStatus = (string) data_get($resource, 'status', 'unknown'))
@@ -107,11 +107,11 @@
@endif
@else
@if ($resources->total() > 0)
<div class="data-table">
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none" wire:target="setSort">
<div class="data-table-header server-resources-unmanaged-table-grid">
<span>Name</span>
<span>Image</span>
<span>Status</span>
<x-table.sort-header label="Name" asc="name_asc" desc="name_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Image" asc="image_asc" desc="image_desc" action="setSort" :current="$sort" />
<x-table.sort-header label="Status" asc="status_asc" desc="status_desc" action="setSort" :current="$sort" />
<span>Actions</span>
</div>
@foreach ($resources as $resource)
+42 -33
View File
@@ -126,10 +126,12 @@
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="tags-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Tag</div>
<div>Resources</div>
<div>Applications</div>
<div class="tag-services">Services</div>
<x-table.sort-header label="Tag" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Resources" asc="resources-asc" desc="resources-desc" first="desc" then="page = 1" />
<x-table.sort-header label="Applications" asc="applications-asc" desc="applications-desc" first="desc"
then="page = 1" />
<x-table.sort-header label="Services" asc="services-asc" desc="services-desc" first="desc" then="page = 1"
class="tag-services" />
</div>
<template x-for="tag in paginatedTags" :key="tag.id">
@@ -185,7 +187,7 @@
label: 'Name Z–A'
},
{
value: 'resources',
value: 'resources-desc',
label: 'Most resources'
},
],
@@ -195,17 +197,22 @@
return !query || tag.name.toLowerCase().includes(query);
});
return tags.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'resources') {
return second.resourceCount - first.resourceCount ||
first.name.localeCompare(second.name);
}
const [column, direction] = this.sortBy.split('-');
const sortValues = {
name: (tag) => tag.name,
resources: (tag) => Number(tag.resourceCount),
applications: (tag) => Number(tag.applicationsCount),
services: (tag) => Number(tag.servicesCount),
};
const sortValue = sortValues[column] || sortValues.name;
const order = direction === 'desc' ? -1 : 1;
const compare = (first, second) => typeof first === 'number' ?
first - second :
first.localeCompare(second);
return first.name.localeCompare(second.name);
});
return tags.sort((first, second) =>
order * compare(sortValue(first), sortValue(second)) ||
first.name.localeCompare(second.name));
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredTags.length / this.pageSize));
@@ -411,7 +418,8 @@
@endif
</x-slot:actions>
<div wire:poll="getDeployments" class="overflow-x-auto">
<div wire:poll="getDeployments" class="overflow-x-auto transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setDeploymentSort">
@if (count($deploymentsPerTagPerServer ?? []) === 0)
<div class="p-3">
<x-empty title="No active deployments"
@@ -421,25 +429,26 @@
@else
<div
class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Resource</div>
<div>Server</div>
<div>Status</div>
<x-table.sort-header label="Resource" asc="resource-asc" desc="resource-desc"
action="setDeploymentSort" :current="$deploymentSort" />
<x-table.sort-header label="Server" asc="server-asc" desc="server-desc"
action="setDeploymentSort" :current="$deploymentSort" />
<x-table.sort-header label="Status" asc="status-asc" desc="status-desc"
action="setDeploymentSort" :current="$deploymentSort" />
</div>
@foreach ($deploymentsPerTagPerServer as $serverName => $deployments)
@foreach ($deployments as $deployment)
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-medium text-black dark:text-fg">
{{ data_get($deployment, 'application_name') }}
</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $serverName }}</div>
<div>
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
</div>
</a>
@endforeach
@foreach ($activeDeployments as $deployment)
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}"
class="grid min-h-13 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.55fr)_7rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-medium text-black dark:text-fg">
{{ data_get($deployment, 'application_name') }}
</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ data_get($deployment, 'server_name') }}</div>
<div>
<x-status-badge :status="str(data_get($deployment, 'status'))->headline()"
:type="data_get($deployment, 'status') === 'in_progress' ? 'warning' : 'neutral'" />
</div>
</a>
@endforeach
@endif
</div>
@@ -36,12 +36,16 @@
@if ($events->isNotEmpty())
<div class="overflow-x-auto">
<div class="data-table transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="search,action,source,setPage,previousPage,nextPage">
wire:target="search,action,source,setSort,setPage,previousPage,nextPage">
<div class="grid min-w-[760px] grid-cols-[14rem_minmax(0,1fr)_12rem_9rem] gap-4 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
<span>Actor</span>
<span>Activity</span>
<span>Source</span>
<span class="text-right">Time</span>
<x-table.sort-header label="Actor" asc="actor-asc" desc="actor-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Activity" asc="activity-asc" desc="activity-desc"
action="setSort" :current="$sort" />
<x-table.sort-header label="Source" asc="source-asc" desc="source-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Time" asc="time-asc" desc="time-desc" first="desc"
action="setSort" :current="$sort" class="justify-self-end" />
</div>
@foreach ($events as $event)
<div wire:key="audit-event-{{ $event->id }}" x-data="{ expanded: false }"
@@ -4,16 +4,20 @@
<x-application.settings-section title="Pending invitations"
description="Invitation links that have not been accepted yet." flush>
<div class="overflow-x-auto">
<div class="data-table min-w-[760px]">
<div class="data-table min-w-[760px] transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
wire:target="setSort">
<div
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem]">
<span>Email</span>
<span>Method</span>
<span>Role</span>
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Method" asc="via-asc" desc="via-desc" action="setSort"
:current="$sort" />
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc"
action="setSort" :current="$sort" />
<span>Invitation link</span>
<span class="text-right">Actions</span>
</div>
@foreach ($invitations as $invite)
@foreach ($sortedInvitations as $invite)
<div wire:key="team-invitation-{{ $invite->id }}"
class="data-table-row grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_6rem] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
@@ -27,10 +27,12 @@
</div>
<div class="truncate text-[12px] text-neutral-500 dark:text-fg-dim">{{ $member->email }}</div>
<div>
<span
class="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
<button type="button"
class="table-filter-value table-filter-badge inline-flex rounded-full border border-transparent bg-neutral-100 px-2 py-0.5 text-[10px] font-medium capitalize text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"
title="Filter by {{ data_get($member, 'pivot.role') }}"
x-on:click="roleFilter = roleFilter === @js(data_get($member, 'pivot.role')) ? 'all' : @js(data_get($member, 'pivot.role')); page = 1">
{{ data_get($member, 'pivot.role') }}
</span>
</button>
@can('manageMembers', currentTeam())
@if (data_get($member, 'pivot.role') === 'member')
<div class="mt-1 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
@@ -1,7 +1,7 @@
<div x-data="{
search: '',
roleFilter: 'all',
sortBy: 'name_asc',
sortBy: 'name-asc',
page: 1,
perPage: 10,
members: @js($members->map(fn ($member) => [
@@ -9,6 +9,8 @@
'name' => $member->name,
'email' => $member->email,
'role' => data_get($member, 'pivot.role'),
'roleRank' => \App\Enums\Role::tryFrom((string) data_get($member, 'pivot.role'))?->rank() ?? 0,
'twoFactor' => auth()->user()?->can('manageMembers', currentTeam()) ? filled($member->two_factor_confirmed_at) : null,
])->values()),
get filteredMembers() {
const query = this.search.trim().toLowerCase();
@@ -21,13 +23,17 @@
return matchesSearch && matchesRole;
});
return filtered.sort((left, right) => {
if (this.sortBy === 'name_desc') return right.name.localeCompare(left.name);
if (this.sortBy === 'email_asc') return left.email.localeCompare(right.email);
if (this.sortBy === 'role') return left.role.localeCompare(right.role);
const [column, direction] = this.sortBy.split('-');
const order = direction === 'desc' ? -1 : 1;
const byName = (left, right) => String(left.name || '').localeCompare(String(right.name || ''));
const compare = {
name: byName,
email: (left, right) => String(left.email || '').localeCompare(String(right.email || '')),
role: (left, right) => left.roleRank - right.roleRank,
twofa: (left, right) => Number(left.twoFactor) - Number(right.twoFactor),
}[column] || byName;
return left.name.localeCompare(right.name);
});
return filtered.sort((left, right) => order * compare(left, right) || byName(left, right));
},
get lastPage() {
return Math.max(1, Math.ceil(this.filteredMembers.length / this.perPage));
@@ -85,11 +91,12 @@
</div>
<div class="w-full sm:w-40">
<x-forms.listbox id="member-sort" :wire="false" x-model="sortBy"
x-effect="page = 1" :value="'name_asc'" :options="[
['value' => 'name_asc', 'label' => 'Name A–Z'],
['value' => 'name_desc', 'label' => 'Name Z–A'],
['value' => 'email_asc', 'label' => 'Email A–Z'],
['value' => 'role', 'label' => 'Role'],
x-effect="page = 1" :value="'name-asc'" :options="[
['value' => 'name-asc', 'label' => 'Name A–Z'],
['value' => 'name-desc', 'label' => 'Name Z–A'],
['value' => 'email-asc', 'label' => 'Email A–Z'],
['value' => 'email-desc', 'label' => 'Email Z–A'],
['value' => 'role-desc', 'label' => 'Role'],
]" />
</div>
</div>
@@ -111,11 +118,11 @@
'data-table-header team-members-table-grid',
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
])>
<span>Name</span>
<span>Email</span>
<span>Role</span>
<x-table.sort-header label="Name" asc="name-asc" desc="name-desc" then="page = 1" />
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" then="page = 1" />
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc" then="page = 1" />
@can('manageMembers', currentTeam())
<span>2FA</span>
<x-table.sort-header label="2FA" asc="twofa-asc" desc="twofa-desc" then="page = 1" />
@endcan
<span class="text-right">Actions</span>
</div>
+38
View File
@@ -205,7 +205,45 @@ set_env_var() {
fi
}
# Quote a value so that Docker Compose and phpdotenv both read it back unchanged (same as install.sh).
quote_env_value() {
local value="$1"
case "$value" in
*\'*)
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//\$/\\\$}
printf '"%s"' "$value"
;;
*)
printf "'%s'" "$value"
;;
esac
}
# Installers before 4.5.1 (and manual edits) wrote root user credentials with spaces without quotes.
# phpdotenv cannot parse such a line, so the recreated Coolify container returns HTTP 500 for every page.
# Only quote lines that phpdotenv rejects: unquoted values that contain whitespace but no comment.
quote_root_user_env_values() {
local key value
for key in ROOT_USERNAME ROOT_USER_EMAIL ROOT_USER_PASSWORD; do
value=$(grep -m1 "^${key}=" "$ENV_FILE" | cut -d '=' -f2-) || continue
# phpdotenv ignores whitespace around an unquoted value (also the \r of CRLF line endings)
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
case "$value" in
\"* | \'* | *[[:space:]]\#*) continue ;;
*[[:space:]]*) ;;
*) continue ;;
esac
# awk reads the value from the environment, so characters such as | & \ in it stay literal
KEY="$key" VALUE="$(quote_env_value "$value")" awk '!done && index($0, ENVIRON["KEY"] "=") == 1 { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"]; done = 1 } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
log "Quoted ${key} (its value has spaces)"
done
}
log "Checking environment variables..."
quote_root_user_env_values
set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
+89
View File
@@ -0,0 +1,89 @@
# documentation: https://hi.events/docs
# slogan: Open-source event management and ticketing platform.
# category: productivity
# tags: events, ticketing, tickets, booking, conferences, concerts, event-management, eventbrite
# logo: svgs/hi-events.svg
# port: 80
services:
hi-events:
# Use an HTTPS domain: Hi.Events always sets its login cookie as Secure, so sign-in fails over plain HTTP.
image: daveearley/hi.events-all-in-one:v2.0.0
environment:
- SERVICE_URL_HIEVENTS_80
- VITE_APP_NAME=${VITE_APP_NAME:-Hi.Events}
- VITE_FRONTEND_URL=${SERVICE_URL_HIEVENTS}
- VITE_API_URL_CLIENT=${SERVICE_URL_HIEVENTS}/api
- VITE_API_URL_SERVER=http://localhost:80/api
- VITE_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLIC_KEY}
- APP_KEY=base64:${SERVICE_REALBASE64_APPKEY}
- JWT_SECRET=${SERVICE_REALBASE64_JWTSECRET}
- APP_FRONTEND_URL=${SERVICE_URL_HIEVENTS}
- APP_CDN_URL=${SERVICE_URL_HIEVENTS}/storage
- APP_DISABLE_REGISTRATION=${APP_DISABLE_REGISTRATION:-false}
- APP_SAAS_MODE_ENABLED=${APP_SAAS_MODE_ENABLED:-false}
- APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=${APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT:-0}
- APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=${APP_SAAS_STRIPE_APPLICATION_FEE_FIXED:-0}
- APP_EMAIL_LOGO_URL=${APP_EMAIL_LOGO_URL}
- APP_EMAIL_LOGO_LINK_URL=${APP_EMAIL_LOGO_LINK_URL}
- LOG_CHANNEL=stderr
- QUEUE_CONNECTION=redis
- DB_CONNECTION=pgsql
- DATABASE_URL=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-hi-events}
- REDIS_HOST=redis
- REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}
- REDIS_PORT=6379
- MAIL_MAILER=${MAIL_MAILER:-log}
- MAIL_HOST=${MAIL_HOST}
- MAIL_PORT=${MAIL_PORT:-587}
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_ENCRYPTION=${MAIL_ENCRYPTION:-tls}
- MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS:-noreply@example.com}
- MAIL_FROM_NAME=${MAIL_FROM_NAME:-Hi.Events}
- FILESYSTEM_PUBLIC_DISK=public
- FILESYSTEM_PRIVATE_DISK=local
- STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- WEBHOOK_QUEUE_NAME=webhook-queue
volumes:
- hi-events-storage:/app/backend/storage/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:80/"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB:-hi-events}
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
volumes:
- hi-events-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 20s
retries: 10
redis:
image: redis:7-alpine
command: redis-server --requirepass ${SERVICE_PASSWORD_REDIS}
environment:
- REDIS_PASSWORD=${SERVICE_PASSWORD_REDIS}
volumes:
- hi-events-redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"]
interval: 5s
timeout: 10s
retries: 10
+20
View File
@@ -2440,6 +2440,26 @@
"template_last_updated_at": "2026-03-03T17:57:00+08:00",
"port": "9157"
},
"hi-events": {
"documentation": "https://hi.events/docs?utm_source=coolify.io",
"slogan": "Open-source event management and ticketing platform.",
"compose": "c2VydmljZXM6CiAgaGktZXZlbnRzOgogICAgaW1hZ2U6ICdkYXZlZWFybGV5L2hpLmV2ZW50cy1hbGwtaW4tb25lOnYyLjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0hJRVZFTlRTXzgwCiAgICAgIC0gJ1ZJVEVfQVBQX05BTUU9JHtWSVRFX0FQUF9OQU1FOi1IaS5FdmVudHN9JwogICAgICAtICdWSVRFX0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfVVJMX0hJRVZFTlRTfScKICAgICAgLSAnVklURV9BUElfVVJMX0NMSUVOVD0ke1NFUlZJQ0VfVVJMX0hJRVZFTlRTfS9hcGknCiAgICAgIC0gJ1ZJVEVfQVBJX1VSTF9TRVJWRVI9aHR0cDovL2xvY2FsaG9zdDo4MC9hcGknCiAgICAgIC0gJ1ZJVEVfU1RSSVBFX1BVQkxJU0hBQkxFX0tFWT0ke1NUUklQRV9QVUJMSUNfS0VZfScKICAgICAgLSAnQVBQX0tFWT1iYXNlNjQ6JHtTRVJWSUNFX1JFQUxCQVNFNjRfQVBQS0VZfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1RTRUNSRVR9JwogICAgICAtICdBUFBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfSElFVkVOVFN9JwogICAgICAtICdBUFBfQ0ROX1VSTD0ke1NFUlZJQ0VfVVJMX0hJRVZFTlRTfS9zdG9yYWdlJwogICAgICAtICdBUFBfRElTQUJMRV9SRUdJU1RSQVRJT049JHtBUFBfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnQVBQX1NBQVNfTU9ERV9FTkFCTEVEPSR7QVBQX1NBQVNfTU9ERV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ0FQUF9TQUFTX1NUUklQRV9BUFBMSUNBVElPTl9GRUVfUEVSQ0VOVD0ke0FQUF9TQUFTX1NUUklQRV9BUFBMSUNBVElPTl9GRUVfUEVSQ0VOVDotMH0nCiAgICAgIC0gJ0FQUF9TQUFTX1NUUklQRV9BUFBMSUNBVElPTl9GRUVfRklYRUQ9JHtBUFBfU0FBU19TVFJJUEVfQVBQTElDQVRJT05fRkVFX0ZJWEVEOi0wfScKICAgICAgLSAnQVBQX0VNQUlMX0xPR09fVVJMPSR7QVBQX0VNQUlMX0xPR09fVVJMfScKICAgICAgLSAnQVBQX0VNQUlMX0xPR09fTElOS19VUkw9JHtBUFBfRU1BSUxfTE9HT19MSU5LX1VSTH0nCiAgICAgIC0gTE9HX0NIQU5ORUw9c3RkZXJyCiAgICAgIC0gUVVFVUVfQ09OTkVDVElPTj1yZWRpcwogICAgICAtIERCX0NPTk5FQ1RJT049cGdzcWwKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LWhpLWV2ZW50c30nCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtICdNQUlMX01BSUxFUj0ke01BSUxfTUFJTEVSOi1sb2d9JwogICAgICAtICdNQUlMX0hPU1Q9JHtNQUlMX0hPU1R9JwogICAgICAtICdNQUlMX1BPUlQ9JHtNQUlMX1BPUlQ6LTU4N30nCiAgICAgIC0gJ01BSUxfVVNFUk5BTUU9JHtNQUlMX1VTRVJOQU1FfScKICAgICAgLSAnTUFJTF9QQVNTV09SRD0ke01BSUxfUEFTU1dPUkR9JwogICAgICAtICdNQUlMX0VOQ1JZUFRJT049JHtNQUlMX0VOQ1JZUFRJT046LXRsc30nCiAgICAgIC0gJ01BSUxfRlJPTV9BRERSRVNTPSR7TUFJTF9GUk9NX0FERFJFU1M6LW5vcmVwbHlAZXhhbXBsZS5jb219JwogICAgICAtICdNQUlMX0ZST01fTkFNRT0ke01BSUxfRlJPTV9OQU1FOi1IaS5FdmVudHN9JwogICAgICAtIEZJTEVTWVNURU1fUFVCTElDX0RJU0s9cHVibGljCiAgICAgIC0gRklMRVNZU1RFTV9QUklWQVRFX0RJU0s9bG9jYWwKICAgICAgLSAnU1RSSVBFX1BVQkxJQ19LRVk9JHtTVFJJUEVfUFVCTElDX0tFWX0nCiAgICAgIC0gJ1NUUklQRV9TRUNSRVRfS0VZPSR7U1RSSVBFX1NFQ1JFVF9LRVl9JwogICAgICAtICdTVFJJUEVfV0VCSE9PS19TRUNSRVQ9JHtTVFJJUEVfV0VCSE9PS19TRUNSRVR9JwogICAgICAtIFdFQkhPT0tfUVVFVUVfTkFNRT13ZWJob29rLXF1ZXVlCiAgICB2b2x1bWVzOgogICAgICAtICdoaS1ldmVudHMtc3RvcmFnZTovYXBwL2JhY2tlbmQvc3RvcmFnZS9hcHAnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZnNTJwogICAgICAgIC0gJy1vJwogICAgICAgIC0gL2Rldi9udWxsCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1oaS1ldmVudHN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hpLWV2ZW50cy1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGktZXZlbnRzLXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3JlZGlzLWNsaSAtYSAiJCR7UkVESVNfUEFTU1dPUkR9IiBwaW5nIHwgZ3JlcCAtcSBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"events",
"ticketing",
"tickets",
"booking",
"conferences",
"concerts",
"event-management",
"eventbrite"
],
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"port": "80"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
"slogan": "Homarr is a self-hosted homepage for your services.",
+20
View File
@@ -2440,6 +2440,26 @@
"template_last_updated_at": "2026-03-03T17:57:00+08:00",
"port": "9157"
},
"hi-events": {
"documentation": "https://hi.events/docs?utm_source=coolify.io",
"slogan": "Open-source event management and ticketing platform.",
"compose": "c2VydmljZXM6CiAgaGktZXZlbnRzOgogICAgaW1hZ2U6ICdkYXZlZWFybGV5L2hpLmV2ZW50cy1hbGwtaW4tb25lOnYyLjAuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ISUVWRU5UU184MAogICAgICAtICdWSVRFX0FQUF9OQU1FPSR7VklURV9BUFBfTkFNRTotSGkuRXZlbnRzfScKICAgICAgLSAnVklURV9GUk9OVEVORF9VUkw9JHtTRVJWSUNFX0ZRRE5fSElFVkVOVFN9JwogICAgICAtICdWSVRFX0FQSV9VUkxfQ0xJRU5UPSR7U0VSVklDRV9GUUROX0hJRVZFTlRTfS9hcGknCiAgICAgIC0gJ1ZJVEVfQVBJX1VSTF9TRVJWRVI9aHR0cDovL2xvY2FsaG9zdDo4MC9hcGknCiAgICAgIC0gJ1ZJVEVfU1RSSVBFX1BVQkxJU0hBQkxFX0tFWT0ke1NUUklQRV9QVUJMSUNfS0VZfScKICAgICAgLSAnQVBQX0tFWT1iYXNlNjQ6JHtTRVJWSUNFX1JFQUxCQVNFNjRfQVBQS0VZfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1RTRUNSRVR9JwogICAgICAtICdBUFBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9GUUROX0hJRVZFTlRTfScKICAgICAgLSAnQVBQX0NETl9VUkw9JHtTRVJWSUNFX0ZRRE5fSElFVkVOVFN9L3N0b3JhZ2UnCiAgICAgIC0gJ0FQUF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0FQUF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdBUFBfU0FBU19NT0RFX0VOQUJMRUQ9JHtBUFBfU0FBU19NT0RFX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnQVBQX1NBQVNfU1RSSVBFX0FQUExJQ0FUSU9OX0ZFRV9QRVJDRU5UPSR7QVBQX1NBQVNfU1RSSVBFX0FQUExJQ0FUSU9OX0ZFRV9QRVJDRU5UOi0wfScKICAgICAgLSAnQVBQX1NBQVNfU1RSSVBFX0FQUExJQ0FUSU9OX0ZFRV9GSVhFRD0ke0FQUF9TQUFTX1NUUklQRV9BUFBMSUNBVElPTl9GRUVfRklYRUQ6LTB9JwogICAgICAtICdBUFBfRU1BSUxfTE9HT19VUkw9JHtBUFBfRU1BSUxfTE9HT19VUkx9JwogICAgICAtICdBUFBfRU1BSUxfTE9HT19MSU5LX1VSTD0ke0FQUF9FTUFJTF9MT0dPX0xJTktfVVJMfScKICAgICAgLSBMT0dfQ0hBTk5FTD1zdGRlcnIKICAgICAgLSBRVUVVRV9DT05ORUNUSU9OPXJlZGlzCiAgICAgIC0gREJfQ09OTkVDVElPTj1wZ3NxbAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotaGktZXZlbnRzfScKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ01BSUxfTUFJTEVSPSR7TUFJTF9NQUlMRVI6LWxvZ30nCiAgICAgIC0gJ01BSUxfSE9TVD0ke01BSUxfSE9TVH0nCiAgICAgIC0gJ01BSUxfUE9SVD0ke01BSUxfUE9SVDotNTg3fScKICAgICAgLSAnTUFJTF9VU0VSTkFNRT0ke01BSUxfVVNFUk5BTUV9JwogICAgICAtICdNQUlMX1BBU1NXT1JEPSR7TUFJTF9QQVNTV09SRH0nCiAgICAgIC0gJ01BSUxfRU5DUllQVElPTj0ke01BSUxfRU5DUllQVElPTjotdGxzfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTUzotbm9yZXBseUBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUU6LUhpLkV2ZW50c30nCiAgICAgIC0gRklMRVNZU1RFTV9QVUJMSUNfRElTSz1wdWJsaWMKICAgICAgLSBGSUxFU1lTVEVNX1BSSVZBVEVfRElTSz1sb2NhbAogICAgICAtICdTVFJJUEVfUFVCTElDX0tFWT0ke1NUUklQRV9QVUJMSUNfS0VZfScKICAgICAgLSAnU1RSSVBFX1NFQ1JFVF9LRVk9JHtTVFJJUEVfU0VDUkVUX0tFWX0nCiAgICAgIC0gJ1NUUklQRV9XRUJIT09LX1NFQ1JFVD0ke1NUUklQRV9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gV0VCSE9PS19RVUVVRV9OQU1FPXdlYmhvb2stcXVldWUKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hpLWV2ZW50cy1zdG9yYWdlOi9hcHAvYmFja2VuZC9zdG9yYWdlL2FwcCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mc1MnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTctYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWhpLWV2ZW50c30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGktZXZlbnRzLXBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdoaS1ldmVudHMtcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICIkJHtSRURJU19QQVNTV09SRH0iIHBpbmcgfCBncmVwIC1xIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"events",
"ticketing",
"tickets",
"booking",
"conferences",
"concerts",
"event-management",
"eventbrite"
],
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"port": "80"
},
"homarr": {
"documentation": "https://homarr.dev?utm_source=coolify.io",
"slogan": "Homarr is a self-hosted homepage for your services.",
@@ -0,0 +1,104 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
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 Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'coolify-test']);
});
function watchPathsTestApplication(array $attributes): Application
{
return Application::factory()->create([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => StandaloneDocker::class,
'git_repository' => 'git@github.com:acme/monorepo.git',
...$attributes,
]);
}
function watchPathsTestGithubApp(bool $isPublic): GithubApp
{
return GithubApp::create([
'name' => $isPublic ? 'public-github' : 'private-github-app',
'team_id' => test()->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => $isPublic,
]);
}
/**
* Regression for #12373: deploy-key applications have no source, but manual Git
* webhooks still filter their deployments by watch paths.
*/
it('shows the watch paths field for deploy key applications', function (string $buildPack) {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$application = watchPathsTestApplication([
'build_pack' => $buildPack,
'private_key_id' => $privateKey->id,
]);
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSee('Watch paths');
})->with(['dockercompose', 'dockerfile', 'nixpacks']);
it('shows the watch paths field for private GitHub App applications', function () {
$githubApp = watchPathsTestGithubApp(isPublic: false);
$application = watchPathsTestApplication([
'source_id' => $githubApp->id,
'source_type' => GithubApp::class,
]);
Livewire::test(General::class, ['application' => $application->fresh()])
->assertSee('Watch paths');
});
it('hides the watch paths field for public repository applications', function () {
$githubApp = watchPathsTestGithubApp(isPublic: true);
$application = watchPathsTestApplication([
'source_id' => $githubApp->id,
'source_type' => GithubApp::class,
]);
Livewire::test(General::class, ['application' => $application->fresh()])
->assertDontSee('Watch paths');
});
it('hides the watch paths field for applications without a Git repository', function () {
$application = watchPathsTestApplication([
'build_pack' => 'dockerfile',
'dockerfile' => "FROM nginx:alpine\n",
]);
Livewire::test(General::class, ['application' => $application->fresh()])
->assertDontSee('Watch paths');
});
+39
View File
@@ -872,6 +872,45 @@ test('audit log page only shows events for the current team', function () {
->assertDontSee('Private app deleted');
});
test('audit log sorts by the chosen column and ignores unknown sort values', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Zoe',
'description' => 'Older event by Zoe',
'created_at' => now()->subHour(),
]);
AuditEvent::factory()->create([
'team_id' => $this->team->id,
'actor_name' => 'Adam',
'description' => 'Newer event by Adam',
'created_at' => now(),
]);
Livewire::test(AuditLog::class)
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
->call('setSort', 'time-asc')
->assertSet('sort', 'time-asc')
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
->call('setSort', 'actor-desc')
->assertSeeInOrder(['Older event by Zoe', 'Newer event by Adam'])
->call('setSort', 'activity-asc')
->assertSeeInOrder(['Newer event by Adam', 'Older event by Zoe'])
->call('setSort', 'resource_uuid-asc')
->assertSet('sort', 'activity-asc')
->call('setSort', 'time-sideways')
->assertSet('sort', 'activity-asc');
});
test('changing the audit log sort returns to the first page', function () {
AuditEvent::factory()->count(11)->create(['team_id' => $this->team->id]);
Livewire::test(AuditLog::class)
->set('perPage', 10)
->call('setPage', 2)
->call('setSort', 'time-asc')
->assertSet('paginators.page', 1);
});
test('audit log expands encrypted model changes for team admins and owners', function () {
AuditEvent::factory()->create([
'team_id' => $this->team->id,
@@ -0,0 +1,34 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
});
it('clears the forced password reset of an invited user who resets the password', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',
'email_verified_at' => null,
'force_password_reset' => true,
]);
$this->post(route('password.update'), [
'token' => Password::createToken($user),
'email' => $user->email,
'password' => 'Own-Passw0rd!',
'password_confirmation' => 'Own-Passw0rd!',
])->assertSessionHasNoErrors();
$user->refresh();
expect(Hash::check('Own-Passw0rd!', $user->password))->toBeTrue()
->and($user->force_password_reset)->toBeFalse()
->and($user->isUnusedInvitedAccount())->toBeFalse();
});
+125 -3
View File
@@ -178,7 +178,16 @@ it('only includes deployments of applications owned by the current team', functi
});
it('does not load deployment payload columns when the dashboard list polls', function () {
createDashboardDeployment(['logs' => '[{"output":"large deployment log"}]']);
createDashboardDeployment([
'logs' => json_encode([['output' => str_repeat('large deployment log ', 20000)]]),
'configuration_snapshot' => ['docker_compose_raw' => str_repeat('x', 100000)],
'configuration_diff' => ['fqdn' => ['old' => null, 'new' => 'https://example.com']],
]);
createDashboardDeployment([
'application_name' => 'Dashboard App',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'logs' => json_encode([['output' => str_repeat('large deployment log ', 20000)]]),
]);
$deploymentQueries = [];
DB::listen(function ($query) use (&$deploymentQueries) {
@@ -190,10 +199,123 @@ it('does not load deployment payload columns when the dashboard list polls', fun
Livewire::test(ActiveDeployments::class)
->assertSee('Dashboard App')
->call('refreshDeployments')
->assertSee('Dashboard App');
->assertSee('Dashboard App')
->call('$refresh')
->assertSee('Running or queued right now')
->assertSee('Recent deployments');
expect($deploymentQueries)->not->toBeEmpty();
foreach ($deploymentQueries as $sql) {
expect($sql)->not->toMatch('/select\s+("?\w+"?\.)?\*/i')->not->toContain('logs');
expect($sql)->not->toMatch('/select\s+("?\w+"?\.)?\*/i')
->not->toContain('logs')
->not->toContain('configuration_snapshot')
->not->toContain('configuration_diff');
}
});
function createDashboardApplication(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
], $overrides));
}
it('lists the five newest eligible deployments of the team across its applications', function () {
$secondApplication = createDashboardApplication();
$thirdApplication = createDashboardApplication();
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherApplication = createDashboardApplication([
'environment_id' => Environment::factory()->create(['project_id' => $otherProject->id])->id,
]);
$expected = [];
foreach (range(1, 4) as $round) {
foreach ([$this->application, $secondApplication, $thirdApplication] as $application) {
$expected[] = createDashboardDeployment([
'application_id' => $application->id,
'status' => $round % 2 === 0
? ApplicationDeploymentStatus::FAILED->value
: ApplicationDeploymentStatus::FINISHED->value,
])->id;
}
}
createDashboardDeployment(['application_id' => $otherApplication->id]);
createDashboardDeployment(['application_id' => $secondApplication->id, 'status' => ApplicationDeploymentStatus::QUEUED->value]);
createDashboardDeployment(['application_id' => $thirdApplication->id, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('recentDeployments')->pluck('id')->all())
->toBe(array_slice(array_reverse($expected), 0, 5));
});
it('lists fewer than five recent deployments when fewer are eligible', function () {
$finished = createDashboardDeployment()->id;
$failed = createDashboardDeployment(['status' => ApplicationDeploymentStatus::FAILED->value])->id;
createDashboardDeployment(['status' => ApplicationDeploymentStatus::QUEUED->value]);
expect(Livewire::test(ActiveDeployments::class)->get('recentDeployments')->pluck('id')->all())
->toBe([$failed, $finished]);
});
it('keeps deployments of deleted applications and skips deployments without an application', function () {
$deletedApplication = createDashboardApplication();
$deployment = createDashboardDeployment([
'application_id' => $deletedApplication->id,
'application_name' => 'Deleted App Deployment',
]);
$deletedApplication->delete();
createDashboardDeployment([
'application_id' => '999999',
'application_name' => 'Orphan Deployment',
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id])
->and($component->get('recentDeployments')->first()->application)->toBeNull();
$component->assertSee('Deleted App Deployment')->assertDontSee('Orphan Deployment');
});
it('moves a deployment from the active to the recent list when its status changes', function () {
$deployment = createDashboardDeployment([
'application_name' => 'Status Change App',
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$component = Livewire::test(ActiveDeployments::class);
expect($component->get('activeDeployments')->pluck('id')->all())->toBe([$deployment->id])
->and($component->get('recentDeployments'))->toBeEmpty();
$deployment->setStatus(ApplicationDeploymentStatus::FINISHED->value);
$component->call('refreshDeployments');
expect($component->get('activeDeployments'))->toBeEmpty()
->and($component->get('recentDeployments')->pluck('id')->all())->toBe([$deployment->id]);
$component->assertDontSee('Running or queued right now')->assertSee('Recent deployments');
});
it('looks up the team applications once per request without loading full applications', function () {
createDashboardDeployment();
createDashboardDeployment(['status' => ApplicationDeploymentStatus::IN_PROGRESS->value]);
$applicationQueries = [];
DB::listen(function ($query) use (&$applicationQueries) {
if (str_contains($query->sql, 'from "applications"')) {
$applicationQueries[] = $query->sql;
}
});
Livewire::test(ActiveDeployments::class)->assertSee('Dashboard App');
$teamLookups = array_filter($applicationQueries, fn ($sql) => str_contains($sql, 'exists'));
expect($teamLookups)->toHaveCount(1);
foreach ($applicationQueries as $sql) {
expect($sql)->not->toMatch('/^select\s+("?\w+"?\.)?\*/i')
->not->toContain('additional_servers_count')
->not->toContain('additional_networks_count');
}
});
+70
View File
@@ -0,0 +1,70 @@
<?php
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('sorts all projects before it limits the dashboard list', function () {
foreach (range(1, Dashboard::ITEM_LIMIT + 1) as $number) {
Project::factory()->create([
'team_id' => $this->team->id,
'name' => sprintf('sort-project-%02d', $number),
'description' => 'description '.(10 - $number),
]);
}
Livewire::test(Dashboard::class)
->assertDontSee('sort-project-09')
->call('setProjectSort', 'name-desc')
->assertSet('projectSort', 'name-desc')
->assertSeeInOrder(['sort-project-09', 'sort-project-08', 'sort-project-02'])
->assertDontSee('sort-project-01')
->call('setProjectSort', 'description-asc')
->assertSeeInOrder(['sort-project-09', 'sort-project-08'])
->call('setProjectSort', 'environments-desc')
->assertSet('projectSort', 'description-asc');
});
it('sorts servers by name and status', function () {
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$servers = collect(['alpha-host', 'bravo-host', 'charlie-host'])->map(fn (string $name) => Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
'name' => $name,
]));
$servers[1]->settings->update(['is_reachable' => true, 'is_usable' => true]);
$servers[2]->settings->update(['force_disabled' => true]);
Livewire::test(Dashboard::class)
->assertSeeInOrder(['alpha-host', 'bravo-host', 'charlie-host'])
->call('setServerSort', 'name-desc')
->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host'])
->call('setServerSort', 'status-asc')
->assertSeeHtml('aria-label="Sort by status, sorted ascending"')
// Disabled, Ready, Unavailable
->assertSeeInOrder(['charlie-host', 'bravo-host', 'alpha-host'])
->call('setServerSort', 'status')
->assertSet('serverSort', 'status-asc');
});
+158 -23
View File
@@ -2,6 +2,8 @@
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\Deployment\Index;
use App\Livewire\Project\Application\Deployment\Show;
use App\Livewire\Project\Application\Heading;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
@@ -37,7 +39,16 @@ beforeEach(function () {
'destination_type' => $destination->getMorphClass(),
]);
$this->createDeployment = fn (string $uuid, string $createdAt) => ApplicationDeploymentQueue::query()->forceCreate([
$this->largeLog = json_encode([[
'command' => null,
'output' => str_repeat('large deployment log ', 20000),
'type' => 'stdout',
'timestamp' => '2026-10-01T10:00:00.000000Z',
'hidden' => false,
'batch' => 1,
'order' => 1,
]]);
$this->createDeployment = fn (string $uuid, string $createdAt, array $overrides = []) => ApplicationDeploymentQueue::query()->forceCreate([
'application_id' => $this->application->id,
'deployment_uuid' => $uuid,
'server_id' => $server->id,
@@ -45,23 +56,67 @@ beforeEach(function () {
'status' => ApplicationDeploymentStatus::FINISHED->value,
'commit' => 'abcdef1234567890',
'commit_message' => "Message of {$uuid}",
'logs' => '[{"output":"large deployment log"}]',
'configuration_snapshot' => ['fqdn' => 'https://example.com'],
'logs' => $this->largeLog,
'configuration_snapshot' => ['fqdn' => 'https://example.com', 'docker_compose_raw' => str_repeat('x', 100000)],
'configuration_diff' => ['fqdn' => ['old' => null, 'new' => 'https://example.com']],
'created_at' => $createdAt,
'updated_at' => $createdAt,
...$overrides,
]);
});
it('does not load deployment payload columns for the history list on any Livewire request', function () {
($this->createDeployment)('deployment-history-row', '2026-10-01 10:00:00');
$historyQueries = [];
DB::listen(function ($query) use (&$historyQueries) {
$this->deploymentQueries = collect();
$this->recordDeploymentQueries = fn () => DB::listen(function ($query) {
if (str_contains($query->sql, 'application_deployment_queues')) {
$historyQueries[] = $query->sql;
$this->deploymentQueries->push($query->sql);
}
});
$this->assertNoPayloadQueries = function () {
expect($this->deploymentQueries)->not->toBeEmpty();
$this->deploymentQueries->each(fn (string $sql) => expect($sql)
->not->toMatch('/select\s+("?\w+"?\.)?\*/i')
->not->toContain('logs')
->not->toContain('configuration_snapshot')
->not->toContain('configuration_diff'));
};
});
function otherTeamDeployment(): array
{
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$deployment = ApplicationDeploymentQueue::query()->forceCreate([
'application_id' => $application->id,
'deployment_uuid' => 'other-team-deployment',
'server_id' => $server->id,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'logs' => '[{"output":"other team log"}]',
]);
return [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $application->uuid,
'deployment_uuid' => $deployment->deployment_uuid,
];
}
it('does not load deployment payload columns for the history list on any Livewire request', function () {
foreach (range(1, 4) as $number) {
($this->createDeployment)("history-d{$number}", "2026-10-0{$number} 10:00:00");
}
($this->createDeployment)('history-d5', '2026-10-05 10:00:00', [
'status' => ApplicationDeploymentStatus::FAILED->value,
'pull_request_id' => 7,
]);
($this->recordDeploymentQueries)();
Livewire::test(Index::class, [
'project_uuid' => $this->project->uuid,
@@ -69,21 +124,101 @@ it('does not load deployment payload columns for the history list on any Livewir
'application_uuid' => $this->application->uuid,
'embedded' => true,
])
->assertSee('deployment-history-row')
->assertSee('Message of deployment-history-row')
->assertSeeInOrder(['history-d5', 'history-d4', 'history-d3'])
->assertSee('Message of history-d5')
->assertDontSee('history-d2')
->call('reloadDeployments')
->assertSee('deployment-history-row')
->assertSee('history-d5')
->call('nextPage')
->assertSeeInOrder(['history-d2', 'history-d1'])
->assertDontSee('history-d5')
->call('previousPage')
->assertSee('history-d5')
->assertDontSee('history-d2')
->call('goToPage', 2)
->assertSee('history-d1')
->call('toggleDeploymentFilter', 'status:failed')
->assertSee('history-d5')
->assertDontSee('history-d4')
->call('toggleDeploymentFilter', 'status:failed')
->call('setPullRequestFilter', '7')
->assertSee('history-d5')
->assertDontSee('history-d4')
->call('clearFilter')
->set('search', 'history-d3')
->assertSee('history-d3')
->assertDontSee('history-d4')
->set('search', '')
->call('setDeploymentSort', 'oldest')
->assertSee('deployment-history-row');
->assertSeeInOrder(['history-d1', 'history-d2', 'history-d3'])
->assertDontSee('history-d5')
->set('defaultTake', 10)
->assertSee('history-d5')
->assertSee('history-d1')
->call('$refresh')
->assertSee('history-d5')
->dispatch('deploymentSelected', deploymentUuid: 'history-d2')
->assertSet('selectedDeploymentUuid', 'history-d2')
->assertSee('history-d2');
expect($historyQueries)->not->toBeEmpty();
foreach ($historyQueries as $sql) {
expect($sql)
->not->toMatch('/select\s+("?\w+"?\.)?\*/i')
->not->toContain('logs')
->not->toContain('configuration_snapshot')
->not->toContain('configuration_diff');
($this->assertNoPayloadQueries)();
});
it('flips the deployment sort from the started column header', function () {
foreach (range(1, 3) as $number) {
($this->createDeployment)("header-d{$number}", "2026-10-0{$number} 10:00:00");
}
Livewire::test(Index::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
])
->assertSeeHtml('aria-label="Sort by started, sorted descending"')
->assertSeeInOrder(['header-d3', 'header-d2', 'header-d1'])
->call('setDeploymentSort', 'oldest')
->assertSeeHtml('aria-label="Sort by started, sorted ascending"')
->assertSeeInOrder(['header-d1', 'header-d2', 'header-d3']);
});
it('does not show the deployment history of another team', function () {
$otherTeam = otherTeamDeployment();
unset($otherTeam['deployment_uuid']);
Livewire::test(Index::class, $otherTeam)
->assertRedirect(route('dashboard'))
->assertDontSee('other-team-deployment');
});
it('loads the full logs and configuration when an authorized user opens a deployment', function () {
($this->createDeployment)('full-detail', '2026-10-01 10:00:00');
$component = Livewire::test(Show::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
'deployment_uuid' => 'full-detail',
])->call('polling');
$deployment = $component->get('application_deployment_queue');
expect($deployment->logs)->toBe($this->largeLog)
->and($deployment->configuration_snapshot['docker_compose_raw'])->toBe(str_repeat('x', 100000));
});
it('does not open a deployment of another team', function () {
Livewire::test(Show::class, otherTeamDeployment())
->assertRedirect(route('dashboard'))
->assertDontSee('other team log');
});
it('loads only the commit of the last successful deployment for the application heading', function () {
($this->createDeployment)('heading-deployment', '2026-10-01 10:00:00', ['commit_message' => 'Heading commit']);
($this->recordDeploymentQueries)();
Livewire::test(Heading::class, ['application' => $this->application])
->assertSet('lastDeploymentInfo', 'abcdef1... Heading commit');
($this->assertNoPayloadQueries)();
});
it('orders and pages deployments with equal timestamps by id in both directions', function () {
@@ -106,6 +241,6 @@ it('keeps the full deployment payload for callers that do not restrict the colum
$full = $this->application->deployments()['deployments']->sole();
expect($listed->getAttributes())->not->toHaveKeys(['logs', 'configuration_snapshot', 'configuration_diff'])
->and($full->logs)->toBe('[{"output":"large deployment log"}]')
->and($full->configuration_snapshot)->toBe(['fqdn' => 'https://example.com']);
->and($full->logs)->toBe($this->largeLog)
->and($full->configuration_snapshot['fqdn'])->toBe('https://example.com');
});
@@ -12,6 +12,7 @@ use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -113,3 +114,30 @@ it('keeps the indicator on an application page when another application is deplo
->assertSee('2 deployments', false)
->assertSee('aria-label="Active deployments"', false);
});
it('does not load deployment payload columns when the indicator refreshes', function () {
ApplicationDeploymentQueue::query()->update([
'logs' => json_encode([['output' => str_repeat('large deployment log ', 20000)]]),
'configuration_snapshot' => json_encode(['docker_compose_raw' => str_repeat('x', 100000)]),
]);
$deploymentQueries = [];
DB::listen(function ($query) use (&$deploymentQueries) {
if (str_contains($query->sql, 'application_deployment_queues')) {
$deploymentQueries[] = $query->sql;
}
});
Livewire::test(DeploymentsIndicator::class)
->assertSee('Indicator App')
->call('$refresh')
->assertSee('Indicator App');
expect($deploymentQueries)->not->toBeEmpty();
foreach ($deploymentQueries as $sql) {
expect($sql)->not->toMatch('/select\s+("?\w+"?\.)?\*/i')
->not->toContain('logs')
->not->toContain('configuration_snapshot')
->not->toContain('configuration_diff');
}
});
+90
View File
@@ -883,3 +883,93 @@ it('fails an interrupted cleanup of a deleted server without breaking its broadc
Queue::assertPushed(BroadcastEvent::class, fn (BroadcastEvent $job) => $job->event instanceof DockerCleanupDone
&& $job->event->broadcastOn() === []);
});
/**
* Creates an execution at an exact age, so tests can place it on either side of the stale cutoff.
*
* @param array<string, mixed> $attributes
*/
function dockerCleanupExecutionCreatedSecondsAgo(Server $server, int $secondsAgo, array $attributes = []): DockerCleanupExecution
{
$execution = DockerCleanupExecution::create(['server_id' => $server->id, ...$attributes]);
$execution->forceFill(['created_at' => now()->subSeconds($secondsAgo)])->saveQuietly();
return $execution;
}
it('fails only running, unfinished cleanups that are older than the server lock and sends one event and notification each', function () {
Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC'));
Event::fake([DockerCleanupDone::class]);
Notification::fake();
$server = dockerCleanupReachableServer();
$server->team->emailNotificationSettings->update(['smtp_enabled' => true, 'docker_cleanup_failure_email_notifications' => true]);
$cutoff = CleanupDocker::overlapLockExpiresAfter();
$stale = dockerCleanupExecutionCreatedSecondsAgo($server, $cutoff + 1, ['message' => 'still running', 'cleanup_log' => '["kept"]']);
$atCutoff = dockerCleanupExecutionCreatedSecondsAgo($server, $cutoff);
$recent = dockerCleanupExecutionCreatedSecondsAgo($server, 60);
$finished = dockerCleanupExecutionCreatedSecondsAgo($server, $cutoff + 3600, ['finished_at' => now()->subHour()]);
$succeeded = dockerCleanupExecutionCreatedSecondsAgo($server, $cutoff + 3600, ['status' => 'success', 'message' => 'Saved 3%']);
$failed = dockerCleanupExecutionCreatedSecondsAgo($server, $cutoff + 3600, ['status' => 'failed', 'message' => 'original error']);
DockerCleanupJob::failInterruptedExecutions();
$stale->refresh();
expect($stale->status)->toBe('failed')
->and($stale->message)->toBe(DockerCleanupJob::INTERRUPTED_MESSAGE)
->and($stale->finished_at)->not->toBeNull()
->and($stale->cleanup_log)->toBe('["kept"]');
foreach ([$atCutoff, $recent] as $running) {
expect($running->fresh()->only(['status', 'finished_at']))->toBe(['status' => 'running', 'finished_at' => null]);
}
expect($finished->fresh()->status)->toBe('running')
->and($succeeded->fresh()->only(['status', 'message', 'finished_at']))->toBe(['status' => 'success', 'message' => 'Saved 3%', 'finished_at' => null])
->and($failed->fresh()->only(['status', 'message', 'finished_at']))->toBe(['status' => 'failed', 'message' => 'original error', 'finished_at' => null]);
Event::assertDispatchedTimes(DockerCleanupDone::class, 1);
Event::assertDispatched(DockerCleanupDone::class, fn (DockerCleanupDone $event) => $event->execution->is($stale)
&& $event->execution->status === 'failed'
&& $event->teamId === $server->team_id);
Notification::assertSentToTimes($server->team, DockerCleanupFailed::class, 1);
Notification::assertSentTo($server->team, DockerCleanupFailed::class, fn (DockerCleanupFailed $notification) => $notification->server->is($server)
&& str_contains($notification->message, DockerCleanupJob::INTERRUPTED_MESSAGE));
});
it('fails interrupted cleanups across more than one batch', function () {
Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC'));
Event::fake([DockerCleanupDone::class]);
$server = dockerCleanupReachableServer();
$count = DockerCleanupJob::INTERRUPTED_EXECUTIONS_CHUNK_SIZE * 2 + 1;
$staleAge = CleanupDocker::overlapLockExpiresAfter() + 1;
foreach (range(1, $count) as $ignored) {
dockerCleanupExecutionCreatedSecondsAgo($server, $staleAge);
}
DockerCleanupJob::failInterruptedExecutions();
expect(DockerCleanupExecution::query()->where('status', 'failed')->whereNotNull('finished_at')->count())->toBe($count)
->and(DockerCleanupExecution::query()->where('status', 'running')->count())->toBe(0);
Event::assertDispatchedTimes(DockerCleanupDone::class, $count);
});
it('does not fail a cleanup again when it finished after the stale lookup read it', function () {
Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC'));
Event::fake([DockerCleanupDone::class]);
$server = dockerCleanupReachableServer();
$staleAge = CleanupDocker::overlapLockExpiresAfter() + 1;
$first = dockerCleanupExecutionCreatedSecondsAgo($server, $staleAge);
$finishesMeanwhile = dockerCleanupExecutionCreatedSecondsAgo($server, $staleAge);
DB::listen(function ($query) use ($first, $finishesMeanwhile) {
static $done = false;
if (! $done && str_starts_with(strtolower($query->sql), 'update') && in_array($first->id, $query->bindings, true)) {
$done = true;
DockerCleanupExecution::query()->whereKey($finishesMeanwhile->id)->update(['status' => 'success', 'message' => 'done', 'finished_at' => now()]);
}
});
DockerCleanupJob::failInterruptedExecutions();
expect($first->fresh()->status)->toBe('failed')
->and($finishesMeanwhile->fresh()->only(['status', 'message']))->toBe(['status' => 'success', 'message' => 'done']);
Event::assertDispatchedTimes(DockerCleanupDone::class, 1);
});
@@ -0,0 +1,70 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner, ['role' => 'owner']);
$this->team->members()->attach($this->member, ['role' => 'member']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_raw' => "services:\n app:\n image: nginx:alpine\n environment:\n - 'BACKUP_KEEP=\${BACKUP_KEEP:-3}'\n - 'LOG_LEVEL=\${LOG_LEVEL:-info}'\n - 'APP_NAME=\${APP_NAME}'\n",
'environment_id' => $project->environments()->firstOrFail()->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
])->fresh();
EnvironmentVariable::query()->delete();
foreach (['BACKUP_KEEP' => '', 'LOG_LEVEL' => 'debug', 'APP_NAME' => ''] as $key => $value) {
EnvironmentVariable::create([
'key' => $key,
'value' => $value,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
}
$this->devView = fn () => Livewire::test(All::class, ['resource' => $this->application])
->call('loadEnvironmentVariables')
->call('switch');
});
it('shows the compose default above an empty variable and keeps it empty when saved', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$component = ($this->devView)();
expect($component->get('variables'))->toBe("# Compose default: 3\nBACKUP_KEEP=\nLOG_LEVEL=debug\nAPP_NAME=");
$component->call('submit')->assertNotDispatched('error');
expect($this->application->environment_variables()->where('key', 'BACKUP_KEEP')->value('value'))->toBe('');
});
it('does not show compose defaults to a member', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(($this->devView)()->get('variables'))->not->toContain('Compose default');
});
@@ -144,3 +144,15 @@ it('names the preview text area in a directive error', function () {
->call('submit')
->assertDispatched('error', 'Preview deployments: Line 2: a "# coolify:" line must be directly above a KEY=value line.');
});
it('keeps the saved order of preview variables when alphabetical sorting is disabled', function () {
$component = ($this->devView)()
->assertSet('showPreview', true)
->assertSet('is_env_sorting_enabled', false)
->set('variablesPreview', "ZEBRA=1\nALPHA=2\nMIDDLE=3")
->call('submit')
->assertNotDispatched('error');
expect($component->get('variablesPreview'))->toBe("ZEBRA=1\nALPHA=2\nMIDDLE=3")
->and($component->instance()->environmentVariablesPreview->pluck('key')->all())->toBe(['ZEBRA', 'ALPHA', 'MIDDLE']);
});
@@ -0,0 +1,103 @@
<?php
use App\Actions\Database\StartPostgresql;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\DatabaseStartCommandExecutor;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Process::fake();
Queue::fake();
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
test('a stored init script filename with path traversal is written inside the initdb directory', function () {
$executor = new class
{
public array $commands = [];
public function execute(array $commands, $database, Activity $activity): Activity
{
$this->commands = $commands;
return $activity;
}
};
app()->instance(DatabaseStartCommandExecutor::class, $executor);
$database = create_standalone_postgresql($this->environment->id, $this->destination, [
'init_scripts' => [['index' => 0, 'filename' => '../../../../../etc/cron.d/pwn', 'content' => 'x']],
]);
StartPostgresql::run($database->fresh(), new Activity);
$initDir = database_configuration_dir().'/'.$database->uuid.'/docker-entrypoint-initdb.d';
$teeCommands = collect($executor->commands)->filter(fn ($command) => is_string($command) && str_contains($command, '| tee ') && str_contains($command, 'docker-entrypoint-initdb.d'));
expect($teeCommands)->toHaveCount(1)
->and($teeCommands->first())->toEndWith('| tee '.escapeshellarg($initDir.'/pwn').' > /dev/null')
->and(implode("\n", array_filter($executor->commands, 'is_string')))->not->toContain('etc/cron.d');
});
test('the init script list cannot be replaced from the browser', function () {
$database = create_standalone_postgresql($this->environment->id, $this->destination);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(fn () => Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()])
->set('initScripts', [['index' => 0, 'filename' => '../../etc/cron.d/pwn', 'content' => 'x']]))
->toThrow(CannotUpdateLockedPropertyException::class);
expect($database->fresh()->init_scripts)->toBeEmpty();
});
test('saving an init script with an unknown original filename validates the new filename', function () {
$database = create_standalone_postgresql($this->environment->id, $this->destination);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()])
->call('save_init_script', ['index' => 0, 'filename' => '../../etc/cron.d/pwn', 'content' => 'x'], 'missing.sql')
->assertDispatched('error');
expect($database->fresh()->init_scripts)->toBeEmpty();
});
test('saving an existing init script with a safe filename still works', function () {
$database = create_standalone_postgresql($this->environment->id, $this->destination, [
'init_scripts' => [['index' => 0, 'filename' => 'init.sql', 'content' => 'select 1;']],
]);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(PostgresqlGeneral::class, ['database' => $database->fresh()])
->call('save_init_script', ['index' => 0, 'filename' => 'init.sql', 'content' => 'select 2;'], 'init.sql')
->assertDispatched('success');
expect($database->fresh()->init_scripts)->toBe([['index' => 0, 'filename' => 'init.sql', 'content' => 'select 2;']]);
});
@@ -114,3 +114,70 @@ it('keeps reconciling a disabled server until its running jobs finish', function
Process::assertRan(reconcileDisabledTestCommandFor($server));
expect($running->fresh()->status)->toBe(GithubRunnerStatus::Failed);
});
function reconcileDisabledTestExecution(GithubApp $githubApp, Server $server, GithubRunnerStatus $status): GithubRunnerExecution
{
static $jobId = 4000;
return GithubRunnerExecution::create([
'github_app_id' => $githubApp->id,
'server_id' => $server->id,
'trigger_workflow_job_id' => ++$jobId,
'labels' => ['self-hosted', 'coolify'],
'status' => $status,
]);
}
function reconcileDisabledTestSelectedIds(): array
{
return (new ReconcileGithubRunnersJob)->serversToReconcile()->pluck('id')->all();
}
it('selects servers with enabled runners or occupying executions', function () {
$enabled = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: true);
$disabled = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: false);
$disabledWithFinished = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: false);
$executionOnly = Server::factory()->create(['team_id' => $this->team->id]);
$finishedOnly = Server::factory()->create(['team_id' => $this->team->id]);
foreach ([GithubRunnerStatus::Queued, GithubRunnerStatus::Completed, GithubRunnerStatus::Failed, GithubRunnerStatus::Cancelled, GithubRunnerStatus::TimedOut] as $status) {
reconcileDisabledTestExecution($this->githubApp, $disabledWithFinished, $status);
reconcileDisabledTestExecution($this->githubApp, $finishedOnly, $status);
}
reconcileDisabledTestExecution($this->githubApp, $executionOnly, GithubRunnerStatus::Provisioning);
expect(reconcileDisabledTestSelectedIds())->toEqualCanonicalizing([$enabled->id, $executionOnly->id])
->not->toContain($disabled->id);
});
it('selects each server once when several conditions match', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: true);
foreach (GithubRunnerStatus::occupying() as $status) {
reconcileDisabledTestExecution($this->githubApp, $server, $status);
}
expect(reconcileDisabledTestSelectedIds())->toBe([$server->id]);
});
it('does not select soft-deleted servers', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: true);
reconcileDisabledTestExecution($this->githubApp, $server, GithubRunnerStatus::Running);
Server::query()->whereKey($server->id)->update(['deleted_at' => now()]);
expect(reconcileDisabledTestSelectedIds())->toBe([]);
});
it('follows configuration and execution status changes', function () {
$server = reconcileDisabledTestServer($this->team, $this->githubApp, enabled: false);
expect(reconcileDisabledTestSelectedIds())->toBe([]);
$server->githubRunnerConfig->update(['is_enabled' => true]);
expect(reconcileDisabledTestSelectedIds())->toBe([$server->id]);
$server->githubRunnerConfig->update(['is_enabled' => false]);
$execution = reconcileDisabledTestExecution($this->githubApp, $server, GithubRunnerStatus::Idle);
expect(reconcileDisabledTestSelectedIds())->toBe([$server->id]);
$execution->update(['status' => GithubRunnerStatus::Completed]);
expect(reconcileDisabledTestSelectedIds())->toBe([]);
});
@@ -0,0 +1,184 @@
<?php
use App\Livewire\Security\PrivateKey\Show;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
Config::set('cache.default', 'array');
Config::set('constants.ssh.mux_enabled', true);
Storage::fake('ssh-keys');
Process::fake();
$this->privateKey = teamKey($this->team);
});
function teamToken(User $user, Team $team): string
{
$token = $user->createToken('test', ['write', 'delete']);
$token->accessToken->forceFill(['team_id' => $team->id])->save();
auth()->forgetGuards();
return $token->plainTextToken;
}
function serverForKey(PrivateKey $privateKey, bool $deleted = false): Server
{
$server = Server::factory()->create([
'team_id' => $privateKey->team_id,
'private_key_id' => $privateKey->id,
]);
if ($deleted) {
$server->delete();
}
return $server;
}
function teamKey(Team $team): PrivateKey
{
return PrivateKey::factory()->create([
'team_id' => $team->id,
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
]);
}
function assertMuxStops(Server $server, int $times): void
{
Process::assertRanTimes(fn ($process) => str_contains($process->command, 'ssh -O stop')
&& str_contains($process->command, 'mux_'.$server->uuid), $times);
}
test('a key used by one or many active servers is in use', function () {
serverForKey($this->privateKey);
expect($this->privateKey->isInUse())->toBeTrue();
serverForKey($this->privateKey);
expect($this->privateKey->servers()->count())->toBe(2)
->and($this->privateKey->isInUse())->toBeTrue();
});
test('a key used only by soft-deleted servers is not in use and can be deleted', function () {
serverForKey($this->privateKey, deleted: true);
expect($this->privateKey->servers()->exists())->toBeFalse()
->and($this->privateKey->safeDelete())->toBeTrue()
->and(PrivateKey::query()->whereKey($this->privateKey->id)->exists())->toBeFalse();
});
test('an unreferenced key is not in use', function () {
serverForKey(teamKey($this->team));
expect($this->privateKey->isInUse())->toBeFalse();
});
test('a key with id 0 is matched like any other key', function () {
$instanceKey = PrivateKey::factory()->make([
'team_id' => $this->team->id,
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
]);
$instanceKey->id = 0;
$instanceKey->save();
expect($instanceKey->isInUse())->toBeFalse();
serverForKey($instanceKey);
expect($instanceKey->isInUse())->toBeTrue()
->and($instanceKey->safeDelete())->toBeFalse();
});
test('a key without an id matches no servers', function () {
serverForKey($this->privateKey);
expect((new PrivateKey)->isInUse())->toBeFalse();
refresh_server_connection(null);
refresh_server_connection(new PrivateKey);
Process::assertNothingRan();
});
test('refreshing connections stops the mux of every active server of the key only', function () {
$first = serverForKey($this->privateKey);
$second = serverForKey($this->privateKey);
$deleted = serverForKey($this->privateKey, deleted: true);
$other = serverForKey(teamKey($this->team));
refresh_server_connection($this->privateKey);
assertMuxStops($first, 1);
assertMuxStops($second, 1);
assertMuxStops($deleted, 0);
assertMuxStops($other, 0);
});
test('changing the key content refreshes each server connection once', function () {
$server = serverForKey($this->privateKey);
$newKey = PrivateKey::generateNewKeyPair('ed25519')['private_key'];
Livewire::test(Show::class, ['private_key_uuid' => $this->privateKey->uuid])
->set('privateKeyValue', $newKey)
->call('changePrivateKey')
->assertDispatched('success');
expect($this->privateKey->fresh()->private_key)->toBe(formatPrivateKey($newKey));
assertMuxStops($server, 1);
});
test('a team member cannot change a key used by servers', function () {
$server = serverForKey($this->privateKey);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(Show::class, ['private_key_uuid' => $this->privateKey->uuid])
->set('privateKeyValue', PrivateKey::generateNewKeyPair('ed25519')['private_key'])
->call('changePrivateKey');
expect($this->privateKey->fresh()->private_key)->toBe($this->privateKey->private_key);
assertMuxStops($server, 0);
});
test('the api refuses to delete a key used by an active server of the team', function () {
serverForKey($this->privateKey);
$this->withToken(teamToken($this->user, $this->team))
->deleteJson("/api/v1/security/keys/{$this->privateKey->uuid}")
->assertStatus(422);
expect(PrivateKey::query()->whereKey($this->privateKey->id)->exists())->toBeTrue();
});
test('the api does not reveal keys of another team', function () {
$otherKey = teamKey(Team::factory()->create());
$this->withToken(teamToken($this->user, $this->team))
->deleteJson("/api/v1/security/keys/{$otherKey->uuid}")
->assertNotFound();
expect(PrivateKey::query()->whereKey($otherKey->id)->exists())->toBeTrue();
});
@@ -0,0 +1,135 @@
<?php
use App\Jobs\ServerConnectionCheckJob;
use App\Jobs\ServerManagerJob;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Subscription;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'instance_timezone' => 'UTC']);
$this->rootTeam = Team::factory()->create(['id' => 0]);
config(['constants.coolify.self_hosted' => false]);
// Minute 0 is due for the Cloud connection check frequency (*/5).
Carbon::setTestNow('2026-07-11 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
function createServerForSelectionTest(Team $team): Server
{
$privateKey = PrivateKey::query()->firstWhere('team_id', $team->id)
?? PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
}
/**
* @return array<int, int> server id => number of connection checks queued for it
*/
function connectionChecksPerServer(): array
{
Queue::fake();
(new ServerManagerJob)->handle();
return collect(Queue::pushed(ServerConnectionCheckJob::class))
->countBy(fn (ServerConnectionCheckJob $job) => $job->server->id)
->sortKeys()
->all();
}
it('processes each server of a paid team once, also with more than one paid subscription', function () {
$team = Team::factory()->create();
Subscription::factory()->count(2)->create(['team_id' => $team->id, 'stripe_invoice_paid' => true]);
$server = createServerForSelectionTest($team);
expect(connectionChecksPerServer())->toBe([$server->id => 1]);
});
it('skips servers of teams without a paid subscription', function () {
$unpaid = Team::factory()->create();
Subscription::factory()->create(['team_id' => $unpaid->id, 'stripe_invoice_paid' => false]);
createServerForSelectionTest($unpaid);
$pastDue = Team::factory()->create();
Subscription::factory()->create(['team_id' => $pastDue->id, 'stripe_invoice_paid' => false, 'stripe_past_due' => true]);
createServerForSelectionTest($pastDue);
createServerForSelectionTest(Team::factory()->create());
expect(connectionChecksPerServer())->toBe([]);
});
it('processes a team with one paid and one unpaid subscription', function () {
$team = Team::factory()->create();
Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => false]);
Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => true]);
$server = createServerForSelectionTest($team);
expect(connectionChecksPerServer())->toBe([$server->id => 1]);
});
it('skips soft-deleted servers of a paid team', function () {
$team = Team::factory()->create();
Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => true]);
$kept = createServerForSelectionTest($team);
createServerForSelectionTest($team)->delete();
expect(connectionChecksPerServer())->toBe([$kept->id => 1]);
});
it('processes root team servers once without a subscription', function () {
$rootServer = createServerForSelectionTest($this->rootTeam);
expect(connectionChecksPerServer())->toBe([$rootServer->id => 1]);
Subscription::factory()->create(['team_id' => 0, 'stripe_invoice_paid' => true]);
Carbon::setTestNow('2026-07-11 12:05:00');
expect(connectionChecksPerServer())->toBe([$rootServer->id => 1]);
});
it('follows subscription payment changes between runs', function () {
$team = Team::factory()->create();
$subscription = Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => false]);
$server = createServerForSelectionTest($team);
expect(connectionChecksPerServer())->toBe([]);
$subscription->update(['stripe_invoice_paid' => true]);
Carbon::setTestNow('2026-07-11 12:05:00');
expect(connectionChecksPerServer())->toBe([$server->id => 1]);
$subscription->update(['stripe_invoice_paid' => false]);
Carbon::setTestNow('2026-07-11 12:10:00');
expect(connectionChecksPerServer())->toBe([]);
});
it('processes every server when they span more than one batch', function () {
$team = Team::factory()->create();
Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => true]);
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$servers = Server::factory()
->count(ServerManagerJob::SERVER_BATCH_SIZE + 1)
->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
expect(connectionChecksPerServer())
->toBe($servers->pluck('id')->sort()->mapWithKeys(fn (int $id) => [$id => 1])->all());
});
it('processes every server in self-hosted mode', function () {
config(['constants.coolify.self_hosted' => true]);
Carbon::setTestNow('2026-07-11 12:01:00');
$server = createServerForSelectionTest(Team::factory()->create());
expect(connectionChecksPerServer())->toBe([$server->id => 1]);
});
+49
View File
@@ -130,3 +130,52 @@ it('paginates images and returns to the first page when the search changes', fun
->assertSee('1–5 of 9')
->assertDontSee('app-06');
});
it('sorts images by the chosen column and ignores unknown sort values', function () {
fakeRemoteProcesses([
'*docker image ls*' => Process::result(output: implode("\n", [
json_encode(['Repository' => 'beta', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('1', 64), 'Size' => '9MB', 'CreatedAt' => '2024-03-01 10:00:00 +0000 UTC', 'CreatedSince' => '7 months ago']),
json_encode(['Repository' => 'alpha', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('2', 64), 'Size' => '100MB', 'CreatedAt' => '2024-01-01 10:00:00 +0000 UTC', 'CreatedSince' => '9 months ago']),
json_encode(['Repository' => 'gamma', 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat('3', 64), 'Size' => '1.2GB', 'CreatedAt' => '2024-02-01 10:00:00 +0000 UTC', 'CreatedSince' => '8 months ago']),
])),
'*docker ps -a*' => Process::result(output: ''),
'*docker system df*' => Process::result(output: ''),
]);
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
->call('load')
->assertSeeInOrder(['beta', 'alpha', 'gamma'])
->call('setSort', 'repository_asc')
->assertSet('sort', 'repository_asc')
->assertSeeInOrder(['alpha', 'beta', 'gamma'])
->call('setSort', 'size_asc')
->assertSeeInOrder(['9MB', '100MB', '1.2GB'])
->call('setSort', 'size_desc')
->assertSeeInOrder(['1.2GB', '100MB', '9MB'])
->call('setSort', 'created_desc')
->assertSeeInOrder(['beta', 'gamma', 'alpha'])
->call('setSort', 'containers_desc')
->assertSet('sort', 'created_desc')
->assertSeeInOrder(['beta', 'gamma', 'alpha']);
});
it('returns to the first page when the sort changes', function () {
fakeRemoteProcesses([
'*docker image ls*' => Process::result(output: collect(range(1, 12))
->map(fn (int $i) => json_encode([
'Repository' => sprintf('app-%02d', $i), 'Tag' => 'latest', 'ID' => 'sha256:'.str_repeat(dechex($i), 64),
'Size' => '1MB', 'CreatedSince' => '1 day ago',
]))
->implode("\n")),
'*docker ps -a*' => Process::result(output: ''),
'*docker system df*' => Process::result(output: ''),
]);
Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid])
->call('load')
->call('nextPage')
->call('setSort', 'repository_desc')
->assertSee('1–10 of 12')
->assertSee('app-12')
->assertDontSee('app-02');
});
+97
View File
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Server\Resources;
use App\Models\Server;
function sortableManagedResource(string $name, string $project, string $environment, string $type, string $status): object
{
return new class($name, $project, $environment, $type, $status)
{
public array $environment;
public function __construct(public string $name, public string $projectName, string $environment, public string $resourceType, public string $status)
{
$this->environment = ['name' => $environment];
}
public function project(): array
{
return ['name' => $this->projectName];
}
public function type(): string
{
return $this->resourceType;
}
};
}
beforeEach(function () {
$this->component = new Resources;
$this->component->server = Mockery::mock(Server::class)->makePartial();
});
function sortedNames(Resources $component, string $nameKey = 'name'): array
{
return $component->render()->getData()['resources']->pluck($nameKey)->all();
}
it('sorts managed resources by each column in both directions with name as tie-breaker', function () {
$this->component->server->shouldReceive('definedResources')->andReturn(collect([
sortableManagedResource('web 10', 'Beta', 'production', 'application', 'running:healthy'),
sortableManagedResource('Web 2', 'alpha', 'staging', 'service', 'exited:unhealthy'),
sortableManagedResource('db', 'Beta', 'dev', 'standalone-postgresql', 'running:healthy'),
]));
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
$this->component->setSort('name_desc');
expect(sortedNames($this->component))->toBe(['web 10', 'Web 2', 'db']);
$this->component->setSort('project_asc');
expect(sortedNames($this->component))->toBe(['Web 2', 'db', 'web 10']);
$this->component->setSort('environment_desc');
expect(sortedNames($this->component))->toBe(['Web 2', 'web 10', 'db']);
$this->component->setSort('type_desc');
expect(sortedNames($this->component))->toBe(['db', 'Web 2', 'web 10']);
$this->component->setSort('status_desc');
expect(sortedNames($this->component))->toBe(['db', 'web 10', 'Web 2']);
});
it('sorts unmanaged containers by image and status', function () {
$this->component->activeTab = 'unmanaged';
$this->component->unmanagedContainers = [
['Names' => 'cache', 'Image' => 'redis:7', 'State' => 'running'],
['Names' => 'proxy', 'Image' => 'nginx:1', 'State' => 'exited'],
['Names' => 'app', 'Image' => 'redis:7', 'State' => 'running'],
];
$this->component->setSort('image_asc');
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
$this->component->setSort('status_asc');
expect(sortedNames($this->component, 'Names'))->toBe(['proxy', 'app', 'cache']);
});
it('ignores unknown sort values and resets the page on a valid sort', function () {
$this->component->setPage(3);
foreach (['bogus', 'name_sideways', 'image_asc', 'name_asc; drop'] as $invalid) {
$this->component->setSort($invalid);
}
expect($this->component->sort)->toBe('name_asc')
->and($this->component->getPage())->toBe(3);
$this->component->setSort('status_desc');
expect($this->component->sort)->toBe('status_desc')
->and($this->component->getPage())->toBe(1);
});
it('renders livewire sort headers for both tabs', function () {
$view = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
foreach (['name', 'project', 'environment', 'type', 'status', 'image'] as $column) {
expect($view)->toContain("asc=\"{$column}_asc\" desc=\"{$column}_desc\" action=\"setSort\"");
}
});
@@ -0,0 +1,118 @@
<?php
use App\Jobs\DockerCleanupJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerManagerJob;
use App\Jobs\ServerPatchCheckJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Team;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Cache::flush();
config(['constants.coolify.self_hosted' => true]);
InstanceSettings::forceCreate(['id' => 0, 'instance_timezone' => 'UTC']);
$this->team = Team::factory()->create();
$this->patchResult = ['package_manager' => 'apt', 'total_updates' => 1, 'updates' => [['package' => 'openssl', 'new_version' => '3.0.13-2']]];
});
afterEach(function () {
Carbon::setTestNow();
});
function createBulkLoadServer(Team $team, array $settings): Server
{
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->forceFill([
'is_reachable' => true,
'is_usable' => true,
'server_timezone' => 'UTC',
...$settings,
])->saveQuietly();
return $server;
}
/**
* @return list<string>
*/
function serverSettingsQueriesDuring(Closure $callback): array
{
$queries = [];
DB::listen(function ($query) use (&$queries) {
if (str_contains($query->sql, 'from "server_settings"')) {
$queries[] = $query->sql;
}
});
$callback();
return $queries;
}
it('loads every settings column except the patch result and matches each settings row to its server', function () {
$first = createBulkLoadServer($this->team, ['docker_cleanup_frequency' => '0 1 * * *', 'patch_check_result' => $this->patchResult]);
$second = createBulkLoadServer($this->team, ['docker_cleanup_frequency' => '0 2 * * *', 'patch_check_frequency' => 'daily']);
$servers = Server::query()
->whereKey([$first->id, $second->id])
->with(['settings' => fn (HasOne $query) => $query->withoutPatchCheckResult()])
->get()
->keyBy('id');
foreach ([$first, $second] as $server) {
$full = ServerSetting::query()->where('server_id', $server->id)->firstOrFail()->getAttributes();
unset($full['patch_check_result']);
expect($servers[$server->id]->settings->getAttributes())->toBe($full);
}
expect($servers[$first->id]->settings->docker_cleanup_frequency)->toBe('0 1 * * *')
->and($servers[$second->id]->settings->docker_cleanup_frequency)->toBe('0 2 * * *')
->and($servers[$second->id]->settings->patch_check_frequency)->toBe('daily')
->and($servers[$first->id]->settings->is_reachable)->toBeTrue();
});
it('does not read the patch results of all servers in the server manager, and its jobs still get them', function () {
Queue::fake();
Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC'));
$server = createBulkLoadServer($this->team, ['patch_check_frequency' => '* * * * *', 'patch_check_result' => $this->patchResult]);
$queries = serverSettingsQueriesDuring(fn () => (new ServerManagerJob)->handle());
expect($queries)->not->toBeEmpty();
foreach ($queries as $sql) {
expect($sql)->not->toContain('patch_check_result')->not->toContain('*');
}
Queue::assertPushed(ServerPatchCheckJob::class, function (ServerPatchCheckJob $job) use ($server) {
$restored = unserialize(serialize($job));
return $restored->server->id === $server->id
&& $restored->server->settings->patch_check_result === $this->patchResult;
});
});
it('does not read the patch results of all servers when it schedules Docker cleanups', function () {
Queue::fakeExcept(ScheduledJobManager::class);
Carbon::setTestNow(Carbon::create(2026, 10, 28, 12, 0, 0, 'UTC'));
$server = createBulkLoadServer($this->team, ['docker_cleanup_frequency' => '* * * * *', 'patch_check_result' => $this->patchResult]);
$queries = serverSettingsQueriesDuring(fn () => (new ScheduledJobManager)->handle());
expect(collect($queries)->filter(fn (string $sql) => str_contains($sql, '"server_id" in')))->not->toBeEmpty();
foreach ($queries as $sql) {
if (str_contains($sql, '"server_id" in')) {
expect($sql)->not->toContain('patch_check_result')->not->toContain('*');
}
}
Queue::assertPushed(DockerCleanupJob::class, fn (DockerCleanupJob $job) => $job->server->id === $server->id);
});
@@ -0,0 +1,180 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Application\General;
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\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], []));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]);
});
function makeRegistryImageApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application
{
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/acme/monorepo',
...$attributes,
]);
}
function runSharedRegistryImageGuard(Application $application): void
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('application')->setValue($job, $application);
$reflection->getMethod('ensureRegistryImageIsNotShared')->invoke($job);
}
describe('deployment guard', function () {
test('fails when another building application of the team pushes the same image name', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'server', 'build_pack' => 'dockerfile']);
expect(fn () => runSharedRegistryImageGuard($server))
->toThrow(DeploymentException::class, 'The application web also builds the Docker image ghcr.io/acme/monorepo');
});
test('allows unique image names', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']);
runSharedRegistryImageGuard($server);
})->throwsNoExceptions();
test('allows applications that do not push a built image', function (array $other, array $deployed) {
makeRegistryImageApplication($this->environment, $this->destination, $other);
$application = makeRegistryImageApplication($this->environment, $this->destination, $deployed);
runSharedRegistryImageGuard($application);
})->with([
'other runs the image' => [['build_pack' => 'dockerimage'], []],
'other is docker compose' => [['build_pack' => 'dockercompose'], []],
'deployed runs the image' => [[], ['build_pack' => 'dockerimage']],
'no image name' => [['docker_registry_image_name' => null], ['docker_registry_image_name' => null]],
])->throwsNoExceptions();
test('ignores applications of other teams', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]);
makeRegistryImageApplication($otherEnvironment, $otherDestination);
$application = makeRegistryImageApplication($this->environment, $this->destination);
runSharedRegistryImageGuard($application);
})->throwsNoExceptions();
});
test('the general form rejects an image name that another building application uses', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, [
'docker_registry_image_name' => 'ghcr.io/acme/server',
'ports_exposes' => '3000',
'base_directory' => '/',
'redirect' => 'both',
'static_image' => 'nginx:alpine',
'is_http_basic_auth_enabled' => false,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(General::class, ['application' => $server])
->set('dockerRegistryImageName', 'ghcr.io/acme/monorepo')
->call('submit')
->assertHasErrors('dockerRegistryImageName');
expect($server->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server');
});
describe('API', function () {
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'shared-image-name-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->apiHeaders = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
$this->server = Server::query()->whereKey($this->destination->server_id)->firstOrFail();
$this->project = Project::query()->whereKey($this->environment->project_id)->firstOrFail();
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
});
test('update rejects an image name that another building application uses', function () {
$application = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']);
$this->withHeaders($this->apiHeaders)
->patchJson("/api/v1/applications/{$application->uuid}", ['docker_registry_image_name' => 'ghcr.io/acme/monorepo'])
->assertUnprocessable()
->assertJsonPath('errors.docker_registry_image_name.0', fn (string $message) => str_contains($message, 'The application web also builds'));
expect($application->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server');
});
test('update accepts other changes of an application that already shares its image name', function () {
$application = makeRegistryImageApplication($this->environment, $this->destination);
$this->withHeaders($this->apiHeaders)
->patchJson("/api/v1/applications/{$application->uuid}", ['description' => 'api'])
->assertOk();
});
test('create rejects an image name that another building application uses', function () {
$count = Application::count();
$this->withHeaders($this->apiHeaders)
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'docker_registry_image_name' => 'ghcr.io/acme/monorepo',
'autogenerate_domain' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_registry_image_name');
expect(Application::count())->toBe($count);
});
test('create accepts a unique image name', function () {
$this->withHeaders($this->apiHeaders)
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'docker_registry_image_name' => 'ghcr.io/acme/server',
'autogenerate_domain' => false,
])
->assertCreated();
});
});
+38
View File
@@ -2,6 +2,7 @@
use App\Livewire\Tags\Show;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
@@ -167,3 +168,40 @@ it('does not let a member change tags from the connect modal', function () {
expect($application->tags()->exists())->toBeFalse();
});
it('sorts active deployments by the chosen column and ignores unknown sort values', function () {
$tag = Tag::create(['name' => 'hello', 'team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
foreach ([['zeta-app', 'alpha-server', 'queued'], ['alpha-app', 'zeta-server', 'in_progress'], ['mid-app', 'alpha-server', 'in_progress']] as [$name, $serverName, $status]) {
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'name' => $name,
]);
$application->tags()->attach($tag->id);
ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => $name,
'application_name' => $name,
'server_id' => $server->id,
'server_name' => $serverName,
'status' => $status,
]);
}
Livewire::test(Show::class, ['tagName' => 'hello'])
->assertSeeInOrder(['Active deployments', 'zeta-app', 'mid-app', 'alpha-app'])
->call('setDeploymentSort', 'resource-asc')
->assertSeeInOrder(['Active deployments', 'alpha-app', 'mid-app', 'zeta-app'])
->call('setDeploymentSort', 'server-desc')
->assertSeeInOrder(['Active deployments', 'alpha-app', 'zeta-app', 'mid-app'])
->call('setDeploymentSort', 'status-desc')
->assertSeeInOrder(['Active deployments', 'zeta-app', 'alpha-app', 'mid-app'])
->call('setDeploymentSort', 'deployment_url-asc')
->assertSet('deploymentSort', 'status-desc');
});
@@ -7,6 +7,8 @@ use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Once;
use Livewire\Livewire;
@@ -168,3 +170,83 @@ test('removes an unused pending user after the last revocation', function () {
$this->assertDatabaseMissing('team_invitations', ['id' => $this->invitationA->id]);
$this->assertDatabaseMissing('users', ['id' => $this->pendingUser->id]);
});
function invitationMagicLinkPassword(TeamInvitation $invitation): ?string
{
if (parse_url($invitation->link, PHP_URL_PATH) !== '/auth/link') {
return null;
}
parse_str((string) parse_url($invitation->link, PHP_URL_QUERY), $query);
return explode('@@@', Crypt::decryptString($query['token']), 3)[2];
}
test('sends a working magic link when the same team invites again after expiration', function () {
$this->actingAs($this->ownerA);
session(['currentTeam' => $this->teamA]);
$this->invitationA->forceFill(['created_at' => now()->subDays(10)])->save();
Livewire::test(InviteLink::class)
->set('email', $this->pendingUser->email)
->call('viaLink')
->assertDispatched('success');
$invitation = TeamInvitation::whereEmail($this->pendingUser->email)->sole();
$user = User::whereEmail($this->pendingUser->email)->sole();
$password = invitationMagicLinkPassword($invitation);
expect($password)->not->toBeNull()
->and(Hash::check($password, $user->password))->toBeTrue();
});
test('sends a working magic link to an invited user who never set a password', function () {
$user = User::factory()->create([
'email' => 'removed-invitee@example.com',
'email_verified_at' => null,
'force_password_reset' => true,
]);
Livewire::test(InviteLink::class)
->set('email', $user->email)
->call('viaLink')
->assertDispatched('success');
$password = invitationMagicLinkPassword(TeamInvitation::whereEmail($user->email)->sole());
expect($password)->not->toBeNull()
->and(Hash::check($password, $user->refresh()->password))->toBeTrue();
});
test('keeps the login link and password of an invited user who can sign in', function (array $attributes) {
$user = User::factory()->create(array_merge(['email' => 'existing-invitee@example.com'], $attributes));
$passwordHash = $user->password;
if ($attributes['force_password_reset'] ?? false) {
$this->teamA->members()->attach($user, ['role' => 'member']);
}
Livewire::test(InviteLink::class)
->set('email', $user->email)
->call('viaLink')
->assertDispatched('success');
expect(invitationMagicLinkPassword(TeamInvitation::whereEmail($user->email)->sole()))->toBeNull()
->and($user->refresh()->password)->toBe($passwordHash);
})->with([
'own password' => [['force_password_reset' => false]],
'pending member of another team' => [['force_password_reset' => true, 'email_verified_at' => null]],
]);
test('keeps the inviter signed in when the invitee password is rotated', function () {
$user = User::factory()->create([
'email' => 'rotated-invitee@example.com',
'email_verified_at' => null,
'force_password_reset' => true,
]);
Livewire::test(InviteLink::class)
->set('email', $user->email)
->call('viaLink');
$this->assertAuthenticatedAs($this->ownerB);
expect(data_get(session('currentTeam'), 'id'))->toBe($this->teamB->id);
});
+29
View File
@@ -49,3 +49,32 @@ it('preserves a provisional user when revoking their invitation fails', function
$this->assertDatabaseHas('users', ['id' => $provisionalUser->id]);
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
});
it('sorts pending invitations by the chosen column and ignores unknown sort values', function () {
$invite = fn (string $email, string $role, string $via) => TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => $email,
'email' => $email,
'role' => $role,
'link' => "http://example.test/invitations/{$email}",
'via' => $via,
]);
$invitations = collect([
$invite('carol@example.com', 'member', 'email'),
$invite('alice@example.com', 'owner', 'link'),
$invite('bob@example.com', 'admin', 'email'),
]);
Livewire::test(Invitations::class, ['invitations' => $invitations])
->assertSeeInOrder(['carol@example.com', 'alice@example.com', 'bob@example.com'])
->call('setSort', 'email-asc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'role-desc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'role-asc')
->assertSeeInOrder(['carol@example.com', 'bob@example.com', 'alice@example.com'])
->call('setSort', 'via-desc')
->assertSeeInOrder(['alice@example.com', 'bob@example.com', 'carol@example.com'])
->call('setSort', 'link-asc')
->assertSet('sort', 'via-desc');
});
@@ -143,3 +143,21 @@ test('the summary confirms when every member has two factor authentication', fun
->assertSee('All members have two-factor authentication enabled')
->assertDontSee('do not have two-factor authentication enabled');
});
test('the members table sorts by role rank and only sends two factor status to member managers', function () {
$owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true);
$member = createTeamMember($this->team, 'member');
actAsTeamMember($owner, $this->team);
Livewire::test(Index::class)
->assertSee('Sort by role', false)
->assertSee('Sort by 2fa', false)
->assertSee('\\u0022roleRank\\u0022:3', false)
->assertSee('\\u0022twoFactor\\u0022:true', false);
actAsTeamMember($member, $this->team);
Livewire::test(Index::class)
->assertDontSee('Sort by 2fa', false)
->assertSee('\\u0022twoFactor\\u0022:null', false)
->assertDontSee('\\u0022twoFactor\\u0022:true', false);
});
@@ -0,0 +1,204 @@
<?php
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
$this->retrievedApplicationIds = collect();
Event::listen('eloquent.retrieved: '.Application::class, fn (Application $application) => $this->retrievedApplicationIds->push($application->id));
});
function manualLookupApplication(string $gitRepository, string $branch = 'main'): Application
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create(['team_id' => $team->id]);
$server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$destination = $server->standaloneDockers()->firstOrFail();
return Application::create([
'name' => 'manual-lookup-app',
'git_repository' => $gitRepository,
'git_branch' => $branch,
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
])->refresh();
}
function sendManualLookupPush(TestCase $test, string $provider, string $secret, string $repository = 'acme/app', string $branch = 'main'): TestResponse
{
$server = ['REMOTE_ADDR' => '203.0.113.20', 'CONTENT_TYPE' => 'application/json'];
if ($provider === 'gitlab') {
$payload = json_encode([
'object_kind' => 'push',
'ref' => "refs/heads/{$branch}",
'project' => ['path_with_namespace' => $repository],
'after' => 'abc1234',
'commits' => [],
]);
return $test->call('POST', '/webhooks/source/gitlab/events/manual', [], [], [], $server + ['HTTP_X-Gitlab-Token' => $secret], $payload);
}
if ($provider === 'bitbucket') {
$payload = json_encode([
'push' => ['changes' => [['new' => ['name' => $branch, 'target' => ['hash' => 'abc1234']]]]],
'repository' => ['full_name' => $repository],
]);
return $test->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], $server + [
'HTTP_X-Event-Key' => 'repo:push',
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
$payload = json_encode([
'ref' => "refs/heads/{$branch}",
'repository' => ['full_name' => $repository],
'after' => 'abc1234',
'commits' => [],
]);
return $test->call('POST', "/webhooks/source/{$provider}/events/manual", [], [], [], $server + [
$provider === 'github' ? 'HTTP_X-GitHub-Event' : 'HTTP_X-Gitea-Event' => 'push',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $secret),
], $payload);
}
function sendManualLookupGithubPullRequest(TestCase $test, Application $application, string $action, string $baseBranch = 'main'): TestResponse
{
$payload = json_encode([
'action' => $action,
'number' => 42,
'repository' => ['id' => 987654321, 'full_name' => 'acme/app'],
'pull_request' => [
'html_url' => 'https://github.com/acme/app/pull/42',
'title' => 'Change',
'head' => ['ref' => 'feature', 'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290', 'repo' => ['id' => 987654321]],
'base' => ['ref' => $baseBranch, 'repo' => ['id' => 987654321]],
],
], JSON_THROW_ON_ERROR);
return $test->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
'HTTP_X-GitHub-Event' => 'pull_request',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, $application->manual_webhook_secret_github),
'CONTENT_TYPE' => 'application/json',
], $payload);
}
function manualLookupDeployed(Application $application): bool
{
return ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists();
}
test('the candidate query filters by repository before applications are loaded', function (string $provider) {
$application = manualLookupApplication('https://github.com/acme/app');
$unrelated = manualLookupApplication('https://github.com/other/service');
$similarName = manualLookupApplication('https://github.com/acme/app-legacy');
$this->retrievedApplicationIds = collect();
DB::enableQueryLog();
sendManualLookupPush($this, $provider, $application->{"manual_webhook_secret_{$provider}"})->assertOk();
$candidateQuery = collect(DB::getQueryLog())->first(fn (array $query): bool => str_contains($query['query'], 'from "applications" where "git_branch" = ?'));
expect($candidateQuery['query'])->toContain('LOWER(git_repository) LIKE ?')
->and($candidateQuery['bindings'])->toContain('%acme/app%')
->and($this->retrievedApplicationIds)->not->toContain($unrelated->id)
->and(manualLookupDeployed($application))->toBeTrue()
->and(manualLookupDeployed($similarName))->toBeFalse();
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('every application of the pushed repository and branch is deployed', function (string $provider) {
$applications = collect([
'https://github.com/acme/app.git',
'git@github.com:Acme/App.git',
'ssh://git@git.example.com:2222/acme/app',
'acme/app',
])->map(fn (string $gitRepository): Application => manualLookupApplication($gitRepository));
$secret = $applications->first()->{"manual_webhook_secret_{$provider}"};
$applications->each(fn (Application $application) => $application->forceFill(["manual_webhook_secret_{$provider}" => $secret])->save());
sendManualLookupPush($this, $provider, $secret)->assertOk();
$applications->each(fn (Application $application) => expect(manualLookupDeployed($application))->toBeTrue());
})->with(['github', 'gitlab', 'bitbucket', 'gitea']);
test('identically named repositories on different git hosts deploy only the application whose secret signs the delivery', function () {
$githubApplication = manualLookupApplication('https://github.com/acme/app');
$giteaApplication = manualLookupApplication('https://gitea.example.com/acme/app.git');
sendManualLookupPush($this, 'gitea', $giteaApplication->manual_webhook_secret_gitea)->assertOk();
expect(manualLookupDeployed($giteaApplication))->toBeTrue()
->and(manualLookupDeployed($githubApplication))->toBeFalse();
});
test('an underscore in the repository name matches only an underscore', function () {
$application = manualLookupApplication('https://github.com/acme/my_app');
$wildcardMatch = manualLookupApplication('https://github.com/acme/myXapp');
$this->retrievedApplicationIds = collect();
sendManualLookupPush($this, 'github', $application->manual_webhook_secret_github, 'acme/my_app')->assertOk();
expect(manualLookupDeployed($application))->toBeTrue()
->and($this->retrievedApplicationIds)->not->toContain($wildcardMatch->id);
});
test('pushes that must not deploy an application', function (string $case, string $expectedMessage) {
$application = manualLookupApplication('https://github.com/acme/app', $case === 'other branch' ? 'develop' : 'main');
$secret = $application->manual_webhook_secret_github;
match ($case) {
'deleted application' => DB::table('applications')->where('id', $application->id)->update(['deleted_at' => now()]),
'auto deploy disabled' => $application->settings->update(['is_auto_deploy_enabled' => false]),
'invalid signature' => $secret = 'wrong-secret',
default => null,
};
$response = sendManualLookupPush($this, 'github', $secret)->assertOk();
expect($response->getContent())->toContain($expectedMessage)
->and(manualLookupDeployed($application))->toBeFalse();
})->with([
'other branch' => ['other branch', 'No matching application or invalid signature.'],
'deleted application' => ['deleted application', 'No matching application or invalid signature.'],
'auto deploy disabled' => ['auto deploy disabled', 'Deployments disabled.'],
'invalid signature' => ['invalid signature', 'No matching application or invalid signature.'],
]);
test('pull request events dispatch only applications of the repository', function (string $action) {
$application = manualLookupApplication('https://github.com/acme/app');
$application->settings->update(['is_preview_deployments_enabled' => true]);
$unrelated = manualLookupApplication('https://github.com/other/service');
$this->retrievedApplicationIds = collect();
sendManualLookupGithubPullRequest($this, $application, $action)->assertOk();
Queue::assertPushed(ProcessGithubPullRequestWebhook::class, 1);
Queue::assertPushed(ProcessGithubPullRequestWebhook::class, fn (ProcessGithubPullRequestWebhook $job): bool => $job->applicationId === $application->id && $job->action === $action);
expect($this->retrievedApplicationIds)->not->toContain($unrelated->id);
})->with(['opened', 'closed']);
+36
View File
@@ -0,0 +1,36 @@
<?php
use App\Services\TerminalSessionService;
use Symfony\Component\Process\Process;
function runTerminalShellCommand(string $shellName, int $exitCode = 1): Process
{
$home = sys_get_temp_dir().'/terminal-shell-'.uniqid();
mkdir($home);
$shell = "{$home}/{$shellName}";
file_put_contents($shell, "#!/bin/sh\necho {$shellName}-ran\nexit {$exitCode}\n");
chmod($shell, 0755);
$command = (fn () => $this->shellCommand())->call(new TerminalSessionService);
$process = new Process(['sh', '-c', $command], null, ['HOME' => $home, 'SHELL' => $shell]);
$process->setInput("echo fallback-ran\n");
$process->run();
return $process;
}
it('falls back to a usable shell when $SHELL is a login-disabling shell', function (string $shellName) {
$process = runTerminalShellCommand($shellName);
expect($process->getOutput())->toContain('fallback-ran')
->not->toContain("{$shellName}-ran");
expect($process->getExitCode())->toBe(0);
})->with(['nologin', 'false']);
it('starts $SHELL when it is a usable shell', function () {
$process = runTerminalShellCommand('zsh', exitCode: 0);
expect($process->getOutput())->toContain('zsh-ran');
expect($process->getExitCode())->toBe(0);
});
@@ -0,0 +1,79 @@
<?php
use Dotenv\Dotenv;
/**
* Runs the upgrade script's quote_root_user_env_values helper against a temporary .env file
* and returns the file contents after the repair.
*/
function repairRootUserEnvWithUpgradeScript(string $scriptPath, string $initialEnv): string
{
$script = file_get_contents(dirname(__DIR__, 2).'/'.$scriptPath);
preg_match('/^quote_env_value\(\) \{.*?^\}$/ms', $script, $quoteEnvValue);
preg_match('/^quote_root_user_env_values\(\) \{.*?^\}$/ms', $script, $quoteRootUserEnvValues);
expect($quoteEnvValue)->not->toBeEmpty()
->and($quoteRootUserEnvValues)->not->toBeEmpty();
$envFile = tempnam(sys_get_temp_dir(), 'coolify-upgrade-env-');
file_put_contents($envFile, $initialEnv);
$process = proc_open(
['bash', '-c', implode("\n", ['set -e', 'log() { :; }', 'ENV_FILE='.escapeshellarg($envFile), $quoteEnvValue[0], $quoteRootUserEnvValues[0], 'quote_root_user_env_values'])],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
$output = stream_get_contents($pipes[1]).stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
expect(proc_close($process))->toBe(0, $output);
$contents = file_get_contents($envFile);
unlink($envFile);
return $contents;
}
$upgradeScripts = ['stable upgrade' => 'scripts/upgrade.sh', 'nightly upgrade' => 'other/nightly/upgrade.sh'];
it('quotes unquoted root user credentials with spaces so that .env parses again', function (string $scriptPath) {
$env = repairRootUserEnvWithUpgradeScript($scriptPath, implode("\n", [
'APP_NAME=Coolify',
'ROOT_USERNAME=Jane Doe',
'ROOT_USER_EMAIL=admin@example.com',
"ROOT_USER_PASSWORD=it's a P@ss|&\\ \$HOME",
'APP_ENV=production',
'',
]));
expect(Dotenv::parse($env))->toBe([
'APP_NAME' => 'Coolify',
'ROOT_USERNAME' => 'Jane Doe',
'ROOT_USER_EMAIL' => 'admin@example.com',
'ROOT_USER_PASSWORD' => "it's a P@ss|&\\ \$HOME",
'APP_ENV' => 'production',
]);
})->with($upgradeScripts);
it('keeps root user credentials that already parse unchanged', function (string $scriptPath) {
$initialEnv = implode("\n", [
'ROOT_USERNAME="Jane Doe"',
"ROOT_USER_EMAIL='admin@example.com'",
'ROOT_USER_PASSWORD=',
'APP_NAME=My Coolify',
'',
]);
expect(repairRootUserEnvWithUpgradeScript($scriptPath, $initialEnv))->toBe($initialEnv);
})->with($upgradeScripts);
it('keeps root user credentials that only have whitespace around the value unchanged', function (string $scriptPath) {
$initialEnv = "ROOT_USER_EMAIL=admin@example.com\r\nROOT_USERNAME= Jane\r\nROOT_USER_PASSWORD=Secret1! \r\nAPP_ENV=production\r\n";
expect(repairRootUserEnvWithUpgradeScript($scriptPath, $initialEnv))->toBe($initialEnv);
})->with($upgradeScripts);
it('quotes a root username with spaces in a file with CRLF line endings', function (string $scriptPath) {
$env = repairRootUserEnvWithUpgradeScript($scriptPath, "ROOT_USERNAME=Jane Doe\r\nAPP_ENV=production\r\n");
expect(Dotenv::parse($env))->toBe(['ROOT_USERNAME' => 'Jane Doe', 'APP_ENV' => 'production']);
})->with($upgradeScripts);
+4
View File
@@ -65,6 +65,10 @@ function assertBackupSortOrders(mixed $page, string $screenshot): void
->click('Sort')
->click('Newest first')
->assertScript(visibleBackupOrderScript(), 'charlie-data,alpha-data,bravo-data')
->click('[aria-label^="Sort by target"]')
->assertScript(visibleBackupOrderScript(), 'alpha-data,bravo-data,charlie-data')
->click('[aria-label^="Sort by target"]')
->assertScript(visibleBackupOrderScript(), 'charlie-data,bravo-data,alpha-data')
->screenshot(filename: $screenshot);
}
+22
View File
@@ -110,6 +110,28 @@ it('submits the automatic two factor challenge only once', function () {
->screenshot(filename: 'login-two-factor-enter-single-submission');
});
it('shows the two factor code as separate digits', function () {
config(['app.maintenance.driver' => 'file']);
$user = createRootUser();
$user->forceFill([
'two_factor_secret' => Fortify::currentEncrypter()->encrypt('JBSWY3DPEHPK3PXP'),
'two_factor_confirmed_at' => now(),
])->save();
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertPathIs('/two-factor-challenge')
->screenshot(filename: 'login-two-factor-digits-empty');
$page->type('code', '123')
->assertScript("[...document.querySelectorAll('[data-code-digit]')].map((box) => box.textContent).join(',')", '1,2,3,,,')
->assertNoJavaScriptErrors()
->screenshot(filename: 'login-two-factor-digits-partial');
});
/**
* Create the root user (id 0) with known credentials for browser login tests.
*/
+24
View File
@@ -17,6 +17,9 @@ class FakeBrowserCityTrafficClient extends SentinelTrafficClient
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
'unique_visitors' => 320,
],
'/traffic/paths' => [
['path' => '/', 'requests' => 900, 'bytes_out' => 20000],
],
'/traffic/breakdown/country' => [
['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000],
['value' => 'FR', 'requests' => 400, 'bytes_out' => 10000],
@@ -65,3 +68,24 @@ it('switches the Locations card between countries and cities and remembers the s
->assertNoJavaScriptErrors()
->screenshot(filename: 'analytics-locations-cities');
});
// The lazy placeholder of this tab has the same sections as the loaded view. The Top paths
// row makes the loaded view start Alpine components, which a morph of the skeleton broke.
it('switches the Locations card between countries and cities on the application analytics tab', function () {
$application = createBrowserApplication($this->stack, ['uuid' => 'app-geo-cities', 'name' => 'Geo App']);
loginAndSkipBoarding();
$url = applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $application);
$page = visit("{$url}/analytics");
$page->assertSee('Locations')
->assertSee('Germany')
->assertDontSee('BE, Germany')
->click('button:visible:has-text("Cities")')
->assertSee('BE, Germany')
->assertSee('IDF, France')
->assertScript("Boolean(document.getElementById('application-analytics-globe')._trafficGlobe)")
->assertNoJavaScriptErrors()
->screenshot(filename: 'application-analytics-locations-cities');
});