diff --git a/.env.development.example b/.env.development.example index 56c17128ce..380f10a446 100644 --- a/.env.development.example +++ b/.env.development.example @@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444 BUNNY_API_KEY= # For asset uploads BUNNY_STORAGE_API_KEY= -AVATAR_CDN_URL= diff --git a/.env.windows-docker-desktop.example b/.env.windows-docker-desktop.example index 626d76ff63..b067b4c5c0 100644 --- a/.env.windows-docker-desktop.example +++ b/.env.windows-docker-desktop.example @@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify PUSHER_APP_ID=coolify PUSHER_APP_KEY=coolify PUSHER_APP_SECRET=coolify -AVATAR_CDN_URL= diff --git a/DESIGN.md b/DESIGN.md index a7b26bb666..53b058ddef 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -34,7 +34,9 @@ The interface is compact and product-focused: - near-neutral layered surfaces instead of large bordered boxes; - 13–14px UI typography and 32px controls; -- hairline rings instead of heavy borders; +- crisp hairline rings plus a restrained card lift (single 1px ring + + `0 1px 2px rgb(0 0 0 / 0.05)`) so cards and tables separate from the canvas, + never heavy borders or a strong floating shadow; - full-width data tables for dense collections; - outline Reicon glyphs through ``; - the Coolify purple brand accent in light mode; @@ -49,6 +51,19 @@ Avoid oversized titles, generic dashboard cards, strong shadows, thick dividers, native browser selects, and isolated colored buttons that do not match the current action styles. +Standard `.button` controls use a compact 2px bottom depth. Hover raises the +button face by 1px and increases the visible depth to 3px. Pressing moves the +face down 2px into the edge and removes the depth until release, keeping the +overall bottom position stable. Disabled controls stay flat, +and focus-visible controls retain the accent ring alongside the depth. +Movement and depth-shadow changes transition over 80ms; color transitions keep +the shared 120ms duration. +Standard button labels use `capitalize`, giving each word an initial capital. +Highlighted buttons mix the accent equally with black for a pronounced bottom +edge, so custom theme colors produce a matching edge instead of a generic one. +Dark mode matches the depth edge of neutral buttons to their regular border +color. Highlighted buttons keep their dark, color-matched accent edge. + --- ## 2. Development and cascade notes @@ -89,14 +104,14 @@ The surface ladder is defined in `resources/css/app.css`. | Token | Light | Dark | Use | |---|---|---|---| -| `--coollabs-canvas` | near white | 10% neutral | page canvas | +| `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) | | `--coollabs-elevated` | 98% neutral | 15% neutral | shells and card headers | | `--coollabs-base` | white | 17% neutral | nested card bodies | | `--coollabs-recessed` | 96% neutral | 20% neutral | inputs and listboxes | | `--coollabs-fill` | 92.2% neutral | 26.9% neutral | dividers and passive fills | | `--coollabs-line` | translucent dark | 32% neutral | control borders | -| `--coollabs-hairline` | 93.5% neutral | 26.9% neutral | shell rings | -| `--coollabs-subtle` | 55.6% neutral | 70.8% neutral | labels and muted titles | +| `--coollabs-hairline` | 85.5% neutral | 32% neutral | shell rings (crisp enough to read as a card edge, ~1.5:1) | +| `--coollabs-subtle` | 50% neutral | 70.8% neutral | labels and muted titles (light darkened for WCAG AA 4.5:1) | Accent behavior is intentionally theme-aware: @@ -116,6 +131,38 @@ dark:bg-warning/15 dark:text-warning dark:ring-warning/25 The filled top-level action/tab treatment uses the same palette at a restrained opacity rather than a fully saturated fill. +### Shell layering + +The app shell is three distinct surface layers, not one flat color. Chrome +lifts, content is the base, cards lift off the content: + +- **Content canvas** is the base layer: `bg-app` in dark (deepest, + `--color-app` `#0a0a0b`), `bg-gray-50` in light. The `
` content area + and page body use it. +- **Sidebar and topbar chrome** use `bg-panel` in dark (`--color-panel` + `#141418`, a clear step lighter than the content canvas) and `bg-white` in + light, so the chrome reads as a separate panel from the content. + +Dark surface tokens are hex, not oklch. oklch lightness compresses toward pure +black below ~15% (oklch(10%) renders as sRGB 3, oklch(15%) as sRGB 11), so oklch +values there give no visible step between layers. The dark ladder is +`--color-app` 10, `--coollabs-elevated` 22, `--coollabs-base` 28, +`--coollabs-recessed` 34 (sRGB), which reads as distinct surfaces. + +Temperature: every panel is **pure neutral gray** (r=g=b), one consistent +temperature across the sidebar, tables, cards, inputs, dividers, borders, and +text, in both modes. Do not give one surface a cool (blue) or warm cast while +the others stay neutral. The light page canvas uses `bg-neutral-50` (not +`bg-gray-50`, which is faintly cool) so it matches the neutral cards and chrome. +The only intentional color is the purple/yellow brand accent. +- **Cards, tables, and collection tiles** lift off the content canvas with + `dark:bg-white/[0.05]` plus the crisp `--coollabs-hairline` ring; in light + they are `bg-white` with the ring and the restrained card lift. + +Do not paint the content area with the same `bg-panel` as the sidebar, and do +not drop card fills below `dark:bg-white/[0.05]`; both make surfaces read as one +color. Row-hover states keep the lighter `dark:hover:bg-white/[0.025]`. + --- ## 4. Page shells and navigation @@ -419,6 +466,12 @@ The popup panel uses a 10px radius around 6px options with a 4px inset. Keep the option content left-aligned and size the panel to its content or trigger; do not create an unnecessarily wide menu. +Every dropdown, menu, listbox panel, and the command palette uses the shared +`--shadow-dropdown` token (`0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px +rgb(0 0 0 / 0.08)`) for a restrained, consistent lift. Do not hand-roll a +heavier `shadow-lg` / `0 18px 50px` / `0.45`-alpha drop shadow on a menu. +Reserve the stronger `--shadow-modal` for actual modals, dialogs, and toasts. + Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The selected option is indicated inside the menu, not repeated on the trigger. @@ -520,7 +573,10 @@ Do not restore the old full-width footer. Deferred fields in one Livewire component use one floating unsaved bar and one submit action. Do not add a separate “Save configuration” button to every card. Selectors that are safe to persist independently should use the existing -instant-save pattern. +instant-save pattern. When those requests share a component with a modal draft, +pass the unsaved bar a `dirty` Alpine expression comparing that draft with its +initial values, so unrelated saves do not hide pending changes. Mount modal save +bars only while the modal is open to avoid inactive keyboard shortcuts. --- @@ -567,6 +623,15 @@ that hide secondary columns before allowing horizontal overflow. --- +### Domain rows on mobile + +Domain tables become compact summary cards below 600px. Keep the public URL on +its own line, followed by a short routing summary such as `HTTP → HTTPS · Port +80 · Noindex`. Put DNS status and the existing icon actions on the final row. +Do not squeeze desktop label/value columns into a mobile card or move settings +behind an overflow menu. Long domains wrap, and icon actions retain 40px touch +targets. + ## 8. Modals, confirmations, and toasts ### Modals @@ -626,8 +691,9 @@ Current toast behavior: - Reicon status tile for success, info, warning, danger, or default; - title plus optional description; - dismiss and copy-details actions; -- up to four stacked notifications; +- normally up to four stacked notifications, without evicting persistent notices; - four-second dismissal, paused while hovered; +- `persistent: true` disables automatic dismissal, including after hover; users close these notices with the dismiss button; - support for all six screen positions and sanitized custom HTML. Do not bring back the old oversized dark rectangle. diff --git a/app/Actions/Stripe/CreateCheckoutSession.php b/app/Actions/Stripe/CreateCheckoutSession.php new file mode 100644 index 0000000000..42d14c0f25 --- /dev/null +++ b/app/Actions/Stripe/CreateCheckoutSession.php @@ -0,0 +1,221 @@ +stripe ??= app(StripeClient::class); + } + + public static function lockKey(int $teamId): string + { + return "stripe-checkout:team:{$teamId}"; + } + + public function execute(Team $team, User $user, string $priceId): object + { + $lock = Cache::lock(self::lockKey($team->id), 30); + + if (! $lock->get()) { + throw new CheckoutUnavailableException('A subscription checkout is already being created for this team.'); + } + + $previousMaxNetworkRetries = Stripe::getMaxNetworkRetries(); + Stripe::setMaxNetworkRetries(2); + + try { + return $this->createOrReuseSession($team, $user, $priceId); + } finally { + Stripe::setMaxNetworkRetries($previousMaxNetworkRetries); + $lock->release(); + } + } + + private function createOrReuseSession(Team $team, User $user, string $priceId): object + { + $subscription = Subscription::query()->firstOrNew(['team_id' => $team->id]); + $customerId = $subscription->stripe_customer_id; + + if (! $customerId) { + $customer = $this->stripe->customers->create([ + 'email' => $user->email, + 'metadata' => [ + 'team_id' => $team->id, + ], + ], [ + 'idempotency_key' => "coolify-team-{$team->id}-customer", + ]); + $customerId = $customer->id; + $subscription->stripe_customer_id = $customerId; + $subscription->save(); + + Log::info('Stripe customer assigned for subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + ]); + } + + $blockingSubscription = null; + foreach ($this->stripe->subscriptions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'all', + ])->autoPagingIterator() as $stripeSubscription) { + if (in_array($stripeSubscription->status, self::BLOCKING_SUBSCRIPTION_STATUSES, true)) { + $blockingSubscription = $stripeSubscription; + break; + } + } + + $this->throwIfBlockingSubscription($team, $customerId, $blockingSubscription); + + $sessions = $this->stripe->checkout->sessions->all([ + 'customer' => $customerId, + 'limit' => 10, + 'status' => 'open', + ]); + $subscriptionSessions = collect($sessions->data)->filter( + fn (object $session): bool => ($session->mode ?? null) === 'subscription' + ); + $openSession = $subscriptionSessions->first( + fn (object $session): bool => ($session->status ?? null) === 'open' + ); + + if ($openSession) { + $lineItems = $this->stripe->checkout->sessions->allLineItems($openSession->id); + if (count($lineItems->data) === 1 && data_get($lineItems, 'data.0.price.id') === $priceId) { + Log::info('Reusing pending Stripe subscription checkout.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $openSession->id, + 'stripe_subscription_id' => $openSession->subscription ?? null, + ]); + + return $openSession; + } + + $this->stripe->checkout->sessions->expire($openSession->id); + } + + $session = $this->stripe->checkout->sessions->create([ + 'allow_promotion_codes' => true, + 'billing_address_collection' => 'required', + 'client_reference_id' => $user->id.':'.$team->id, + 'customer' => $customerId, + 'customer_update' => [ + 'name' => 'auto', + 'address' => 'auto', + ], + 'line_items' => [[ + 'price' => $priceId, + 'adjustable_quantity' => [ + 'enabled' => true, + 'minimum' => 2, + ], + 'quantity' => 2, + ]], + 'tax_id_collection' => [ + 'enabled' => true, + ], + 'automatic_tax' => [ + 'enabled' => true, + ], + 'subscription_data' => [ + 'metadata' => [ + 'user_id' => $user->id, + 'team_id' => $team->id, + ], + ], + 'payment_method_collection' => 'if_required', + 'mode' => 'subscription', + 'expires_at' => now()->addMinutes(35)->timestamp, + 'success_url' => route('dashboard', ['success' => true]), + 'cancel_url' => route('subscription.index', ['cancelled' => true]), + ]); + + Log::info('Stripe subscription checkout created.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => $session->id, + 'stripe_subscription_id' => $session->subscription ?? null, + ]); + + return $session; + } + + private function throwIfBlockingSubscription(Team $team, string $customerId, ?object $blockingSubscription): void + { + if (! $blockingSubscription) { + return; + } + + Log::warning('Stripe subscription checkout blocked by existing subscription.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_subscription_id' => $blockingSubscription->id, + 'stripe_subscription_status' => $blockingSubscription->status, + ]); + + $portalUrl = in_array($blockingSubscription->status, self::RECOVERABLE_SUBSCRIPTION_STATUSES, true) + ? $this->billingPortalUrl($customerId) + : null; + + throw new CheckoutUnavailableException( + $this->blockingSubscriptionMessage($blockingSubscription->status), + $portalUrl, + ); + } + + private function blockingSubscriptionMessage(string $status): string + { + return match ($status) { + 'incomplete' => "This team's subscription payment is incomplete. Complete the payment in the billing portal.", + 'past_due' => "This team's subscription payment is past due. Update the payment method or settle the outstanding invoice in the billing portal.", + 'unpaid' => "This team's subscription is unpaid. Settle the outstanding invoice in the billing portal.", + 'paused' => "This team's subscription is paused. Resume it in the billing portal.", + default => 'Team already has an active subscription.', + }; + } + + private function billingPortalUrl(string $customerId): ?string + { + try { + $session = $this->stripe->billingPortal->sessions->create([ + 'customer' => $customerId, + 'return_url' => route('subscription.show'), + ]); + } catch (Throwable) { + return null; + } + + return is_string($session->url ?? null) ? $session->url : null; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 8eb010b8ac..79660c2490 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -5,6 +5,7 @@ namespace App\Console; use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; +use App\Jobs\CheckMissingDatabaseBackupsJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; @@ -53,6 +54,7 @@ class Kernel extends ConsoleKernel ->runInBackground(); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); + $this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer(); if (isDev()) { // Instance Jobs diff --git a/app/Exceptions/CheckoutUnavailableException.php b/app/Exceptions/CheckoutUnavailableException.php new file mode 100644 index 0000000000..329941dbc6 --- /dev/null +++ b/app/Exceptions/CheckoutUnavailableException.php @@ -0,0 +1,18 @@ +routeIs('login.store', 'two-factor.login.store') && $request->user()) { + return redirect()->intended(RouteServiceProvider::HOME); + } + // Handle authorization exceptions for API routes. Exceptions carrying // an explicit status (e.g. denyAsNotFound) keep it via parent::render. if ($e instanceof AuthorizationException && ! $e->hasStatus()) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 487d319e18..dbe0f86336 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -27,6 +27,7 @@ use App\Support\DomainPortOverrides; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -1455,7 +1456,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1719,7 +1722,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1950,7 +1955,9 @@ class ApplicationsController extends Controller $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts($dockerComposeDomainsJson); $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); + $application->domain_port_overrides = $domainPortOverrides; } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; @@ -3369,7 +3376,12 @@ class ApplicationsController extends Controller } if ($dockerComposeDomainsJson->count() > 0) { + [$dockerComposeDomainsJson, $domainPortOverrides] = $this->normalizeDockerComposeDomainPorts( + $dockerComposeDomainsJson, + $application->domain_port_overrides, + ); data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); + data_set($data, 'domain_port_overrides', $domainPortOverrides); } $requestHasNoindexDomains = $request->has('noindex_domains'); data_forget($data, 'noindex_domains'); @@ -6116,4 +6128,28 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Destination detached.']); } + + /** + * @param Collection $domains + * @param array|null $existingOverrides + * @return array{Collection, ?array} + */ + private function normalizeDockerComposeDomainPorts(Collection $domains, ?array $existingOverrides = null): array + { + $allDomains = $domains + ->pluck('domain') + ->filter() + ->implode(','); + $normalized = DomainPortOverrides::normalize($allDomains, $existingOverrides); + + $domains = $domains->map(function (array $entry): array { + $entry['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain'] ?? null)) + ->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain)) + ->implode(','); + + return $entry; + }); + + return [$domains, $normalized['overrides']]; + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index aeb69ac8b4..881aa5a897 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -769,6 +769,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0], ], ), ) @@ -805,7 +806,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -833,6 +834,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { @@ -1025,6 +1027,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'], 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], + 'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365], ], ), ) @@ -1054,7 +1057,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1080,6 +1083,7 @@ class DatabasesController extends Controller 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'numeric|min:0', 'timeout' => 'integer|min:60|max:36000', + 'missing_backup_notification_days' => 'integer|min:0|max:365', ]); if ($validator->fails()) { return response()->json([ @@ -4885,6 +4889,8 @@ class DatabasesController extends Controller 'id', 'created_at', 'updated_at', + 'last_execution_at', + 'missing_backup_notification_sent_at', ])->fill([ 'uuid' => new_public_id(), 'database_id' => $newDatabase->id, diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index e8446467de..5bf51bc027 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -9,6 +9,7 @@ use App\Actions\Service\UpdateServiceApplicationFromApi; use App\Http\Controllers\Controller; use App\Models\Service; use App\Models\ServiceApplication; +use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -333,7 +334,7 @@ class ServiceApplicationsController extends Controller ]; $validationRules = [ - 'url' => 'nullable|string', + 'url' => ValidationPatterns::applicationDomainRules(), 'noindex_domains' => 'sometimes|array|nullable', 'noindex_domains.*' => 'string', 'human_name' => 'nullable|string|max:255', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 9bfcfd8539..1a199c07ec 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -386,7 +386,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -602,7 +602,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', 'tags' => 'array|nullable', @@ -1187,7 +1187,7 @@ class ServicesController extends Controller 'urls' => 'array|nullable', 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', - 'urls.*.url' => 'string|nullable', + 'urls.*.url' => ValidationPatterns::applicationDomainRules(), 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', ]; diff --git a/app/Http/Controllers/ProfileAvatarController.php b/app/Http/Controllers/ProfileAvatarController.php index 2cf01400e8..f53cef7c51 100644 --- a/app/Http/Controllers/ProfileAvatarController.php +++ b/app/Http/Controllers/ProfileAvatarController.php @@ -14,7 +14,7 @@ class ProfileAvatarController extends Controller return response($contents, 200, [ 'Content-Type' => 'image/jpeg', - 'Cache-Control' => 'private, max-age=300', + 'Cache-Control' => 'private, max-age=31536000, immutable', ]); } } diff --git a/app/Http/Controllers/ProjectIconController.php b/app/Http/Controllers/ProjectIconController.php index fb7ebc8860..d99e9166d0 100644 --- a/app/Http/Controllers/ProjectIconController.php +++ b/app/Http/Controllers/ProjectIconController.php @@ -15,6 +15,9 @@ class ProjectIconController extends Controller abort_if($contents === null, 404); - return response($contents)->header('Content-Type', 'image/jpeg'); + return response($contents, 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=31536000, immutable', + ]); } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 28e92dcd49..c4fdc5fd5c 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -83,7 +83,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $this->manualWebhookApplications($applications, $full_name); if ($applications->isEmpty()) { return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'."); } @@ -334,7 +337,10 @@ class Github extends Controller } } if ($x_github_event === 'pull_request') { - $applications = $applications->where('git_branch', $base_branch)->get(); + if ($action !== 'closed') { + $applications->where('git_branch', $base_branch); + } + $applications = $applications->get(); if ($applications->isEmpty()) { return response("Nothing to do. No applications found with branch '$base_branch'."); } diff --git a/app/Jobs/CheckMissingDatabaseBackupsJob.php b/app/Jobs/CheckMissingDatabaseBackupsJob.php new file mode 100644 index 0000000000..06ba79a33f --- /dev/null +++ b/app/Jobs/CheckMissingDatabaseBackupsJob.php @@ -0,0 +1,59 @@ +with(['team', 'database', 'latest_log']) + ->where('enabled', true) + ->where('missing_backup_notification_days', '>', 0) + ->chunkById(100, function ($backups): void { + foreach ($backups as $backup) { + $this->notifyIfMissing($backup); + } + }); + } + + private function notifyIfMissing(ScheduledDatabaseBackup $backup): void + { + $lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at; + $lastActivityAt = $lastExecutionAt ?? $backup->created_at; + + if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) { + return; + } + + if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) { + return; + } + + if (! $backup->team) { + Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found"); + + return; + } + + if ($backup->team->getEnabledChannels('backup_failure') === []) { + return; + } + + $backup->team->notify(new BackupMissing($backup, $lastExecutionAt)); + $backup->forceFill(['missing_backup_notification_sent_at' => now()])->save(); + } +} diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index 6ddbfe145c..0f56476e25 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -74,7 +74,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::updateOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -83,6 +83,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_past_due' => false, ] ); + logger()->info('Stripe subscription checkout completed.', [ + 'team_id' => $team->id, + 'stripe_customer_id' => $customerId, + 'stripe_checkout_session_id' => data_get($data, 'id'), + 'stripe_subscription_id' => $subscription->stripe_subscription_id, + ]); break; case 'invoice.paid': $customerId = data_get($data, 'customer'); @@ -218,7 +224,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } - Subscription::updateOrCreate( + $subscription = Subscription::firstOrCreate( ['team_id' => $teamId], [ 'stripe_subscription_id' => $subscriptionId, @@ -226,6 +232,11 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue 'stripe_invoice_paid' => false, ] ); + if (! $subscription->stripe_subscription_id && $subscription->stripe_customer_id === $customerId) { + $subscription->update(['stripe_subscription_id' => $subscriptionId]); + } elseif ($subscription->stripe_customer_id !== $customerId) { + throw new \RuntimeException("Stripe customer ID mismatch for team {$teamId}: stored {$subscription->stripe_customer_id}, event {$customerId}."); + } break; case 'customer.subscription.updated': $teamId = data_get($data, 'metadata.team_id'); diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php index b567a71b7f..0b13c66609 100644 --- a/app/Jobs/VolumeBackupJob.php +++ b/app/Jobs/VolumeBackupJob.php @@ -73,6 +73,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $filename = str($this->backup->targetType())->lower().'-'.str($this->backup->targetName())->slug().'-'.Carbon::now()->timestamp.'.tar.gz'; $backupLocation = $backupDirectory.'/'.$filename; $this->execution->update(['filename' => $backupLocation]); + $streamToS3 = $this->backup->save_s3 && $this->backup->disable_local_backup; try { $source = $this->backup->sourcePath(); @@ -86,11 +87,17 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage); $archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume ."; - $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) - .' -v '.escapeshellarg($source.':/volume:ro') - .' '.escapeshellarg($image) - .' sh -c '.escapeshellarg($archiveScript) - .' > '.escapeshellarg($backupLocation); + if ($streamToS3) { + $this->execution->update(['local_storage_deleted' => true]); + $archiveCommand = $this->streamToS3Command($archiveScript, $backupLocation, $source, $containerName, $image); + $this->execution->update(['s3_cleanup_pending' => true]); + } else { + $archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($archiveScript) + .' > '.escapeshellarg($backupLocation); + } if ($this->backup->stop_during_backup) { $containers = $this->containersUsingVolume($source, $server); @@ -104,21 +111,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } - instant_remote_process([ + $archiveOutput = instant_remote_process(array_filter([ $verifySourceCommand, - 'mkdir -p '.escapeshellarg($backupDirectory), + $streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory), $archiveCommand, - ], $server, timeout: $this->timeout, disableMultiplexing: true); + ]), $server, timeout: $this->timeout, disableMultiplexing: true); $this->execution->update([ 'stop_container_ids' => null, 'stop_recovery_pending' => false, ]); - $size = (int) instant_remote_process( - ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], - $server, - disableMultiplexing: true, - ); + $size = $streamToS3 + ? (int) str($archiveOutput)->trim()->afterLast("\n")->toString() + : (int) instant_remote_process( + ['du -b '.escapeshellarg($backupLocation).' | cut -f1'], + $server, + disableMultiplexing: true, + ); if ($size <= 0) { throw new \RuntimeException('The storage backup archive is empty or was not created.'); @@ -127,9 +136,12 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $warning = null; $s3Uploaded = null; $s3CleanupPending = false; - $localStorageDeleted = false; + $localStorageDeleted = $streamToS3; - if ($this->backup->save_s3) { + if ($streamToS3) { + $s3Uploaded = true; + $this->execution->update(['s3_cleanup_pending' => false]); + } elseif ($this->backup->save_s3) { $s3CleanupPending = true; $this->execution->update(['s3_cleanup_pending' => true]); @@ -181,13 +193,23 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } catch (Throwable $exception) { $recoveryError = $this->recoverIncompleteBackup($this->execution); - $archiveDeleted = false; + $archiveDeleted = $streamToS3; - try { - deleteBackupsLocally($backupLocation, $server, throwError: true); - $archiveDeleted = true; - } catch (Throwable $cleanupException) { - $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + if ($streamToS3) { + $exception = new \RuntimeException( + 'S3-only streaming backup failed: '.$exception->getMessage() + .'. The S3 destination may not support streaming uploads. Enable local backups to use the local archive upload method.', + previous: $exception, + ); + } + + if (! $streamToS3) { + try { + deleteBackupsLocally($backupLocation, $server, throwError: true); + $archiveDeleted = true; + } catch (Throwable $cleanupException) { + $recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage(); + } } $s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending; @@ -195,7 +217,9 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue $this->execution->update([ 'status' => 'failed', 'message' => $exception->getMessage().$recoveryError, - 'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation, + 'filename' => $streamToS3 + ? ($s3CleanupPending ? $backupLocation : null) + : ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation), 'local_storage_deleted' => $archiveDeleted, ]); @@ -338,6 +362,34 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function streamToS3Command(string $archiveScript, string $backupLocation, string $source, string $containerName, string $image): string + { + $s3 = $this->backup->s3; + + if (! $s3) { + $this->backup->update(['save_s3' => false, 's3_storage_id' => null]); + + throw new \RuntimeException('The selected S3 storage no longer exists. S3 backup has been disabled.'); + } + + $s3->testConnection(shouldSave: true); + $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts())) + ->map(fn (string $option): string => '--resolve '.escapeshellarg($option)) + ->implode(' '); + $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions; + $destination = 'temporary/'.$s3->bucket.$backupLocation; + $streamScript = 'set -o pipefail; mc alias set'.$resolveOptions.' temporary ' + .escapeshellarg($s3->endpoint).' '.escapeshellarg($s3->key).' '.escapeshellarg($s3->secret) + .' >/dev/null && ('.$archiveScript.' | mc pipe --quiet'.$resolveOptions.' '.escapeshellarg($destination).' >/dev/null)' + .' && mc stat --json'.$resolveOptions.' '.escapeshellarg($destination) + .' | sed -n '.escapeshellarg('s/.*"size":\([0-9][0-9]*\).*/\1/p'); + + return 'docker run --rm --name '.escapeshellarg($containerName) + .' -v '.escapeshellarg($source.':/volume:ro') + .' '.escapeshellarg($image) + .' sh -c '.escapeshellarg($streamScript); + } + private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void { if (! isDev()) { diff --git a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php index 44dba0d5e8..6ecea8e96f 100644 --- a/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php +++ b/app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php @@ -209,19 +209,20 @@ trait InteractsWithCloudflareDomainConnect } } - // Prefer instance public IPv6 when the destination IP is IPv4-only (and vice versa). - try { - $settings = instanceSettings(); - $publicV4 = data_get($settings, 'public_ipv4'); - $publicV6 = data_get($settings, 'public_ipv6'); - if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $ipv4 = $publicV4; + if ($this->usesInstanceNetworkAddressesForDnsHints()) { + try { + $settings = instanceSettings(); + $publicV4 = data_get($settings, 'public_ipv4'); + $publicV6 = data_get($settings, 'public_ipv6'); + if ($ipv4 === null && is_string($publicV4) && filter_var($publicV4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipv4 = $publicV4; + } + if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipv6 = $publicV6; + } + } catch (\Throwable) { + // } - if ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $ipv6 = $publicV6; - } - } catch (\Throwable) { - // } return [$ipv4, $ipv6]; @@ -253,5 +254,7 @@ trait InteractsWithCloudflareDomainConnect return null; } + abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool; + abstract protected function authorizeUpdateForDomainConnect(): void; } diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index 45e284c5dc..a9e1c0be28 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -27,12 +27,6 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitShallowCloneEnabled = false; - #[Validate(['boolean'])] - public bool $isPreviewDeploymentsEnabled = false; - - #[Validate(['boolean'])] - public bool $isPrDeploymentsPublicEnabled = false; - #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -107,8 +101,6 @@ class Advanced extends Component $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; - $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; - $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -136,8 +128,6 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; - $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; - $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index d7797935f5..906cc147a0 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -150,7 +150,15 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && ($row['service'] ?? null) === ($editingRow['service'] ?? null)); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -227,7 +235,9 @@ class Domains extends Component $this->isCompose = $this->application->build_pack === 'dockercompose'; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; - $this->redirect = $this->application->redirect ?? 'both'; + if ($this->pendingAction !== 'redirect' || $this->isCompose) { + $this->redirect = $this->application->redirect ?? 'both'; + } $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); $settings = instanceSettings(); @@ -254,6 +264,9 @@ class Domains extends Component } $this->composeServices = []; + $pendingRedirect = $this->pendingRedirectService !== null + ? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null) + : null; $this->serviceRedirects = []; if ($this->isCompose) { try { @@ -290,7 +303,9 @@ class Domains extends Component $serviceEntry = $domains[$serviceName] ?? null; $storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null; $this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect( - is_string($storedRedirect) ? $storedRedirect : null + $this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService + ? $pendingRedirect + : (is_string($storedRedirect) ? $storedRedirect : null) ); } } @@ -554,13 +569,6 @@ class Domains extends Component ]; } - if ($this->application->settings?->is_static) { - return [ - 'internal_port' => 80, - 'has_port_override' => false, - ]; - } - $composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service); if ($composePort !== null) { return [ @@ -569,6 +577,20 @@ class Domains extends Component ]; } + if ($this->isCompose && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + + if ($this->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + $exposed = $this->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] @@ -606,7 +628,7 @@ class Domains extends Component return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - protected function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -615,7 +637,7 @@ class Domains extends Component return false; } - return $this->application->portRequiresConfirmation($port); + return $this->application->portRequiresConfirmation($port, $serviceName); } protected function openPortWarning(?int $port, string $action): void @@ -661,6 +683,11 @@ class Domains extends Component $this->authorize('update', $this->application); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->application->destination?->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->application); @@ -961,7 +988,13 @@ class Domains extends Component return; } + $this->authorize('update', $this->application); + $wasRedirect = $this->pendingAction === 'redirect'; $this->pendingAction = null; + $this->pendingRedirectService = null; + if ($wasRedirect) { + $this->refreshDomains(); + } } public function addDomain(): void @@ -1006,7 +1039,7 @@ class Domains extends Component } } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -1403,7 +1436,7 @@ class Domains extends Component return; } - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 8f0bb2385d..d25bbab374 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -145,7 +145,9 @@ class General extends Component return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), - 'fqdn' => ValidationPatterns::applicationDomainRules(), + 'fqdn' => isset($this->application) && $this->fqdn === $this->application->fqdn + ? ['nullable'] + : ValidationPatterns::applicationDomainRules(), 'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(), 'gitRepository' => 'required', 'gitBranch' => ['required', 'string', new ValidGitBranch], @@ -757,8 +759,11 @@ class General extends Component $oldDockerComposeLocation = $this->initialDockerComposeLocation; $oldBaseDirectory = $this->application->base_directory; - // Process FQDN with intermediate variable to avoid Collection/string confusion - $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); + $fqdnChanged = $this->fqdn !== $this->application->fqdn; + if ($fqdnChanged) { + $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); + } + $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index e6e9f5d60f..cba3f18b65 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -38,6 +38,7 @@ class PreviewDomains extends Component public function mount(): void { + $this->authorize('view', $this->preview->application); $this->refreshDomains(); if ($this->preview->application->build_pack === 'dockercompose') { $this->newDomainService = $this->composeServices()[0] ?? null; @@ -74,7 +75,7 @@ class PreviewDomains extends Component return; } - if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts))) { + if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) { $this->openPortWarning($this->portFromParts($this->newDomainParts), 'add'); return; @@ -94,7 +95,7 @@ class PreviewDomains extends Component ? ($this->composeServices()[0] ?? null) : null; $this->forceUseUnknownPort = false; - $this->dispatch('close-modal'); + $this->dispatch('close-preview-domain-add', previewId: $this->preview->id); try { $server = $this->preview->application->destination?->server; @@ -149,6 +150,7 @@ class PreviewDomains extends Component public function startEdit(int $index): void { + $this->authorize('update', $this->preview->application); if (! isset($this->domainRows[$index])) { return; } @@ -159,7 +161,8 @@ class PreviewDomains extends Component if (filled($savedPort)) { $this->editingDomainParts['port'] = (string) $savedPort; } - $this->dispatch('open-preview-domain-edit'); + $this->resetErrorBag('editingDomainParts.host'); + $this->dispatch('open-preview-domain-edit', previewId: $this->preview->id); } public function updateDomain(): void @@ -173,7 +176,7 @@ class PreviewDomains extends Component return; } $oldUrl = $this->domainRows[$this->editingIndex]['url']; - if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl))) { + if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) { $this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update'); return; @@ -193,7 +196,7 @@ class PreviewDomains extends Component return; } $this->forceUseUnknownPort = false; - $this->dispatch('close-preview-domain-edit'); + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); $this->dispatch('success', 'Domain updated.'); $this->checkDomainDns($index); } @@ -229,6 +232,12 @@ class PreviewDomains extends Component if (! isset($this->domainRows[$index])) { return; } + if ($this->editingIndex === $index) { + $this->editingIndex = null; + $this->dispatch('close-preview-domain-edit', previewId: $this->preview->id); + } elseif ($this->editingIndex !== null && $this->editingIndex > $index) { + $this->editingIndex--; + } unset($this->domainRows[$index]); $this->domainRows = array_values($this->domainRows); if (! $this->persistDomains()) { @@ -268,6 +277,7 @@ class PreviewDomains extends Component public function pollDnsChecks(): void { + $this->authorize('view', $this->preview->application); $checkingRows = collect($this->domainRows) ->where('dns_status', 'checking') ->values(); @@ -321,6 +331,7 @@ class PreviewDomains extends Component private function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; $this->preview->refresh(); $statuses = $this->preview->domain_dns_statuses ?? []; $rows = []; @@ -336,6 +347,11 @@ class PreviewDomains extends Component } } $this->domainRows = $rows; + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && $row['service'] === $editingRow['service']); + $this->editingIndex = $index === false ? null : (int) $index; + } } private function persistDomains(): bool @@ -349,13 +365,16 @@ class PreviewDomains extends Component return false; } - $domains = collect($composeServices) - ->mapWithKeys(fn (string $service): array => [$service => ['domain' => '']]) - ->all(); + $existingDomains = json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: []; + $domains = []; + foreach ($composeServices as $service) { + $domains[$service] = is_array($existingDomains[$service] ?? null) ? $existingDomains[$service] : []; + $domains[$service]['domain'] = ''; + } $validRows = collect($this->domainRows) ->filter(fn (array $row): bool => in_array($row['service'] ?? null, $composeServices, true)); foreach ($validRows->groupBy('service') as $service => $rows) { - $domains[$service] = ['domain' => $rows->pluck('url')->implode(',')]; + $domains[$service]['domain'] = $rows->pluck('url')->implode(','); } $this->preview->docker_compose_domains = json_encode($domains); $this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null; @@ -440,10 +459,22 @@ class PreviewDomains extends Component { $status = $statuses[$this->statusKey($url, $service)] ?? []; $port = $this->effectiveDomainInternalPort($url, $service); + $redirect = 'both'; + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + $usesPreviewRedirect = (int) $this->preview->application->compose_parsing_version >= 3; + $domains = json_decode(($usesPreviewRedirect + ? $this->preview->docker_compose_domains + : $this->preview->application->docker_compose_domains) ?: '[]', true) ?: []; + $storedRedirect = $usesPreviewRedirect + ? ($domains[$service]['redirect'] ?? null) + : data_get($domains, "$service.redirect"); + $redirect = in_array($storedRedirect, ['www', 'non-www', 'both'], true) ? $storedRedirect : 'both'; + } return [ 'url' => $url, 'service' => $service, + 'redirect' => $redirect, 'internal_port' => $port['internal_port'], 'has_port_override' => $port['has_port_override'], 'dns_status' => $status['status'] ?? 'pending', @@ -478,7 +509,7 @@ class PreviewDomains extends Component return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null; } - private function shouldConfirmPort(?int $port, ?int $currentPort = null): bool + private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool { if ($this->forceUseUnknownPort || $port === null) { return false; @@ -487,7 +518,7 @@ class PreviewDomains extends Component return false; } - return $this->preview->application->portRequiresConfirmation($port); + return $this->preview->application->portRequiresConfirmation($port, $serviceName); } private function openPortWarning(?int $port, string $action): void @@ -522,13 +553,6 @@ class PreviewDomains extends Component ]; } - if ($this->preview->application->settings?->is_static) { - return [ - 'internal_port' => 80, - 'has_port_override' => false, - ]; - } - $composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service); if ($composePort !== null) { return [ @@ -537,6 +561,20 @@ class PreviewDomains extends Component ]; } + if ($this->preview->application->build_pack === 'dockercompose' && $service !== null) { + return [ + 'internal_port' => null, + 'has_port_override' => false, + ]; + } + + if ($this->preview->application->settings?->is_static) { + return [ + 'internal_port' => 80, + 'has_port_override' => false, + ]; + } + $exposed = $this->preview->application->ports_exposes_array; $defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0 ? (int) $exposed[0] diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index fa0272bd64..acc7dc8608 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -19,6 +19,10 @@ class Previews extends Component public Application $application; + public bool $isPreviewDeploymentsEnabled = false; + + public bool $isPrDeploymentsPublicEnabled = false; + public string $deployment_uuid; public array $parameters; @@ -41,11 +45,37 @@ class Previews extends Component public function mount() { + $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->pull_requests = collect(); $this->parameters = get_route_parameters(); $this->syncDockerTags(); } + public function savePreviewSettings(): void + { + $this->authorize('update', $this->application); + $this->validate([ + 'isPreviewDeploymentsEnabled' => 'boolean', + 'isPrDeploymentsPublicEnabled' => 'boolean', + ]); + + $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; + $this->application->settings->save(); + + $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); + } + + public function togglePreviewDeployments(): void + { + $this->authorize('update', $this->application); + + $this->isPreviewDeploymentsEnabled = ! $this->isPreviewDeploymentsEnabled; + $this->savePreviewSettings(); + } + private function syncDockerTags(): void { $this->previewDockerTags = []; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 28f7e02556..4a709d2b96 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -85,6 +85,9 @@ class BackupEdit extends Component #[Validate(['required', 'int', 'min:60', 'max:36000'])] public int|string $timeout = 3600; + #[Validate(['required', 'integer', 'min:0', 'max:365'])] + public int $missingBackupNotificationDays = 0; + public function getListeners(): array { // Keep "Backup Now" in sync when the database starts/stops without a full page refresh. @@ -152,6 +155,7 @@ class BackupEdit extends Component $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; $this->backup->timeout = $this->timeout; + $this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays; $this->customValidate(); $this->backup->save(); } else { @@ -170,6 +174,7 @@ class BackupEdit extends Component $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; $this->timeout = $this->backup->timeout; + $this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days; } } @@ -237,6 +242,14 @@ class BackupEdit extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + $this->status = $database->status; + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..8c83e33556 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -17,6 +17,13 @@ class BackupNow extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index b4236b215a..96d2ac7aaf 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($database->getMorphClass() === ServiceDatabase::class) { $service = $database->service; - $this->redirectRoute('project.service.database.backup.show', [ + $this->redirectRoute('project.service.volume-backups.index', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 87f24fb1da..6d7fb97f68 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -9,16 +9,21 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; +use Livewire\WithPagination; class BackupExecutions extends Component { use AuthorizesRequests; + use WithPagination; public Service $service; + public int $perPage = 10; + public bool $executionModalOpen = false; public ?array $selectedExecution = null; @@ -40,10 +45,18 @@ class BackupExecutions extends Component $this->authorize('view', $this->service); } + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage('executionsPage'); + } + public function openExecution(string $executionUuid): void { - $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); - abort_unless($this->selectedExecution, 404); + $this->authorize('view', $this->service); + $execution = $this->executionQuery($executionUuid)->first(); + abort_unless($execution, 404); + $this->selectedExecution = $this->formatExecutions(collect([$execution]))->first(); $this->executionModalOpen = true; } @@ -55,70 +68,89 @@ class BackupExecutions extends Component public function render(): View { + $this->authorize('view', $this->service); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + if ($executions->currentPage() > $executions->lastPage()) { + $this->setPage($executions->lastPage(), 'executionsPage'); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + } + $executions->setCollection($this->formatExecutions($executions->getCollection())); + return view('livewire.project.service.backup-executions', [ - 'executions' => $this->executions(), + 'executions' => $executions, ]); } - private function executions(): Collection + private function executionQuery(?string $uuid = null): Builder { $databaseScheduleIds = ScheduledDatabaseBackup::query() ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) - ->pluck('id'); + ->select('id'); + $volumeScheduleIds = ScheduledVolumeBackup::query() + ->forService($this->service) + ->select('id'); $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with('scheduledDatabaseBackup.database') + ->select('id', 'uuid', 'created_at') + ->selectRaw("'database' as type") ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) - ->latest() - ->limit(100) - ->get() - ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ - 'id' => 'database:'.$execution->id, + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'storage' as type") + ->whereIn('scheduled_volume_backup_id', $volumeScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + + return $databaseExecutions->toBase() + ->unionAll($volumeExecutions->toBase()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->orderBy('type'); + } + + private function formatExecutions(Collection $rows): Collection + { + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->whereIn('id', $rows->where('type', 'database')->pluck('id')) + ->get()->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) + ->get()->keyBy('id'); + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $isDatabase = $row->type === 'database'; + $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); + $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; + $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; + if ($storage?->team_id !== currentTeam()->id) { + $storage = null; + } + $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + if ($isDatabase && ! $schedule->save_s3) { + $storageLabel = 'Not configured'; + } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + $storageLabel = 'No destination recorded'; + } + + return [ + 'id' => $row->type.':'.$execution->id, 'uuid' => $execution->uuid, - 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, - 'type' => 'Database', - 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(), + 'type' => $isDatabase ? 'Database' : $schedule->targetType(), + 'schedule' => $schedule->frequency, + 's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel, 'status' => $execution->status, 'started_at' => $execution->created_at, 'size' => $execution->size, 'message' => $execution->message, 'filename' => $execution->filename, 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.backup', $execution->id) + ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) : null, - ]); - - $volumeSchedules = ScheduledVolumeBackup::query() - ->with('backupable.resource') - ->forService($this->service) - ->get() - ->keyBy('id'); - $volumeExecutions = ScheduledVolumeBackupExecution::query() - ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) - ->latest() - ->limit(100) - ->get() - ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { - $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); - - return [ - 'id' => 'storage:'.$execution->id, - 'uuid' => $execution->uuid, - 'target' => $schedule->targetName(), - 'type' => $schedule->targetType(), - 'schedule' => $schedule->frequency, - 'status' => $execution->status, - 'started_at' => $execution->created_at, - 'size' => $execution->size, - 'message' => $execution->message, - 'filename' => $execution->filename, - 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.volume-backup', $execution->id) - : null, - ]; - }); - - return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + ]; + }); } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index d5254e093a..b4f76d12d9 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -129,9 +129,17 @@ class Domains extends Component public function refreshDomains(): void { + $editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null; + $this->service->refresh(); $this->service->load(['applications', 'server']); $this->loadDomainState(); + + if ($editingRow !== null) { + $index = collect($this->domainRows)->search(fn (array $row): bool => $row['url'] === $editingRow['url'] + && (int) $row['service_application_id'] === (int) $editingRow['service_application_id']); + $this->editingIndex = $index === false ? null : (int) $index; + } } public function pollDnsChecks(): void @@ -239,9 +247,14 @@ class Domains extends Component ]) ->all(); + $pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null; $this->serviceRedirects = []; foreach ($this->service->applications as $app) { - $this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null); + $this->serviceRedirects[$app->id] = $this->normalizeRedirect( + $this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId + ? $pendingRedirect + : $app->redirect + ); } if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) { @@ -459,6 +472,11 @@ class Domains extends Component $this->authorize('update', $this->service); } + protected function usesInstanceNetworkAddressesForDnsHints(): bool + { + return $this->service->server?->id === 0; + } + public function checkAllDns(): void { $this->authorize('update', $this->service); @@ -919,6 +937,7 @@ class Domains extends Component } $toAdd = collect(); + $portOverrides = $app->domain_port_overrides ?? []; foreach ($current as $url) { $counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true); if ($counterpart === null) { @@ -935,6 +954,11 @@ class Domains extends Component continue; } + $port = $this->effectiveDomainInternalPort($url, $app); + if ($port['has_port_override']) { + $portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port']; + } + $knownHosts[$hostKey] = true; $toAdd->push($counterpart); } @@ -943,12 +967,13 @@ class Domains extends Component return true; } + $app->domain_port_overrides = $portOverrides ?: null; $merged = $current->merge($toAdd)->unique()->values(); $this->pendingAction = 'redirect'; $this->pendingRedirectServiceApplicationId = $app->id; - // Skip DNS: pairing for redirects must still be configured even when DNS is not ready. - if (! $this->saveDomainListForApp($app, $merged)) { + // Counterparts inherit an existing port, so only domain conflicts need confirmation. + if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) { return false; } @@ -975,11 +1000,24 @@ class Domains extends Component return; } + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $this->setServiceRedirect($this->pendingRedirectServiceApplicationId); + + return; + } + $this->addDomain(); } public function cancelRemovePort(): void { + $this->authorize('update', $this->service); + + if ($this->pendingAction === 'redirect' && $this->pendingRedirectServiceApplicationId) { + $app = $this->findServiceApp($this->pendingRedirectServiceApplicationId); + $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] = $this->normalizeRedirect($app?->redirect); + } + $this->pendingRedirectServiceApplicationId = null; $this->showPortWarningModal = false; $this->forceSaveDomains = false; $this->forceRemovePort = false; @@ -1416,6 +1454,7 @@ class Domains extends Component ServiceApplication $app, Collection $domains, bool $checkConflicts = true, + bool $checkPorts = true, ): bool { $domainString = $domains->filter()->unique()->implode(','); $domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString); @@ -1442,7 +1481,7 @@ class Domains extends Component } } - if (! $this->forceRemovePort) { + if ($checkPorts && ! $this->forceRemovePort) { $requiredPort = $app->getRequiredPort(); if ($requiredPort !== null && $domainString) { $previousFqdn = $app->getOriginal('fqdn'); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 7980e07056..3b8ca7af19 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -307,36 +307,48 @@ class Index extends Component public function instantSave() { + $this->authorize('update', $this->serviceDatabase); try { - $this->authorize('update', $this->serviceDatabase); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncDatabaseData(true); - if ($this->serviceDatabase->is_public) { - if (! str($this->serviceDatabase->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + if ($this->isPublic) { + if (! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; - $this->serviceDatabase->is_public = false; return; } + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + + return; + } + $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { + $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { + $this->isPublic = ! $this->isPublic; + $this->persistPublicAccess(); + return handleError($e, $this); } } + private function persistPublicAccess(): void + { + $this->serviceDatabase->update([ + 'is_public' => $this->isPublic, + 'public_port' => $this->publicPort ?: null, + 'public_port_timeout' => $this->publicPortTimeout ?: null, + ]); + } + public function submitDatabase() { try { diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index e856d1373a..600b8b9047 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -11,6 +11,7 @@ use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component @@ -23,6 +24,9 @@ class Index extends Component public string $search = ''; + #[Url(as: 'backup_uuid', except: '')] + public string $backupUuid = ''; + public bool $scheduleModalOpen = false; public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; @@ -38,6 +42,7 @@ class Index extends Component return [ 'refreshVolumeBackups' => '$refresh', 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},ServiceChecked" => '$refresh', "echo-private:team.{$teamId},BackupCreated" => '$refresh', ]; } @@ -49,10 +54,14 @@ class Index extends Component $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + if ($this->backupUuid !== '') { + $this->openSchedule($this->backupUuid); + } } public function openSchedule(string $backupUuid): void { + $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); $this->s3s = currentTeam()->s3s; $this->scheduleModalOpen = true; @@ -60,6 +69,7 @@ class Index extends Component public function closeScheduleModal(): void { + $this->backupUuid = ''; $this->scheduleModalOpen = false; $this->selectedDatabaseBackup = null; $this->selectedVolumeBackup = null; @@ -72,6 +82,12 @@ class Index extends Component $this->loadSelectedSchedule($backupUuid); abort_unless($this->selectedDatabaseBackup, 404); $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) { + $this->selectedDatabaseBackup = null; + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); } else { abort_unless($type === 'storage', 404); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 8350cffa4c..d42184f650 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -299,6 +299,7 @@ class Show extends Component $this->syncData(true); $this->syncData(false); $this->dispatch('success', 'Environment variable updated.'); + $this->dispatch('environment-variable-updated', envId: $this->env->id); $this->dispatch('envsUpdated'); $this->dispatch('configurationChanged'); diff --git a/app/Livewire/Project/Shared/Storages/VolumeBackups.php b/app/Livewire/Project/Shared/Storages/VolumeBackups.php index a10eb5ad03..a8e2d72df1 100644 --- a/app/Livewire/Project/Shared/Storages/VolumeBackups.php +++ b/app/Livewire/Project/Shared/Storages/VolumeBackups.php @@ -147,7 +147,11 @@ class VolumeBackups extends Component } $this->resetErrorBag('s3StorageId'); - $this->backup?->update(['s3_storage_id' => $this->s3StorageId]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', 'S3 storage updated.'); } @@ -163,11 +167,11 @@ class VolumeBackups extends Component $this->saveToS3 = ! $this->saveToS3; $this->disableLocalBackup = $this->saveToS3 && $this->disableLocalBackup; - $this->backup?->update([ - 'save_s3' => $this->saveToS3, - 'disable_local_backup' => $this->disableLocalBackup, - 's3_storage_id' => $this->s3StorageId, - ]); + if (! $this->validateSettings()) { + return; + } + + $this->backup = $this->persistBackup($this->enabled); $this->dispatch('success', $this->saveToS3 ? 'S3 backups enabled.' : 'S3 backups disabled.'); } diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 9ea87161d8..5d0d2538bd 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -5,11 +5,29 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Component; +use Livewire\WithPagination; class Resources extends Component { use AuthorizesRequests; + use WithPagination; + + public int $perPage = 10; + + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } public ?Server $server = null; @@ -93,6 +111,10 @@ class Resources extends Component public function loadManagedContainers() { try { + if ($this->activeTab !== 'managed') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'managed'; $this->server->refresh(); } catch (\Throwable $e) { @@ -102,6 +124,10 @@ class Resources extends Component public function loadUnmanagedContainers() { + if ($this->activeTab !== 'unmanaged') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'unmanaged'; try { $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); @@ -125,6 +151,29 @@ class Resources extends Component public function render() { - return view('livewire.server.resources'); + $resources = $this->activeTab === 'managed' + ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) + : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $search = trim($this->search); + if ($search !== '') { + $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; + $resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey)) + ->contains($search, ignoreCase: true)); + } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($resources->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + + return view('livewire.server.resources', [ + 'resources' => new LengthAwarePaginator( + $resources->forPage($page, $this->perPage)->values(), + $resources->count(), + $this->perPage, + $page, + ), + ]); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 1190cd59a1..49739ac6dd 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Sentinel; +use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -29,6 +30,35 @@ class Logs extends Component $this->authorize('viewSentinel', $this->server); } + public function enableSentinel(): void + { + $this->authorize('manageSentinel', $this->server); + + try { + $this->server->refresh(); + if ($this->server->isBuildServer()) { + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } + if ($this->server->isSwarm()) { + $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); + + return; + } + if ($this->server->isSentinelEnabled()) { + return; + } + + StartSentinel::run($this->server, true); + $this->server->refresh(); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'Sentinel has been enabled.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..45aff3f3c9 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -53,6 +53,8 @@ class Advanced extends Component public string $avatar_storage = 'local'; + public ?string $image_cdn_url = null; + public array $avatar_storage_options = []; public function rules() @@ -71,6 +73,7 @@ class Advanced extends Component 'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allow_localhost' => 'boolean', 'domain_connect_private_key' => 'nullable|string', + 'image_cdn_url' => 'nullable|url|max:255', ]; } @@ -97,6 +100,7 @@ class Advanced extends Component $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id : 'local'; + $this->image_cdn_url = $this->settings->image_cdn_url; $this->avatar_storage_options = [ ['value' => 'local', 'label' => 'Local storage'], ...S3Storage::query() @@ -210,6 +214,7 @@ class Advanced extends Component $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; + $this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null; $this->saveAvatarStorageSetting(); $this->settings->save(); $this->dispatch('success', 'Settings updated!'); diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index 022f6fdeed..31f2e9141d 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -2,8 +2,11 @@ namespace App\Livewire\Subscription; +use App\Actions\Stripe\UpdateSubscriptionQuantity; +use App\Jobs\ServerLimitCheckJob; use App\Models\InstanceSettings; use App\Providers\RouteServiceProvider; +use Illuminate\Support\Facades\Cache; use Livewire\Component; use Stripe\StripeClient; @@ -49,12 +52,19 @@ class Index extends Component return redirect($session->url); } - public function getStripeStatus() + public function getStripeStatus(): mixed { + $team = currentTeam(); + $user = auth()->user(); + abort_unless($team && $user?->isAdminOfTeam($team->id), 403); + try { - $subscription = currentTeam()->subscription; + $subscription = $team->subscription()->first(); + if (! $subscription?->stripe_customer_id) { + return null; + } $stripe = app(StripeClient::class); - $customer = $stripe->customers->retrieve(currentTeam()->subscription->stripe_customer_id); + $customer = $stripe->customers->retrieve($subscription->stripe_customer_id); if ($customer) { $subscriptions = $stripe->subscriptions->all(['customer' => $customer->id]); $currentTeam = currentTeam()->id ?? null; @@ -65,6 +75,26 @@ class Index extends Component $subscription->update([ 'stripe_subscription_id' => $foundSubscription->id, ]); + if ($status === 'active') { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + 'stripe_plan_id' => data_get($foundSubscription, 'items.data.0.price.id'), + 'stripe_cancel_at_period_end' => data_get($foundSubscription, 'cancel_at_period_end', false), + ]); + if (str(data_get($foundSubscription, 'items.data.0.price.lookup_key'))->contains('dynamic')) { + $quantity = max( + UpdateSubscriptionQuantity::MIN_SERVER_LIMIT, + min((int) data_get($foundSubscription, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT) + ); + $team->update(['custom_server_limit' => $quantity]); + ServerLimitCheckJob::dispatch($team); + } + $team->unsetRelation('subscription'); + Cache::forget('user:'.$user->id.':team:'.$team->id); + + return redirect()->route('subscription.show'); + } if ($status === 'unpaid') { $this->isUnpaid = true; } @@ -82,6 +112,8 @@ class Index extends Component } finally { $this->loading = false; } + + return null; } public function render() diff --git a/app/Livewire/Subscription/PricingPlans.php b/app/Livewire/Subscription/PricingPlans.php index 65966aea5f..e53c5c677b 100644 --- a/app/Livewire/Subscription/PricingPlans.php +++ b/app/Livewire/Subscription/PricingPlans.php @@ -2,22 +2,28 @@ namespace App\Livewire\Subscription; -use Illuminate\Support\Facades\Auth; +use App\Actions\Stripe\CreateCheckoutSession; +use App\Exceptions\CheckoutUnavailableException; use Livewire\Component; -use Stripe\Checkout\Session; -use Stripe\Stripe; +use RuntimeException; +use Stripe\Exception\ApiErrorException; class PricingPlans extends Component { - public function subscribeStripe($type) + public function subscribeStripe(string $type): mixed { - if (currentTeam()->subscription?->stripe_invoice_paid) { - $this->dispatch('error', 'Team already has an active subscription.'); + $team = currentTeam(); + $user = auth()->user(); - return; + if (! $team || ! $user?->isAdminOfTeam($team->id)) { + abort(403); } - Stripe::setApiKey(config('subscription.stripe_api_key')); + if ($team->subscription?->stripe_invoice_paid) { + $this->dispatch('error', 'Team already has an active subscription.'); + + return null; + } $priceId = match ($type) { 'dynamic-monthly' => config('subscription.stripe_price_id_dynamic_monthly'), @@ -28,48 +34,29 @@ class PricingPlans extends Component if (! $priceId) { $this->dispatch('error', 'Price ID not found! Please contact the administrator.'); - return; + return null; } - $payload = [ - 'allow_promotion_codes' => true, - 'billing_address_collection' => 'required', - 'client_reference_id' => Auth::id().':'.currentTeam()->id, - 'line_items' => [[ - 'price' => $priceId, - 'adjustable_quantity' => [ - 'enabled' => true, - 'minimum' => 2, - ], - 'quantity' => 2, - ]], - 'tax_id_collection' => [ - 'enabled' => true, - ], - 'automatic_tax' => [ - 'enabled' => true, - ], - 'subscription_data' => [ - 'metadata' => [ - 'user_id' => Auth::id(), - 'team_id' => currentTeam()->id, - ], - ], - 'payment_method_collection' => 'if_required', - 'mode' => 'subscription', - 'success_url' => route('dashboard', ['success' => true]), - 'cancel_url' => route('subscription.index', ['cancelled' => true]), - ]; + try { + $session = app(CreateCheckoutSession::class)->execute($team, $user, $priceId); + } catch (ApiErrorException $exception) { + report($exception); + $this->dispatch('error', 'Unable to confirm checkout with Stripe. Please try again shortly.'); - $customer = currentTeam()->subscription?->stripe_customer_id ?? null; - if ($customer) { - $payload['customer'] = $customer; - $payload['customer_update'] = [ - 'name' => 'auto', - ]; - } else { - $payload['customer_email'] = Auth::user()->email; + return null; + } catch (CheckoutUnavailableException $exception) { + $message = $exception->getMessage(); + if ($exception->billingPortalUrl) { + $message .= ' Open billing portal'; + } + $this->dispatch('error', $message); + + return null; + } catch (RuntimeException $exception) { + report($exception); + $this->dispatch('error', 'Unable to start checkout. Please try again shortly.'); + + return null; } - $session = Session::create($payload); return redirect($session->url, 303); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 7f7ce5ea1f..cec8d501ab 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; +use App\Enums\BuildPackTypes; use App\Services\ConfigurationGenerator; use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; use App\Services\DeploymentConfiguration\ConfigurationDiff; @@ -291,9 +292,11 @@ class Application extends BaseModel if ($application->fqdn === '') { $application->fqdn = null; } - $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); - $application->fqdn = $normalized['fqdn']; - $application->domain_port_overrides = $normalized['overrides']; + if ($application->build_pack !== BuildPackTypes::DOCKERCOMPOSE->value || filled($application->fqdn)) { + $normalized = DomainPortOverrides::normalize($application->fqdn, $application->domain_port_overrides); + $application->fqdn = $normalized['fqdn']; + $application->domain_port_overrides = $normalized['overrides']; + } $payload['fqdn'] = $application->fqdn; $application->syncNoindexDomains(); } @@ -961,12 +964,16 @@ class Application extends BaseModel } /** - * Ports the container is expected to listen on: Ports Exposes plus ports already used by application domains. + * Ports declared by the selected Compose service, or exposed and previously used application ports. * * @return list */ - public function availableInternalPorts(): array + public function availableInternalPorts(?string $serviceName = null): array { + if ($this->build_pack === 'dockercompose') { + return dockerComposeServicePorts($this->docker_compose_raw, $serviceName); + } + $ports = collect($this->settings?->is_static ? [80] : $this->ports_exposes_array) ->filter(fn (mixed $port): bool => is_numeric($port) && (int) $port > 0) ->map(fn (mixed $port): int => (int) $port); @@ -991,13 +998,13 @@ class Application extends BaseModel return $ports->unique()->sort()->values()->all(); } - public function portRequiresConfirmation(?int $port): bool + public function portRequiresConfirmation(?int $port, ?string $serviceName = null): bool { if ($port === null || $port <= 0) { return false; } - return ! in_array($port, $this->availableInternalPorts(), true); + return ! in_array($port, $this->availableInternalPorts($serviceName), true); } public function detectPortFromEnvironment(?bool $isPreview = false): ?int diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..26aceec354 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -56,6 +56,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'image_cdn_url', 'is_dashboard_force_https_enabled', ]; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index e41c793c86..7a26658e4f 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -14,6 +14,9 @@ class ScheduledDatabaseBackup extends BaseModel 'dump_all' => 'boolean', 'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_s3' => 'float', + 'missing_backup_notification_days' => 'integer', + 'missing_backup_notification_sent_at' => 'datetime', + 'last_execution_at' => 'datetime', ]; } @@ -37,6 +40,7 @@ class ScheduledDatabaseBackup extends BaseModel 'database_backup_retention_max_storage_s3', 'timeout', 'disable_local_backup', + 'missing_backup_notification_days', ]; public static function ownedByCurrentTeam() diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php index 8c5de1e8b1..1a479772cf 100644 --- a/app/Models/ScheduledDatabaseBackupExecution.php +++ b/app/Models/ScheduledDatabaseBackupExecution.php @@ -6,6 +6,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ScheduledDatabaseBackupExecution extends BaseModel { + protected static function booted(): void + { + static::created(function (ScheduledDatabaseBackupExecution $execution): void { + $execution->scheduledDatabaseBackup()->update(['last_execution_at' => $execution->created_at ?? now()]); + }); + } + protected $fillable = [ 'uuid', 'scheduled_database_backup_id', diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index 1e26c7be3f..8f496fc1e6 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel 'started_at' => 'datetime', 'finished_at' => 'datetime', 'retry_count' => 'integer', - 'duration' => 'decimal:2', + 'duration' => 'float', ]; } diff --git a/app/Models/Service.php b/app/Models/Service.php index 4308b78609..accbd9c30c 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1477,7 +1477,10 @@ class Service extends BaseModel { try { $services = get_service_templates(); - $serviceName = $this->service_type ?: str($this->name)->beforeLast('-')->value(); + if (blank($this->service_type)) { + return null; + } + $serviceName = $this->service_type; $service = data_get($services, $serviceName, []); $port = data_get($service, 'port'); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index cf0faef5bd..cba18c1f23 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -201,7 +201,7 @@ class ServiceApplication extends BaseModel } /** - * Return the public URLs with their persisted internal port overrides. + * Return editable URLs with persisted overrides or legacy embedded ports. */ protected function url(): Attribute { @@ -220,7 +220,7 @@ class ServiceApplication extends BaseModel $port = $overrides[$canonical] ?? null; if ($port === null) { - return $canonical; + return $url; } $parts = DomainUrlParts::split($canonical); @@ -366,7 +366,7 @@ class ServiceApplication extends BaseModel } $dockerCompose = Yaml::parse($dockerComposeRaw); - $serviceConfig = data_get($dockerCompose, "services.{$this->name}"); + $serviceConfig = $dockerCompose['services'][$this->name] ?? null; if (! $serviceConfig) { return $this->service->getRequiredPort(); } @@ -417,9 +417,21 @@ class ServiceApplication extends BaseModel return $portFound; } + $composePort = firstDockerComposeServicePort($serviceConfig); + if ($composePort !== null) { + return $composePort; + } + // HTTP-facing compose services that only declare SERVICE_URL/FQDN (no _PORT // suffix), such as WordPress, inherit the one-click template `# port:`. if ($declaresHttpUrl) { + if (blank($this->service->service_type)) { + $savedPort = $this->getSavedLegacyRoutingPort($serviceConfig); + if ($savedPort !== null) { + return $savedPort; + } + } + return $this->service->getRequiredPort(); } @@ -428,4 +440,52 @@ class ServiceApplication extends BaseModel return null; } } + + /** + * Preserve only an unambiguous upstream from this legacy container's saved labels. + */ + private function getSavedLegacyRoutingPort(array $serviceConfig): ?int + { + $savedCompose = Yaml::parse($this->service->docker_compose ?? ''); + $savedService = $savedCompose['services'][$this->name] ?? null; + $image = $serviceConfig['image'] ?? null; + if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) { + return null; + } + + $labels = $savedService['labels'] ?? []; + if (! is_array($labels)) { + return null; + } + + $ports = []; + foreach ($labels as $key => $value) { + if (is_int($key)) { + if (! is_string($value)) { + return null; + } + [$key, $value] = array_pad(explode('=', $value, 2), 2, null); + } + + if (preg_match('/^traefik\.http\.services\.[^.]+\.loadbalancer\.server\.port$/', $key)) { + $port = $value; + } elseif (preg_match('/^caddy(?:_\d+)?\..*reverse_proxy$/', $key)) { + if (! is_string($value) || ! preg_match('/^\{\{upstreams ([0-9]+)\}\}$/', $value, $matches)) { + return null; + } + $port = $matches[1]; + } else { + continue; + } + + if ((! is_string($port) && ! is_int($port)) || ! preg_match('/^[0-9]+$/', (string) $port) || (int) $port < 1 || (int) $port > 65535) { + return null; + } + $ports[] = (int) $port; + } + + $ports = array_values(array_unique($ports)); + + return count($ports) === 1 ? $ports[0] : null; + } } diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 40bd3ead0d..01f58af7ec 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -100,4 +100,16 @@ class ApiTokenExpiringNotification extends CustomEmailNotification color: SlackMessage::warningColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "API token '{$this->tokenName}' expires on {$this->expiresAt}. Rotate this token before it expires to avoid API outages.", + 'event' => 'api_token_expiring', + 'token_name' => $this->tokenName, + 'expires_at' => $this->expiresAt, + 'url' => $this->manageUrl, + ]; + } } diff --git a/app/Notifications/Channels/TelegramChannel.php b/app/Notifications/Channels/TelegramChannel.php index a52feda087..118ad4269c 100644 --- a/app/Notifications/Channels/TelegramChannel.php +++ b/app/Notifications/Channels/TelegramChannel.php @@ -9,6 +9,7 @@ use App\Notifications\Application\RestartLimitReached; use App\Notifications\Application\StatusChanged; use App\Notifications\Container\ContainerRestarted; use App\Notifications\Database\BackupFailed; +use App\Notifications\Database\BackupMissing; use App\Notifications\Database\BackupSuccess; use App\Notifications\ScheduledTask\TaskFailed; use App\Notifications\ScheduledTask\TaskSuccess; @@ -40,7 +41,8 @@ class TelegramChannel RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id, BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id, - BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id, + BackupFailed::class, + BackupMissing::class => $settings->telegram_notifications_backup_failure_thread_id, TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id, TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id, diff --git a/app/Notifications/Database/BackupMissing.php b/app/Notifications/Database/BackupMissing.php new file mode 100644 index 0000000000..d7f127ce3c --- /dev/null +++ b/app/Notifications/Database/BackupMissing.php @@ -0,0 +1,83 @@ +onQueue('high'); + $this->databaseName = $backup->database?->name ?? $backup->description ?? $backup->uuid; + } + + public function via(object $notifiable): array + { + return $notifiable->getEnabledChannels('backup_failure'); + } + + public function toMail(): MailMessage + { + return (new MailMessage) + ->subject("Coolify: [ACTION REQUIRED] No recent backup for {$this->databaseName}") + ->view('emails.backup-missing', $this->messageData()); + } + + public function toDiscord(): DiscordMessage + { + return new DiscordMessage( + title: ':warning: Scheduled database backup missing', + description: $this->description(), + color: DiscordMessage::errorColor(), + isCritical: true, + ); + } + + public function toTelegram(): array + { + return ['message' => 'Coolify: '.$this->description()]; + } + + public function toPushover(): PushoverMessage + { + return new PushoverMessage(title: 'Scheduled database backup missing', level: 'error', message: $this->description()); + } + + public function toSlack(): SlackMessage + { + return new SlackMessage(title: 'Scheduled database backup missing', description: $this->description(), color: SlackMessage::errorColor()); + } + + public function toWebhook(): array + { + return array_merge($this->messageData(), [ + 'success' => false, + 'message' => 'Scheduled database backup missing', + 'event' => 'backup_missing', + 'backup_uuid' => $this->backup->uuid, + ]); + } + + private function description(): string + { + return "The enabled backup schedule for {$this->databaseName} has produced no executions in the last {$this->backup->missing_backup_notification_days} day(s)."; + } + + private function messageData(): array + { + return [ + 'database_name' => $this->databaseName, + 'days' => $this->backup->missing_backup_notification_days, + 'last_execution_at' => $this->lastExecutionAt?->toDateTimeString(), + ]; + } +} diff --git a/app/Notifications/Internal/GeneralNotification.php b/app/Notifications/Internal/GeneralNotification.php index 1d23672100..52e986ed6e 100644 --- a/app/Notifications/Internal/GeneralNotification.php +++ b/app/Notifications/Internal/GeneralNotification.php @@ -58,4 +58,14 @@ class GeneralNotification extends Notification implements ShouldQueue color: SlackMessage::infoColor(), ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => $this->message, + 'event' => 'general', + 'url' => base_url(), + ]; + } } diff --git a/app/Notifications/Notification.php b/app/Notifications/Notification.php deleted file mode 100644 index d37716a8b4..0000000000 --- a/app/Notifications/Notification.php +++ /dev/null @@ -1,22 +0,0 @@ - false, + 'message' => "Server ({$this->server->name}) disabled because it is not paid! All automations and integrations are stopped.", + 'event' => 'server_force_disabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60f..61022d36b5 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -65,4 +65,16 @@ class ForceEnabled extends CustomEmailNotification color: SlackMessage::successColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => true, + 'message' => "Server ({$this->server->name}) enabled again!", + 'event' => 'server_force_enabled', + 'server_name' => $this->server->name, + 'server_uuid' => $this->server->uuid, + 'url' => base_url().'/server/'.$this->server->uuid, + ]; + } } diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054b..6c2712b68d 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -17,8 +17,7 @@ class HetznerDeletionFailed extends CustomEmailNotification public function via(object $notifiable): array { - - return $notifiable->getEnabledChannels('hetzner_deletion_failed'); + return $notifiable->getEnabledChannels('hetzner_deletion_failure'); } public function toMail(): MailMessage @@ -66,4 +65,16 @@ class HetznerDeletionFailed extends CustomEmailNotification color: SlackMessage::errorColor() ); } + + public function toWebhook(): array + { + return [ + 'success' => false, + 'message' => "[ACTION REQUIRED] Failed to delete Hetzner server #{$this->hetznerServerId} from Hetzner Cloud. The server has been removed from Coolify, but may still exist in your Hetzner Cloud account.", + 'event' => 'hetzner_deletion_failed', + 'hetzner_server_id' => $this->hetznerServerId, + 'error' => $this->errorMessage, + 'url' => base_url().'/servers', + ]; + } } diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 023031ddd7..8d2a5e3952 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -117,4 +117,18 @@ class SslExpirationNotification extends CustomEmailNotification color: SlackMessage::warningColor() ); } + + public function toWebhook(): array + { + $resourceNames = $this->resources->pluck('name'); + + return [ + 'success' => false, + 'message' => "SSL certificates have been renewed for: {$resourceNames->join(', ')}. These resources need to be redeployed manually for the new SSL certificates to take effect.", + 'event' => 'ssl_certificate_renewal', + 'resources' => $resourceNames->values()->all(), + 'urls' => $this->urls, + 'url' => base_url(), + ]; + } } diff --git a/app/Support/DatabaseBackupFileValidator.php b/app/Support/DatabaseBackupFileValidator.php index 84e629fe1a..2c1de948ba 100644 --- a/app/Support/DatabaseBackupFileValidator.php +++ b/app/Support/DatabaseBackupFileValidator.php @@ -90,11 +90,8 @@ class DatabaseBackupFileValidator public static function containsPostgresqlProgramExecution(string $sql): bool { - $requireStatementBoundary = true; - if (str_starts_with($sql, 'PGDMP')) { - $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql; - $requireStatementBoundary = false; + return false; } $withoutComments = self::stripSqlComments($sql); @@ -103,9 +100,7 @@ class DatabaseBackupFileValidator return true; } - $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b'; - - return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; + return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; } private static function extensionFor(string $name): ?string diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php index 3e1600127e..f16fc3d953 100644 --- a/app/View/Components/Forms/Button.php +++ b/app/View/Components/Forms/Button.php @@ -23,6 +23,7 @@ class Button extends Component public mixed $canResource = null, public bool $autoDisable = true, public bool $isHighlighted = false, + public bool $isError = false, public ?string $tooltip = null, ) { // Handle authorization-based disabling @@ -37,6 +38,8 @@ class Button extends Component if ($this->noStyle) { $this->defaultClass = ''; + } elseif ($this->isError) { + $this->defaultClass .= ' button-error'; } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 84232a8d72..613a104e0e 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -607,6 +607,13 @@ function firstDockerComposeServicePort(mixed $service): ?int ->merge(data_get($service, 'ports', [])); foreach ($portDefinitions as $definition) { + $protocol = is_array($definition) + ? data_get($definition, 'protocol', 'tcp') + : (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp'); + if ($protocol !== 'tcp') { + continue; + } + $port = is_array($definition) ? data_get($definition, 'target') : str((string) $definition)->before('/')->afterLast(':')->value(); @@ -620,18 +627,29 @@ function firstDockerComposeServicePort(mixed $service): ?int } function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int +{ + return dockerComposeServicePorts($compose, $serviceName)[0] ?? null; +} + +function dockerComposeServicePorts(?string $compose, ?string $serviceName): array { if (blank($compose) || blank($serviceName)) { - return null; + return []; } try { $services = data_get(Yaml::parse($compose), 'services', []); } catch (Throwable) { - return null; + return []; } - return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null); + $service = is_array($services) ? ($services[$serviceName] ?? []) : []; + + return collect(data_get($service, 'expose', [])) + ->merge(data_get($service, 'ports', [])) + ->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]])) + ->filter(fn ($port) => $port !== null) + ->unique()->values()->all(); } function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = []) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 03ebf5af7a..e50b852eca 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1354,8 +1354,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $domainPortOverrides = $isPullRequest ? ($previewForPorts?->domain_port_overrides ?? []) : ($originalResource->domain_port_overrides ?? []); - $exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service); if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); } @@ -1562,7 +1561,6 @@ function serviceParser(Service $resource): Collection $envComments = extractYamlEnvironmentComments($compose); $server = data_get($resource, 'server'); - $allServices = get_service_templates(); try { $yaml = Yaml::parse($compose); @@ -1695,22 +1693,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2080,22 +2063,7 @@ function serviceParser(Service $resource): Collection $containerName = "$serviceName-{$resource->uuid}"; - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } + $predefinedPort = $resource->getRequiredPort(); if ($migratedApp || $migratedDb) { // Use the already determined migrated service @@ -2638,7 +2606,7 @@ function serviceParser(Service $resource): Collection ? data_get($originalResource, 'redirect') : 'both'; $onlyPort = $originalResource instanceof ServiceApplication - ? ($originalResource->getRequiredPort() ?? $predefinedPort) + ? $originalResource->getRequiredPort() : $predefinedPort; if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) { $serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first()); @@ -2673,7 +2641,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection @@ -2706,7 +2674,7 @@ function serviceParser(Service $resource): Collection service_name: $serviceName, image: $image, onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, domainPortOverrides: $originalResource->domain_port_overrides ?? [], noindex_domains: $noindexDomains, redirect_direction: $redirectDirection diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f9bf44dffa..fbfbc9b566 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string return null; } - $baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); + $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl(); return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; } @@ -2486,7 +2486,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } catch (Exception $e) { throw new RuntimeException($e->getMessage()); } - $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelConfigs = collect(data_get($yaml, 'configs', [])); @@ -2512,25 +2511,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } $topLevelVolumes = collect($tempTopLevelVolumes); } - $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) { - // Workarounds for beta users. - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - // End of workarounds for beta users. + $services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $envComments) { + $predefinedPort = $resource->getRequiredPort(); $serviceVolumes = collect(data_get($service, 'volumes', [])); $servicePorts = collect(data_get($service, 'ports', [])); $serviceNetworks = collect(data_get($service, 'networks', [])); @@ -3107,7 +3089,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal ? ($savedService->domain_port_overrides ?? []) : []; $onlyPort = $savedService instanceof ServiceApplication - ? ($savedService->getRequiredPort() ?? $predefinedPort) + ? $savedService->getRequiredPort() : $predefinedPort; if ($shouldGenerateLabelsExactly) { switch ($resource->server->proxyType()) { @@ -3139,7 +3121,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3172,7 +3154,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal service_name: $serviceName, image: data_get($service, 'image'), onlyPort: $onlyPort, - predefinedPort: $predefinedPort, + predefinedPort: $onlyPort, noindex_domains: $noindexDomains, redirect_direction: $redirectDirection, domainPortOverrides: $domainPortOverrides, @@ -3915,8 +3897,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $domainPortOverrides = $pull_request_id === 0 ? ($resource->domain_port_overrides ?? []) : ($preview?->domain_port_overrides ?? []); - $exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array; - $onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null); + $onlyPort = firstDockerComposeServicePort($service); if ($shouldGenerateLabelsExactly) { switch ($server->proxyType()) { case ProxyTypes::TRAEFIK->value: diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index 397efc387c..98dbe3af7a 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -59,13 +59,18 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array return $line; } + // Negation belongs to the shell, before the elevated command. + if (preg_match('/^\s*!\s+/', $line)) { + return preg_replace('/^(\s*(?:!\s+)+)/', '$1sudo ', $line); + } + // Check all keywords with word boundary matching // Match keyword followed by space, semicolon, or end of line foreach ($bashKeywords as $keyword) { if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) { - // Special handling for 'if' - insert sudo after 'if ' + // Keep any shell negation before sudo in the condition. if ($keyword === 'if') { - return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line); + return preg_replace('/^(\s*if\s+(?:!\s+)*)/', '$1sudo ', $line); } return $line; diff --git a/config/constants.php b/config/constants.php index 7ad0c4594f..7e649668d8 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.17', + 'version' => env('COOLIFY_VERSION') ?: '4.3.19', 'helper_version' => '1.0.16', - 'realtime_version' => '1.0.18', + 'realtime_version' => '1.0.19', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -14,7 +14,6 @@ return [ 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), - 'avatar_cdn_url' => env('AVATAR_CDN_URL'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), diff --git a/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php new file mode 100644 index 0000000000..fe7c1e6292 --- /dev/null +++ b/database/migrations/2026_08_20_150000_add_missing_backup_notification_fields_to_scheduled_database_backups_table.php @@ -0,0 +1,28 @@ +unsignedInteger('missing_backup_notification_days')->default(0); + $table->timestamp('missing_backup_notification_sent_at')->nullable(); + $table->timestamp('last_execution_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn([ + 'missing_backup_notification_days', + 'missing_backup_notification_sent_at', + 'last_execution_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php new file mode 100644 index 0000000000..1c898dd5da --- /dev/null +++ b/database/migrations/2026_09_04_191011_add_image_cdn_url_to_instance_settings_table.php @@ -0,0 +1,28 @@ +string('image_cdn_url')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('image_cdn_url'); + }); + } +}; diff --git a/database/seeders/TeamSeeder.php b/database/seeders/TeamSeeder.php index 67c5ec4897..08426044c6 100644 --- a/database/seeders/TeamSeeder.php +++ b/database/seeders/TeamSeeder.php @@ -10,14 +10,14 @@ class TeamSeeder extends Seeder { public function run(): void { - $normal_user_in_root_team = User::find(1); + $normal_user_in_root_team = User::where('email', 'test2@example.com')->firstOrFail(); $root_user_personal_team = Team::find(0); $root_user_personal_team->description = 'The root team'; $root_user_personal_team->save(); $normal_user_in_root_team->teams()->attach($root_user_personal_team); - $normal_user_not_in_root_team = User::find(2); - $normal_user_in_root_team_personal_team = Team::find(1); + $normal_user_not_in_root_team = User::where('email', 'test3@example.com')->firstOrFail(); + $normal_user_in_root_team_personal_team = $normal_user_in_root_team->teams()->where('personal_team', true)->wherePivot('role', 'owner')->firstOrFail(); $normal_user_not_in_root_team->teams()->attach($normal_user_in_root_team_personal_team, ['role' => 'admin']); } } diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 2ac615cc01..9f237dc3b5 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,14 +15,13 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ - 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ - 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); + } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ebf12379d5..d611fc69f7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,7 +62,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.18' + image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.19' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cc266e5562..6a52a0126a 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -97,7 +97,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.18' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.19' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index b72574c8d0..09f5bd5e12 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -8,6 +8,7 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, sanitizeSshArgs, @@ -401,7 +402,7 @@ async function handleCommand(ws, command, userId) { cols: 80, rows: 30, cwd: process.env.HOME, - env: {}, + env: getTerminalProcessEnv(), }; // NOTE: - Initiates a process within the Terminal container diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js index 4e86dc1f73..0d13dc18f1 100644 --- a/docker/coolify-realtime/terminal-utils.js +++ b/docker/coolify-realtime/terminal-utils.js @@ -1,5 +1,13 @@ export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60; +const DEFAULT_TERMINAL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; + +export function getTerminalProcessEnv(environment = process.env) { + return { + PATH: environment.PATH || DEFAULT_TERMINAL_PATH, + }; +} + export function getTerminalSessionTimeout() { return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS; } diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js index 5ca0be8304..21625eece4 100644 --- a/docker/coolify-realtime/terminal-utils.test.js +++ b/docker/coolify-realtime/terminal-utils.test.js @@ -4,6 +4,7 @@ import { MAX_TERMINAL_SESSION_TIMEOUT_SECONDS, extractSshArgs, extractTargetHost, + getTerminalProcessEnv, getTerminalSessionTimeout, isAuthorizedTargetHost, normalizeHostForAuthorization, @@ -11,6 +12,32 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '/usr/local/bin:/usr/bin:/bin', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/bin:/usr/bin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is absent', () => { + assert.deepEqual(getTerminalProcessEnv({ + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + +test('getTerminalProcessEnv uses the default PATH when PATH is empty', () => { + assert.deepEqual(getTerminalProcessEnv({ + PATH: '', + APP_KEY: 'must-not-be-inherited', + }), { + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }); +}); + test('extractTargetHost normalizes quoted IPv4 hosts from generated ssh commands', () => { const sshArgs = extractSshArgs( "timeout 3600 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ServerAliveInterval=20 -o ConnectTimeout=10 'root'@'10.0.0.5' 'bash -se' << \\\\$abc\necho hi\nabc" diff --git a/docker/development/etc/nginx/conf.d/custom.conf b/docker/development/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/development/etc/nginx/conf.d/custom.conf +++ b/docker/development/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/docker/production/etc/nginx/conf.d/custom.conf b/docker/production/etc/nginx/conf.d/custom.conf index f26dc30495..4672e3de55 100644 --- a/docker/production/etc/nginx/conf.d/custom.conf +++ b/docker/production/etc/nginx/conf.d/custom.conf @@ -2,3 +2,9 @@ # Disable access logs access_log off; + +# Allow request headers up to 32k (nginx default is 8k). Large JWT cookies can push the +# Cookie header past 8k, and nginx would then reject the request with a bare 400 +# before it reaches the application. +client_header_buffer_size 8k; +large_client_header_buffers 8 32k; diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 0e83c0cf74..e2d8c18bc8 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.17" + "version": "4.3.19" }, "nightly": { "version": "4.4-rc.1" diff --git a/resources/css/app.css b/resources/css/app.css index 40af521c69..bf506dbda4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -20,51 +20,152 @@ --font-geist-sans: 'Geist Sans', Inter, sans-serif; --font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - --color-base: #101010; - --color-warning: #fcd452; - --color-warning-50: #fefce8; - --color-warning-100: #fef9c3; - --color-warning-200: #fef08a; - --color-warning-300: #fde047; - --color-warning-400: #fcd452; - --color-warning-500: #facc15; - --color-warning-600: #ca8a04; - --color-warning-700: #a16207; - --color-warning-800: #854d0e; - --color-warning-900: #713f12; - --color-success: #22C55E; - --color-error: #dc2626; - --color-coollabs-50: #f5f0ff; - --color-coollabs: #6b16ed; - --color-coollabs-100: #7317ff; - --color-coollabs-200: #5a12c7; - --color-coollabs-300: #4a0fa3; - --color-coolgray-100: #181818; - --color-coolgray-200: #202020; - --color-coolgray-300: #242424; - --color-coolgray-400: #282828; - --color-coolgray-500: #323232; + --color-base: oklch(17.3% 0 0); + --color-warning: oklch(88.13% 0.1507 91.7); + --color-warning-50: oklch(98.73% 0.0262 102.21); + --color-warning-100: oklch(97.29% 0.0693 103.19); + --color-warning-200: oklch(94.51% 0.1243 101.54); + --color-warning-300: oklch(90.52% 0.1657 98.11); + --color-warning-400: oklch(88.13% 0.1507 91.7); + --color-warning-500: oklch(86.06% 0.1731 91.94); + --color-warning-600: oklch(68.06% 0.1423 75.83); + --color-warning-700: oklch(55.38% 0.1207 66.44); + --color-warning-800: oklch(47.62% 0.1034 61.91); + --color-warning-900: oklch(42.1% 0.0897 57.71); + --color-success: oklch(72.27% 0.192 149.58); + --color-error: oklch(57.71% 0.2152 27.33); + --color-coollabs-50: oklch(96.33% 0.0206 301.15); + --color-coollabs: oklch(49.65% 0.2709 289.33); + --color-coollabs-100: oklch(52.34% 0.287 289.16); + --color-coollabs-200: oklch(43.76% 0.2369 289.82); + --color-coollabs-300: oklch(38.04% 0.2031 290.47); + --color-coolgray-100: oklch(20.9% 0 0); + --color-coolgray-200: oklch(24.35% 0 0); + --color-coolgray-300: oklch(26.03% 0 0); + --color-coolgray-400: oklch(27.68% 0 0); + --color-coolgray-500: oklch(31.71% 0 0); /* Graphite design language (ported from ref/frontend). Layered neutral surfaces + translucent hairlines. See DESIGN.md. */ - --color-app: #0c0c0d; - /* Canvas: neutral near-black (oklch), not pure black */ - --color-panel: oklch(10% 0 0); - --color-surface: #161618; - --color-raised: #1c1c1e; - --color-selected: #26262a; - --color-fg: #f2f2f2; - --color-fg-dim: #b4b4b8; - --color-fg-faint: #6e6e74; - --color-accent: #6b16ed; - --color-accent-foreground: #ffffff; - --color-hairline: rgba(255, 255, 255, 0.08); - --color-nav-text: #525252; - --color-nav-muted: #666666; - --color-nav-active: #171717; - --color-log: #0d0d0d; + /* Content canvas: the darkest shell layer (sRGB ~10). Hex, not oklch: + oklch lightness compresses to near-black below ~15%, so oklch(7.5%) + renders as sRGB 1 with no visible step. */ + --color-app: oklch(14.48% 0 0); + /* Sidebar + topbar chrome (sRGB ~20): a clear step lighter than the content + canvas so the shell chrome separates from the content area. */ + --color-panel: oklch(19.13% 0 0); + /* Pure neutral (r=g=b). Were cool-tinted (#161618/#1c1c1e/#26262a, b>r), + which clashed with the neutral panel ladder. */ + --color-surface: oklch(20.02% 0 0); + --color-raised: oklch(22.64% 0 0); + --color-selected: oklch(26.86% 0 0); + --color-fg: oklch(96.12% 0 0); + --color-fg-dim: oklch(76.99% 0 0); + /* Tertiary text. Raised from #6e6e74 (3.78:1 on base, failed WCAG AA) + to #7e7e84 so 13-14px text clears 4.5:1 on the dark surface ladder. */ + --color-fg-faint: oklch(59.31% 0 0); + --color-accent: oklch(49.65% 0.2709 289.33); + --color-accent-foreground: oklch(100.0% 0 0); + --color-hairline: oklch(100.0% 0 0 / 0.08); + --color-nav-text: oklch(43.86% 0 0); + --color-nav-muted: oklch(51.03% 0 0); + --color-nav-active: oklch(20.46% 0 0); + --color-log: oklch(15.91% 0 0); --shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.4); + /* One restrained lift shared by every dropdown/menu/listbox panel and the + command palette. Lighter and consistent, replacing the old scattered + 0.45 / 0.35 / shadow-lg / shadow-sm mix. Modals keep --shadow-modal. */ + --shadow-dropdown: 0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px rgb(0 0 0 / 0.08); + + /* Shared motion curves (stronger than the built-in CSS easings). Use these + for consistency: --ease-out for UI enter/press, --ease-drawer for + drawers/sheets, --ease-in-out for on-screen movement. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +} + +/* Standard buttons have a compact physical edge. Hover lifts the face by 1px; + pressing nudges it down while the depth disappears. */ +.button:not(:disabled) { + --button-depth-color: rgb(0 0 0 / 0.22); + --button-depth: 0 2px 0 var(--button-depth-color); + --button-depth-hover: 0 3px 0 var(--button-depth-color); + + box-shadow: var(--button-depth); +} + +.button.button-highlighted:not(:disabled), +.button[isHighlighted]:not(:disabled) { + --button-depth-color: color-mix(in oklab, var(--color-coollabs) 52%, black); +} + +.dark .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) { + --button-depth-color: rgb(255 255 255 / 0.08); +} + +.button.button-error:not(:disabled) { + --button-depth-color: var(--color-red-300); +} + +.dark .button.button-error:not(:disabled) { + --button-depth-color: var(--color-red-800); +} + +/* Keep the shared focus indicator when the depth shadow owns box-shadow. */ +.button:not(:disabled):focus-visible { + box-shadow: var(--button-depth), 0 0 0 1px var(--color-accent); +} + +.button:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: var(--button-depth-hover); +} + +.button:not(:disabled):hover:focus-visible { + box-shadow: var(--button-depth-hover), 0 0 0 1px var(--color-accent); +} + +/* Other button-like controls retain the lighter scale feedback. */ +.button, +.icon-button, +.app-tab, +.split-action-main, +.split-action-caret { + transition-property: color, background-color, border-color, box-shadow, transform; + transition-timing-function: var(--ease-out); + transition-duration: 120ms; +} + +.button { + transition-duration: 120ms, 120ms, 120ms, 80ms, 80ms; +} + +.icon-button:not(:disabled):active, +.app-tab:active, +.split-action-main:not(:disabled):active, +.split-action-caret:not(:disabled):active { + transform: scale(0.97); +} + +.button:not(:disabled):active { + transform: translateY(2px); + box-shadow: none; +} + +/* Focus border/ring on any form control should ease, not snap. The accent + focus indicator is a border-color and/or box-shadow (ring) change, which the + utilities' `transition-colors` did not fully animate. Target the elements + themselves (covers bespoke search fields too) plus the div-based controls. */ +input, +textarea, +select, +.listbox-trigger, +.chip-input { + transition-property: color, background-color, border-color, box-shadow; + transition-timing-function: var(--ease-out); + transition-duration: 120ms; } /* @@ -258,7 +359,7 @@ */ html, body { - @apply w-full min-h-full bg-gray-50 dark:bg-app dark:text-fg-dim; + @apply w-full min-h-full bg-neutral-50 dark:bg-app dark:text-fg-dim; } body { @@ -284,8 +385,16 @@ option { @apply dark:text-white dark:bg-coolgray-100; } -button[isError]:not(:disabled) { - @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white; +.button.button-error { + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800; +} + +.button.button-error:disabled { + @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800; +} + +.button-error:not(:disabled) { + @apply hover:bg-red-100 hover:text-red-900 dark:hover:bg-red-700 dark:hover:text-white; } button[isHighlighted]:not(:disabled) { @@ -505,25 +614,24 @@ html[data-theme="custom"] #nprogress .spinner-icon { .application-console-shell[data-console-theme="system"], .terminal-fullscreen-shell[data-console-theme="system"] { - --console-theme-background: #fff; - --console-theme-border: #d4d4d8; + --console-theme-background: oklch(100.0% 0 0); + --console-theme-border: oklch(87.11% 0.0055 286.29); --console-theme-opacity: 1; } -html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header { - background: transparent; - color: #52525b; - border-color: rgb(0 0 0 / 0.1); - backdrop-filter: none; -} - -html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] { +/* Session toolbar (selected-target trigger + label) sits over the white + system console in light mode. Without this its text-white/* stayed white on + white and the selected server was invisible (issue #11532). */ +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-toolbar[class*="text-white"], +html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-toolbar [class*="text-white"], +html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-toolbar[class*="text-white"], +html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-toolbar [class*="text-white"] { color: #52525b !important; } html.dark .application-console-shell[data-console-theme="system"], html.dark .terminal-fullscreen-shell[data-console-theme="system"] { - --console-theme-background: #121214; + --console-theme-background: oklch(18.31% 0.004 285.99); --console-theme-border: rgb(255 255 255 / 0.08); } @@ -533,6 +641,19 @@ html[data-theme="custom"] .terminal-fullscreen-shell[data-console-theme="system" --console-theme-border: var(--coollabs-line); } +/* Docked System console only: the fill behind the terminal panel is the + elevated (header) surface, so the panel's rounded-corner notch reads as the + header colour — the card's figure/ground. The panel paints its own base + surface over this fill, so only the notch shows it. The fullscreen shell has + no panel, so it is intentionally excluded and keeps its plain terminal fill. */ +.application-console-shell[data-console-theme="system"] { + --console-theme-background: var(--coollabs-elevated); +} + +html.dark .application-console-shell[data-console-theme="system"] { + --console-theme-background: var(--coollabs-elevated); +} + .console-theme-selector { scrollbar-color: rgb(161 161 170) transparent; scrollbar-width: thin; @@ -656,6 +777,10 @@ html.dark .console-theme-selector::-webkit-scrollbar-thumb { flex: 1 1 0%; flex-direction: column; overflow: hidden; + /* Breathing room so the console's box-shadow ring + lift are not clipped by + this overflow-hidden, viewport-height container (the card gets this from + the scrollable page around it). */ + padding: 6px; } .terminal-page-console > .application-console-shell { @@ -712,11 +837,71 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too touch-action: manipulation; } -.application-console-header { +/* Session header band: an attached card-style header on top of the console, + mirroring the page card header (elevated surface + hairline divider). The + System theme uses the neutral card surface in both modes; colorful themes + fall back to a translucent dark band so the white controls stay legible. */ +.terminal-session-toolbar { position: relative; z-index: 2; - background: rgb(0 0 0 / 0.24); - backdrop-filter: blur(20px); + flex-shrink: 0; + min-height: 3rem; + padding: 0.5rem 0.75rem 0.5rem 1rem; + /* No border-radius: the shell is overflow-hidden + rounded and clips the + header's top corners, so rounding here only risks a sub-pixel seam. */ + border-bottom: 1px solid var(--terminal-header-border, rgb(255 255 255 / 0.1)); + background: var(--terminal-header-bg, rgb(0 0 0 / 0.28)); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +/* System header has no bottom border: the body panel's ring is the divider, and + its rounded top corners tuck under the header — the same merge the + target-picker card uses (elevated header meeting a base body panel). */ +.application-console-shell[data-console-theme="system"] .terminal-session-toolbar { + --terminal-header-bg: var(--coollabs-elevated); + --terminal-header-border: transparent; + /* Shell no longer clips (overflow: visible), so the header rounds its own + top corners — matching the card header. */ + border-radius: 8px 8px 0 0; + /* Drop the z-index:2 stacking context: it painted the header OVER the body + panel, hiding the panel's ring (the divider). The header only had z-2 to + beat ::before, but its dropdowns sit in their own .relative wrappers, so + z:auto is safe and lets the body panel's ring paint over the header — + exactly how the card body overlaps the card header. */ + z-index: auto; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* System console body: the terminal panel, a full rounded box-shadow ring like + the card body. Its base surface is the terminal fill; the elevated surface + behind it (::before) shows only through the rounded-corner notch, reading as + the header colour — the card's figure/ground. */ +.application-console-shell[data-console-theme="system"] .terminal-session-panel { + background: var(--coollabs-base); + /* The body panel is a full rounded ring (like the card body). position: + relative puts it (and its ring) above ::before AND, because it comes + after the now-z:auto header in the DOM, above the header — so the ring's + top edge is the divider and its rounded top corners connect down into the + side ring, which coincides with the shell's ring (one hairline, no + doubling). A box-shadow ring rounds and connects cleanly; a border-top + cannot (square corners, or a disconnected transparent edge). */ + position: relative; + border-radius: 8px; + box-shadow: 0 0 0 1px var(--coollabs-hairline); +} + +/* Connected System console frame = the target-picker card recipe: a hairline + box-shadow ring + a restrained lift. overflow stays visible so the ring is + not clipped (the .terminal-page-console padding gives it room inside the + viewport-locked, overflow-hidden page). Colorful themes keep the themed, + overflow-hidden shell. */ +.application-console-shell[data-console-theme="system"] { + overflow: visible; + box-shadow: + 0 0 0 1px var(--coollabs-hairline), + 0 1px 2px 0 rgb(0 0 0 / 0.05); } .terminal-loading-label { @@ -824,7 +1009,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina } html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-panel { - background: transparent; + background: var(--coollabs-base); } .terminal-target-list { @@ -851,7 +1036,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina color: #52525b; background: #fff; border-color: #d4d4d8; - box-shadow: 0 18px 50px rgb(0 0 0 / 0.18); + box-shadow: var(--shadow-dropdown); } html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-target-picker [class*="text-white"] { @@ -924,75 +1109,166 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina * utility classes placed in the card markup. */ :root { - --coollabs-canvas: oklch(98.75% 0 0); + /* Page canvas is a faint off-white so white/elevated cards lift off it + (shadcn pattern), instead of card and page reading as one flat color. */ + --coollabs-canvas: oklch(97% 0 0); --coollabs-elevated: oklch(98% 0 0); --coollabs-recessed: oklch(96% 0 0); - --coollabs-base: #ffffff; + --coollabs-base: oklch(100.0% 0 0); --coollabs-fill: oklch(92.2% 0 0); --coollabs-line: oklch(14.5% 0 0 / 0.1); - --coollabs-hairline: oklch(93.5% 0 0); - --coollabs-subtle: oklch(55.6% 0 0); + /* Card/table edge ring. Crisped from 93.5% (1.17:1 on canvas, invisible) + to 85.5% (1.51:1) so surfaces read as bordered cards, not flat fills. */ + --coollabs-hairline: oklch(85.5% 0 0); + /* Muted labels/titles. Darkened from 55.6% (failed WCAG AA on elevated + 4.46, recessed 4.24, fill 3.76) to 50% so muted text clears 4.5:1 on + every light surface above white. Dark stays 70.8% (already passes). */ + --coollabs-subtle: oklch(50% 0 0); } .dark { - --color-accent: #fcd452; - --coollabs-canvas: oklch(10% 0 0); - /* elevated (card shells/headers) and recessed (input fills) sit a touch - above the canvas so they read lighter than near-black */ - --coollabs-elevated: oklch(15% 0 0); - --coollabs-recessed: oklch(20% 0 0); - --coollabs-base: oklch(17% 0 0); - --coollabs-fill: oklch(26.9% 0 0); + --color-accent: oklch(88.13% 0.1507 91.7); + /* Dark surface ladder in hex (sRGB), not oklch: oklch lightness compresses + to near-black below ~15%, so oklch(15%) rendered as sRGB 11 and cards + could not lift off the content canvas (sRGB 10). These give clear, + visible steps: content 10 -> elevated 22 -> base 28 -> recessed 34. + Pure neutral (r=g=b), matching the neutral borders/text and the light + ladder, so every panel shares one temperature (no blue cast). */ + --coollabs-canvas: oklch(14.48% 0 0); + --coollabs-elevated: oklch(20.02% 0 0); + --coollabs-recessed: oklch(25.2% 0 0); + --coollabs-base: oklch(22.64% 0 0); + --coollabs-fill: oklch(29.31% 0 0); --coollabs-line: oklch(32% 0 0); - --coollabs-hairline: oklch(26.9% 0 0); + /* Crisped from 26.9% (1.36:1 on canvas) to 32% (1.63:1) so dark cards + and tables show a visible edge instead of blending into the canvas. */ + --coollabs-hairline: oklch(32% 0 0); --coollabs-subtle: oklch(70.8% 0 0); - --color-nav-text: #a8a8b0; - --color-nav-muted: #7a7a84; - --color-nav-active: #f2f2f2; + /* Neutral (was #a8a8b0 / #7a7a84, both cool-tinted). Sidebar text now shares + the same neutral temperature as the content text, so the sidebar no longer + reads cooler than the panels/cards. */ + --color-nav-text: oklch(73.8% 0 0); + /* Raised from 58.97% (4.47:1 on panel, just under AA) to 61% (4.86:1) so the + 11px sidebar section labels clear 4.5:1. */ + --color-nav-muted: oklch(61% 0 0); + --color-nav-active: oklch(96.12% 0 0); + /* Tertiary text. Base token (59.31%) only clears 4.5:1 on app/panel; it + failed on surface/raised/selected (4.46/4.20/3.73). Raised to 64% so the + 313 dark:text-fg-faint usages clear AA across the whole surface ladder, + while staying clearly dimmer than fg-dim (76.99%). */ + --color-fg-faint: oklch(65% 0 0); + /* Error text. The base error red (57.71%) failed on every dark surface + (4.10/3.75/3.53). Brightened for dark mode so text-error clears AA on the + card ladder while staying unmistakably red. */ + --color-error: oklch(66% 0.2 27.33); +} + +/* Light-mode semantic text. + warning / success / error double as the bright brand accent and as solid + fills, which only appear under `dark:` — so in light mode these tokens are + used almost entirely as text, translucent tints (bg-warning/10) and borders. + The bright values (yellow 88%, green 72%) and the base red (57.71%) fail AA + as text on white and the light surface ladder (warning 1.43, success 2.28, + error 4.43 on canvas). Scoped to `:not(.dark)` so dark keeps the brand + colors and the custom theme keeps its own overrides. Every value clears + 4.5:1 on white, canvas, elevated, recessed and fill, and is in sRGB gamut. */ +html:not(.dark) { + --color-warning: oklch(50% 0.105 80); + --color-success: oklch(48% 0.135 149.58); + --color-error: oklch(52% 0.2 27.33); } /* Theme surfaces are derived from one brand color. Changing --theme-base-color is enough to generate a complete dark surface ladder. */ html[data-theme="custom"] { - --theme-base-color: #6b16ed; - --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white); + --theme-base-color: oklch(49.65% 0.2709 289.33); + --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0)); --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground)); - --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b); - --theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color)); + --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79)); + /* Accent text/icons sit directly on dark surfaces. Force the lightness into a + readable band (and tame chroma) so any picked hue keeps enough contrast. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.72, l, 0.86) min(c, 0.13) h); + /* Placeholders read as a light, near-neutral hint of the hue - never the raw + (possibly dark) base color, which is invisible on the dark input. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.62 min(c, 0.03) h); --color-accent: var(--theme-bright-color); --color-coollabs: var(--theme-bright-color); - --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, white); - --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, black); - --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, black); + --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(100% 0 0)); + --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(0% 0 0)); + --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, oklch(0% 0 0)); /* Legacy dark-theme accent utilities use warning as the brand color. */ --color-warning: var(--theme-bright-color); --color-accent-foreground: var(--theme-accent-foreground); - --color-app: color-mix(in oklab, var(--theme-base-color) 12%, #09090a); - --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, #0c0c0d); - --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, #101011); - --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, #141416); - --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --color-app: color-mix(in oklab, var(--theme-base-color) 12%, oklch(14.03% 0.0022 286.11)); + --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, oklch(15.48% 0.0021 286.15)); + --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, oklch(17.35% 0.0020 286.18)); + --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, oklch(19.21% 0.0040 286.02)); + --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --color-coolgray-100: var(--color-surface); --color-coolgray-200: var(--color-raised); --color-coolgray-300: var(--color-selected); - --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, #1b1b1e); - --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, #202024); + --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, oklch(22.34% 0.0058 285.92)); + --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, oklch(24.52% 0.0075 285.83)); --coollabs-canvas: var(--color-app); --coollabs-elevated: var(--color-surface); --coollabs-recessed: var(--color-raised); - --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, #111112); + --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, oklch(17.81% 0.0020 286.19)); --color-content-surface: var(--coollabs-base); - --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --coollabs-line: var(--theme-border-color); --coollabs-hairline: color-mix(in srgb, var(--theme-border-color) 55%, var(--color-panel)); - --color-nav-text: #e4dfea; - --color-nav-muted: #d8d2df; - --color-nav-active: #ffffff; - --color-log: color-mix(in oklab, var(--theme-base-color) 20%, #080809); - --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, #101011); - --color-fg-dim: #e4dfea; - --color-fg-faint: #d8d2df; - --coollabs-subtle: #d8d2df; + --color-nav-text: oklch(91.08% 0.0157 306.4); + --color-nav-muted: oklch(87.24% 0.0188 306.63); + --color-nav-active: oklch(100.0% 0 0); + --color-log: color-mix(in oklab, var(--theme-base-color) 20%, oklch(13.49% 0.0024 286.07)); + --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, oklch(17.35% 0.0020 286.18)); + --color-fg-dim: oklch(91.08% 0.0157 306.4); + --color-fg-faint: oklch(87.24% 0.0188 306.63); + --coollabs-subtle: oklch(87.24% 0.0188 306.63); +} + +/* Light custom theme: the same single brand color, but surfaces tint toward + white and every foreground token flips to a dark, readable band. This selector + outranks the plain html:not(.dark) light palette, so it wins where they meet. + Accent fills (--theme-bright-color, --color-warning/coollabs) are inherited from + the block above so the JS-computed --theme-accent-foreground stays consistent. */ +html[data-theme="custom"]:not(.dark) { + --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-base-color) 45%, oklch(60% 0 0)); + /* Accent text/icons on light surfaces: force lightness into a dark band. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.30, l, 0.48) min(c, 0.16) h); + /* Placeholder: medium-dark, near-neutral hint of the hue. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + /* Surfaces hold a fixed high lightness (stays light, keeps dark text readable) + while injecting the base hue's chroma, so the tint is actually visible. + color-mix into white coupled lightness to the base and washed the tint out + (a 4% mix of any color into near-white is imperceptible). */ + --color-app: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-panel: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --color-surface: oklch(from var(--theme-base-color) 98% min(c, 0.03) h); + --color-raised: oklch(from var(--theme-base-color) 95.5% min(c, 0.04) h); + --color-selected: oklch(from var(--theme-base-color) 92% min(c, 0.05) h); + --color-coolgray-100: var(--color-surface); + --color-coolgray-200: var(--color-raised); + --color-coolgray-300: var(--color-selected); + --color-coolgray-400: oklch(from var(--theme-base-color) 90% min(c, 0.05) h); + --color-coolgray-500: oklch(from var(--theme-base-color) 87% min(c, 0.055) h); + --coollabs-canvas: var(--color-app); + --coollabs-elevated: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --coollabs-recessed: var(--color-raised); + --coollabs-base: oklch(from var(--theme-base-color) 99.3% min(c, 0.018) h); + --color-content-surface: var(--coollabs-base); + --coollabs-fill: oklch(from var(--theme-base-color) 91% min(c, 0.05) h); + --coollabs-line: oklch(from var(--theme-base-color) 82% min(c, 0.055) h); + --coollabs-hairline: oklch(from var(--theme-base-color) 88% min(c, 0.04) h); + --color-nav-text: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-nav-muted: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --color-nav-active: oklch(from var(--theme-base-color) 0.22 min(c, 0.05) h); + --color-log: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-log-toolbar: oklch(from var(--theme-base-color) 93% min(c, 0.045) h); + --color-fg: oklch(from var(--theme-base-color) 0.22 min(c, 0.04) h); + --color-fg-dim: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-fg-faint: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --coollabs-subtle: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); } html[data-theme="custom"] .control-selected, @@ -1051,7 +1327,55 @@ html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] { html[data-theme="custom"] input::placeholder, html[data-theme="custom"] textarea::placeholder { color: var(--theme-placeholder-color) !important; - opacity: 0.7; + /* The token is already a readable light tone; no extra opacity dampening, + which would drop it back below the contrast floor. */ + opacity: 1; +} + +/* Accent text/icons (links, badges, "update available" pill, check marks) use the + brand color as foreground on dark surfaces. Route them to the clamped, always + readable token; fills (bg-*) keep the brighter accent + its computed foreground. */ +html[data-theme="custom"] :is( + [class~="text-coollabs"], + [class~="text-warning"], + [class~="text-accent"], + [class~="dark:text-coollabs"], + [class~="dark:text-warning"] +) { + color: var(--theme-fg-on-surface) !important; +} + +html[data-theme="custom"] [class~="hover:text-coollabs"]:hover, +html[data-theme="custom"] [class~="hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-coollabs"]:hover { + color: var(--theme-fg-on-surface) !important; +} + +/* Light custom theme reaches raw neutral utilities too. Dark mode tints + everything through the pervasive dark:bg-*/dark:border-* utilities (which + resolve to --color-* vars the block overrides); light mode has no equivalent, + so components fall back to hardcoded neutrals. Remap the dominant light + surface/border utilities to the tinted custom vars so the color reaches the + whole UI, not just design-system classes. */ +html[data-theme="custom"]:not(.dark) :is([class~="bg-white"], [class~="bg-white/95"], [class~="bg-white/80"]) { + background-color: var(--coollabs-elevated) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="bg-neutral-50"] { + background-color: var(--coollabs-canvas) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="bg-neutral-100"], [class~="bg-neutral-200"]) { + background-color: var(--coollabs-fill) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="border-neutral-200"] { + border-color: var(--coollabs-hairline) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="border-neutral-300"], [class~="border-neutral-100"]) { + border-color: var(--coollabs-line) !important; } html[data-theme="custom"] input:read-only, @@ -1318,27 +1642,6 @@ html[data-theme="custom"] textarea:disabled { margin-top: 1.75rem; } -.error-contact-link { - display: inline-flex; - align-items: center; - gap: 0.25rem; - margin-left: 0.25rem; - font-size: 0.8125rem; - font-weight: 500; - color: #525252; - text-underline-offset: 3px; - transition: color 0.15s ease; -} - -.dark .error-contact-link { - color: var(--color-fg-dim); -} - -.error-contact-link:hover { - color: var(--color-accent); - text-decoration: underline; -} - /* Layer card root: elevated shell with a hairline ring. overflow stays visible so floating panels (listbox, tooltips) can escape the card. */ .application-settings-section { @@ -1349,7 +1652,9 @@ html[data-theme="custom"] textarea:disabled { border-radius: 8px; border: none; background: var(--coollabs-elevated); - box-shadow: 0 0 0 1px var(--coollabs-hairline); + /* crisp ring + a restrained lift so the card separates from the canvas + (shadcn-style), not a heavy floating shadow */ + box-shadow: 0 0 0 1px var(--coollabs-hairline), 0 1px 2px 0 rgb(0 0 0 / 0.05); /* keep anchored scrolls (scrollIntoView) clear of the fixed topbar + layer-2 nav */ scroll-margin-top: 7rem; } @@ -1415,7 +1720,14 @@ html[data-theme="custom"] textarea:disabled { font-weight: 500; line-height: 1.25rem; letter-spacing: 0; - color: inherit; + /* Card title is a heading, so give it a readable strength instead of the + muted --coollabs-subtle (5.66:1 light / 6.98:1 dark). ~8.7:1 in both + modes (comfortable AAA) while descriptions/labels stay muted. */ + color: oklch(40% 0 0); +} + +.dark .application-settings-section > :is(header, .application-settings-section-header) :is(h2, h3) { + color: var(--color-fg-dim); } .application-settings-section > .application-settings-section-header p { @@ -1425,12 +1737,16 @@ html[data-theme="custom"] textarea:disabled { color: var(--coollabs-subtle); } -/* Layer card body: nested rounded panel with base bg + fill ring */ +/* Layer card body: nested rounded panel with base bg. Ring uses the same + --coollabs-hairline as the card shell (line ~1456) so the body edge and the + header edge read as one continuous border instead of two different grays + (fill was lighter than hairline, so the header sides and body sides did not + match). */ .application-settings-section-body { position: relative; border-radius: 8px; background: var(--coollabs-base); - box-shadow: 0 0 0 1px var(--coollabs-fill); + box-shadow: 0 0 0 1px var(--coollabs-hairline); padding: 1rem; } @@ -1469,28 +1785,71 @@ html[data-theme="custom"] textarea:disabled { } @media (min-width: 1280px) { + /* Fixed like the main sidebar (3rem → bottom); left tracks its width (--sidebar-w). + Fixed leaves the grid, so the content sibling is pinned to column 2 below. */ .application-settings-navigation { - position: sticky; - top: calc(3rem + 1.75rem); - align-self: start; - max-height: calc(100dvh - 5.5rem); - padding-right: 0.375rem; + position: fixed; + top: 3rem; + left: var(--sidebar-w, 14rem); + width: calc(210px + 2.5rem); + height: calc(100dvh - 3rem); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; + scrollbar-color: var(--coollabs-fill) transparent; + scrollbar-gutter: stable; + --rail-fill: var(--coollabs-elevated); + padding: 0.5rem; + background: var(--rail-fill); + border-right: 1px solid var(--coollabs-hairline); + transition: left 200ms ease; } - /* Resource pages have a second fixed header row below the main header. */ - .application-settings-workspace > .application-settings-navigation { - top: 4rem; - max-height: calc(100dvh - 5rem); + .application-settings-navigation ~ * { + grid-column: 2; } + html:not(.dark) .application-settings-navigation { + --rail-fill: var(--coollabs-base); + } + + .application-settings-navigation::-webkit-scrollbar { + width: 6px; + } + + .application-settings-navigation::-webkit-scrollbar-track { + background: transparent; + } + + .application-settings-navigation::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: var(--coollabs-fill); + } + + .application-settings-navigation::-webkit-scrollbar-thumb:hover { + background: var(--coollabs-line); + } + + /* Embeds: keep the surface, drop the fixed positioning. */ .application-settings-navigation.is-flush { position: static; - max-height: none; + inset: auto; + width: auto; + height: auto; overflow: visible; + transition: none; + } +} + +/* Honor reduced-motion for the settings nav disclosure/accordion and the mobile + sidebar sheet: keep open/close functional but drop the height/slide/fade. */ +@media (prefers-reduced-motion: reduce) { + .application-settings-navigation, + .application-settings-navigation *, + .mobile-sidebar-sheet * { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; } } @@ -1560,7 +1919,7 @@ html[data-theme="custom"] textarea:disabled { .application-settings-form .input:focus-visible, .application-settings-form .select:focus-visible { border-color: var(--color-accent); - box-shadow: 0 0 0 1px var(--color-accent) !important; + box-shadow: none !important; } .application-settings-workspace textarea.input, @@ -1603,7 +1962,7 @@ html[data-theme="custom"] textarea:disabled { * Circular chevrons sit over gradient fades when more tabs are off-screen. */ .resource-heading-tabs-scroller { - --resource-heading-tabs-fade: #f5f5f5; /* neutral-100, matches pill track */ + --resource-heading-tabs-fade: oklch(97.02% 0 0); /* neutral-100, matches pill track */ } .dark .resource-heading-tabs-scroller { @@ -1662,7 +2021,7 @@ html[data-theme="custom"] textarea:disabled { margin-inline: 0.25rem; border-radius: 9999px; color: #737373; - background: #ffffff; + background: var(--coollabs-base); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.08); @@ -1736,20 +2095,6 @@ html[data-theme="custom"] textarea:disabled { box-shadow: none !important; } -.resource-heading-overflow-separator { - width: 1px; - align-self: stretch; - margin: 0.25rem 0.25rem; - background: color-mix(in srgb, var(--coollabs-line) 80%, transparent); -} - -.resource-heading-overflow.is-collapsed .resource-heading-overflow-separator { - width: auto; - height: 1px; - align-self: auto; - margin: 0.25rem 0.375rem; -} - .resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button, .resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button { width: 100%; @@ -1870,6 +2215,61 @@ html[data-theme="custom"] textarea:disabled { flex-shrink: 0; } +.split-action { + display: inline-flex; +} + +.split-action-main, +.split-action-caret { + @apply button-highlighted; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.split-action-main { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.625rem; + border-radius: 6px 0 0 6px; +} + +.split-action-caret { + flex-shrink: 0; + width: 1.75rem; + border-left: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 0 6px 6px 0; +} + +.split-action > .split-action-main:only-of-type { + border-radius: 6px; +} + +.split-action-main:disabled, +.split-action-caret:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.split-action-main:focus-visible, +.split-action-caret:focus-visible { + outline: none; + position: relative; + z-index: 1; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-accent) 40%, transparent); +} + +.application-heading-actions .split-action-main, +.application-heading-actions .split-action-caret { + height: 1.75rem; +} + /* Custom listbox (replaces native