Merge remote-tracking branch 'origin/main' into feat/services

This commit is contained in:
peaklabs-dev
2026-09-08 12:31:43 +02:00
251 changed files with 8519 additions and 2002 deletions
-1
View File
@@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
BUNNY_API_KEY=
# For asset uploads
BUNNY_STORAGE_API_KEY=
AVATAR_CDN_URL=
-1
View File
@@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
PUSHER_APP_ID=coolify
PUSHER_APP_KEY=coolify
PUSHER_APP_SECRET=coolify
AVATAR_CDN_URL=
+72 -6
View File
@@ -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 `<x-reicon>`;
- 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 `<main>` 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.
@@ -0,0 +1,221 @@
<?php
namespace App\Actions\Stripe;
use App\Exceptions\CheckoutUnavailableException;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\StripeClient;
use Throwable;
class CreateCheckoutSession
{
private const BLOCKING_SUBSCRIPTION_STATUSES = [
'active',
'incomplete',
'past_due',
'paused',
'trialing',
'unpaid',
];
private const RECOVERABLE_SUBSCRIPTION_STATUSES = [
'incomplete',
'past_due',
'paused',
'unpaid',
];
public function __construct(private ?StripeClient $stripe = null)
{
$this->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;
}
}
+2
View File
@@ -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
@@ -0,0 +1,18 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class CheckoutUnavailableException extends Exception
{
public function __construct(
string $message = '',
public readonly ?string $billingPortalUrl = null,
int $code = 0,
?Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}
+7
View File
@@ -4,9 +4,11 @@ namespace App\Exceptions;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Session\TokenMismatchException;
use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
@@ -69,6 +71,11 @@ class Handler extends ExceptionHandler
*/
public function render($request, Throwable $e)
{
// A duplicate login or 2FA submission carries a stale token on an already authenticated session, see https://github.com/coollabsio/coolify/issues/10670
if ($e instanceof TokenMismatchException && $request->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()) {
@@ -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<string, array{domain: ?string, redirect?: string}> $domains
* @param array<string, int|string>|null $existingOverrides
* @return array{Collection<string, array{domain: ?string, redirect?: string}>, ?array<string, int>}
*/
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']];
}
}
@@ -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,
@@ -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',
@@ -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',
];
@@ -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',
]);
}
}
@@ -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',
]);
}
}
+8 -2
View File
@@ -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'.");
}
@@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\Database\BackupMissing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
ScheduledDatabaseBackup::query()
->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();
}
}
+13 -2
View File
@@ -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');
+74 -22
View File
@@ -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()) {
@@ -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;
}
@@ -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;
+46 -13
View File
@@ -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;
+8 -3
View File
@@ -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'));
@@ -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]
@@ -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 = [];
@@ -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.');
@@ -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) {
@@ -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 {
@@ -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();
];
});
}
}
+43 -4
View File
@@ -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');
+24 -12
View File
@@ -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 {
@@ -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);
@@ -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');
@@ -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.');
}
+50 -1
View File
@@ -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,
),
]);
}
}
+30
View File
@@ -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');
+5
View File
@@ -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!');
+35 -3
View File
@@ -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()
+34 -47
View File
@@ -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 .= ' <a href="'.e($exception->billingPortalUrl).'" target="_blank" rel="noopener noreferrer" class="underline">Open billing portal</a>';
}
$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);
}
+14 -7
View File
@@ -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<int>
*/
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
+1
View File
@@ -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',
];
+4
View File
@@ -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()
@@ -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',
+1 -1
View File
@@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel
'started_at' => 'datetime',
'finished_at' => 'datetime',
'retry_count' => 'integer',
'duration' => 'decimal:2',
'duration' => 'float',
];
}
+4 -1
View File
@@ -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');
+63 -3
View File
@@ -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;
}
}
@@ -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,
];
}
}
@@ -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,
@@ -0,0 +1,83 @@
<?php
namespace App\Notifications\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
class BackupMissing extends CustomEmailNotification
{
public string $databaseName;
public function __construct(public ScheduledDatabaseBackup $backup, public ?Carbon $lastExecutionAt)
{
$this->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(),
];
}
}
@@ -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(),
];
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace Illuminate\Notifications;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
interface Notification
{
public function toMail(SendsEmail $notifiable): MailMessage;
public function toPushover(): PushoverMessage;
public function toDiscord(): DiscordMessage;
public function toSlack(): SlackMessage;
public function toTelegram();
}
@@ -74,4 +74,16 @@ class ForceDisabled extends CustomEmailNotification
color: SlackMessage::errorColor()
);
}
public function toWebhook(): array
{
return [
'success' => 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,
];
}
}
+12
View File
@@ -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,
];
}
}
@@ -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',
];
}
}
@@ -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(),
];
}
}
+2 -7
View File
@@ -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
+3
View File
@@ -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';
}
}
+21 -3
View File
@@ -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 = [])
+6 -38
View File
@@ -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
+7 -26
View File
@@ -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:
+7 -2
View File
@@ -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;
+2 -3
View File
@@ -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'),
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->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',
]);
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('image_cdn_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('image_cdn_url');
});
}
};
+3 -3
View File
@@ -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']);
}
}
+1 -2
View File
@@ -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',
]);
}
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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;
}
@@ -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"
@@ -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;
@@ -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;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.3.17"
"version": "4.3.19"
},
"nightly": {
"version": "4.4-rc.1"
+683 -252
View File
File diff suppressed because it is too large Load Diff
+23 -12
View File
@@ -15,7 +15,7 @@
}
@utility apexcharts-tooltip-custom {
@apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm;
@apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-dropdown p-3 text-sm;
min-width: 160px;
}
@@ -59,7 +59,7 @@
/* input, select before */
@utility input-select {
@apply block h-9 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-white dark:bg-surface dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint;
@apply block h-9 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-[var(--coollabs-recessed)] dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint;
box-shadow: none;
&:where(.dark, .dark *) {
@@ -77,18 +77,18 @@
/* Readonly */
@utility input {
@apply dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply input-select;
@apply focus-visible:outline-none;
&:focus-visible {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
box-shadow: none;
}
&:where(.dark, .dark *):focus-visible {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
box-shadow: none;
}
&:read-only {
@@ -116,17 +116,17 @@
&:focus-visible {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
box-shadow: none;
}
&:where(.dark, .dark *):focus-visible {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
box-shadow: none;
}
}
@utility button {
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black capitalize rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
}
@utility button-highlighted {
@@ -162,7 +162,7 @@
}
@utility auth-tooltip {
@apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
@apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-dropdown dark:text-fg dark:bg-raised dark:border-white/10;
}
@utility alert-success {
@@ -222,7 +222,10 @@
}
@utility menu-item {
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
/* Translucent hover (like the active pill) so it darkens against any surface,
including the light-mode settings rail's gray fill; on white it matches the
old neutral-100. */
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-item-icon {
@apply shrink-0 size-[18px] opacity-90;
@@ -245,7 +248,7 @@
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-subitem-active {
@apply rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06];
@@ -344,7 +347,7 @@
}
@utility info-helper-popup {
@apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-modal whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim;
@apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-dropdown whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim;
}
@utility buyme {
@@ -367,6 +370,14 @@
@apply z-[1];
}
/* Floating notice / popover surface: elevated fill, hairline ring, and the
shared dropdown lift. Toasts, change-pending popovers and the deployments
list share this so they match menus/dropdowns instead of the heavier modal. */
@utility surface-popover {
background: var(--coollabs-elevated);
box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-dropdown);
}
@utility dz-button {
@apply p-4 py-10 my-4 w-full font-bold bg-white border dark:border-coolgray-400 dark:text-white dark:bg-transparent dark:hover:bg-coolgray-400;
}
+13
View File
@@ -124,3 +124,16 @@ window.scrollToSettingsSection = function scrollToSettingsSection(id) {
rafId = window.requestAnimationFrame(tick);
};
// When a settings sub-section link navigates across pages (href="route#section-id"),
// scroll to that section once the destination page has rendered.
function scrollToHashSettingsSection() {
const hash = window.location.hash;
if (!hash || hash.length < 2) {
return;
}
const id = decodeURIComponent(hash.slice(1));
window.requestAnimationFrame(() => window.scrollToSettingsSection?.(id));
}
document.addEventListener('livewire:navigated', scrollToHashSettingsSection);
document.addEventListener('DOMContentLoaded', scrollToHashSettingsSection);
@@ -2,6 +2,14 @@
<x-auth.shell title="Coolify" description="Verify your identity to finish signing in.">
<div class="flex flex-col gap-4" x-data="{
showRecovery: false,
submitting: false,
handleSubmit(event) {
if (this.submitting) {
event.preventDefault();
}
this.submitting = true;
},
submitAuthenticatorCode(event) {
event.target.value = event.target.value.replace(/\D/g, '').slice(0, 6);
@@ -30,7 +38,8 @@
<p x-show="showRecovery" x-cloak>Enter one of the recovery codes you saved when setting up two-factor authentication.</p>
</div>
<form x-ref="challengeForm" action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
<form x-ref="challengeForm" action="/two-factor-challenge" method="POST" class="flex flex-col gap-4"
@submit="handleSubmit($event)">
@csrf
<div x-show="!showRecovery" class="flex flex-col gap-3">
@@ -54,7 +63,7 @@
</button>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
<x-forms.button class="w-full justify-center" type="submit" x-bind:disabled="submitting" isHighlighted>
Verify and continue
</x-forms.button>
</form>
@@ -238,52 +238,104 @@
];
@endphp
@php
$activeMenuLabel = collect($groupedMenuItems)->flatMap(fn ($items) => $items)->firstWhere('active', true)['label'] ?? 'Settings';
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])>
<nav aria-label="Configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
])
x-data="{
menuOpen: false,
desktop: window.matchMedia('(min-width: 1280px)').matches,
init() {
const mq = window.matchMedia('(min-width: 1280px)');
mq.addEventListener('change', (e) => { this.desktop = e.matches; if (e.matches) { this.menuOpen = false; } });
},
}"
x-on:click.outside="menuOpen = false"
x-on:keydown.escape.window="menuOpen = false">
{{-- Mobile disclosure: tap to reveal the full grouped nav; hidden at xl (the pinned rail). --}}
<button type="button" x-show="!desktop" x-cloak x-on:click="menuOpen = !menuOpen"
:aria-expanded="menuOpen" aria-label="Configuration menu"
:class="menuOpen && 'ring-1 ring-black/10 dark:ring-white/15'"
class="flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-neutral-200 bg-white px-3 text-[13px] font-medium text-black transition-transform duration-100 ease-out hover:bg-neutral-50 active:scale-[0.985] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg dark:hover:bg-white/[0.08]">
<span class="flex min-w-0 items-center gap-2">
<x-reicon name="settings" class="size-4 shrink-0 text-nav-muted" />
<span class="truncate">{{ $activeMenuLabel }}</span>
</span>
<svg class="size-3.5 shrink-0 text-nav-muted transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
:class="menuOpen && 'rotate-180'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<nav aria-label="Configuration sections" x-show="desktop || menuOpen" x-collapse.duration.200ms x-cloak
class="mt-2 flex flex-col gap-0.5 rounded-xl border border-neutral-200 bg-white p-2 shadow-[var(--shadow-dropdown)] dark:border-white/[0.08] dark:bg-white/[0.03] xl:mt-0 xl:rounded-none xl:border-0 xl:bg-transparent xl:p-0 xl:shadow-none xl:dark:bg-transparent">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
<div class="my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<div class="nav-section">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application"
:key="'application-server-status-'.$application->uuid" />
</span>
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
@if (filled($sections)) x-data="{ open: @js($menuItem['active']), activeSection: '' }" @endif
class="relative">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
'pr-9' => filled($sections),
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}"
x-on:click="menuOpen = false">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application"
:key="'application-server-status-'.$application->uuid" />
</span>
@endif
</a>
@if (filled($sections))
{{-- Expand/collapse the page's sub-sections. Active page opens by
default; the label still navigates, the chevron only toggles. --}}
<button type="button"
class="absolute right-1 top-1 flex size-6 items-center justify-center rounded-md text-nav-muted transition-colors hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.06]"
x-on:click.stop.prevent="open = !open" :aria-expanded="open"
aria-label="Toggle {{ $menuItem['label'] }} sections">
<svg class="size-3.5 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
:class="open && 'rotate-90'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m4.5 3 3 3-3 3" stroke="currentColor" stroke-width="1.25"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div>
<div x-show="open" x-collapse.duration.200ms x-cloak
class="nav-children flex flex-col gap-0.5 py-1">
@foreach ($sections as $section)
@if ($menuItem['active'])
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
x-on:click="menuOpen = false; activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@else
<a class="menu-subitem"
href="{{ route($menuItem['route'], $applicationRouteParameters) }}#{{ $section['id'] }}"
{{ wireNavigate() }} x-on:click="menuOpen = false">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</a>
@endif
@endforeach
</div>
</div>
@endif
</a>
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{
activeSection: '',
scrollToSection(id) {
this.activeSection = id;
window.scrollToSettingsSection?.(id);
},
}">
@foreach ($pageSections[$menuItem['route']] as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
</div>
@endforeach
@endforeach
</nav>
@@ -2,7 +2,7 @@
<div class="mx-auto mb-6 w-full max-w-3xl">
<div
class="grid grid-cols-3 overflow-hidden rounded-[10px] border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-white/[0.025]">
class="grid grid-cols-3 overflow-hidden rounded-[10px] border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-white/[0.05]">
@for ($i = 1; $i <= $totalSteps; $i++)
<div @class([
'flex min-h-10 items-center justify-center gap-2 border-r border-neutral-200 px-3 text-[11px] font-medium last:border-r-0 dark:border-white/[0.08]',
@@ -9,8 +9,7 @@
</button>
<div x-show="open" x-cloak x-transition.opacity role="dialog"
class="fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
class="surface-popover fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0">
<div class="flex items-start gap-2.5">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
@@ -60,15 +60,21 @@
</svg>
</button>
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
:style="panelStyles" @class([
'origin-top' => true,
'mt-1 w-full' => $inline,
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
]) x-cloak>
<div @class([
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
'shadow-sm dark:bg-coolgray-200' => ! $inline,
'shadow-[var(--shadow-dropdown)] dark:bg-coolgray-200' => ! $inline,
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
$panelClass,
])>
@@ -80,6 +80,10 @@
</button>
@if ($authDisabled || filled($tooltip))
<div x-ref="tip" x-show="visible" x-cloak :id="$id('button-tooltip')" role="tooltip"
x-transition:enter="transition-opacity ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-75"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="auth-tooltip">
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
</div>
@@ -49,7 +49,7 @@
@endif
<span
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white shadow-[inset_0_1px_1px_rgb(0_0_0/0.04)] transition-colors group-hover:border-neutral-400 peer-checked:border-coollabs peer-checked:bg-coollabs peer-focus-visible:ring-2 peer-focus-visible:ring-coollabs/25 peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 dark:border-white/[0.14] dark:bg-white/[0.045] dark:shadow-none dark:group-hover:border-white/[0.22] dark:peer-checked:border-warning dark:peer-checked:bg-warning dark:peer-focus-visible:ring-warning/30 dark:peer-focus-visible:ring-offset-base"></span>
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white shadow-[inset_0_1px_1px_rgb(0_0_0/0.04)] transition-[color,background-color,border-color,box-shadow] duration-150 ease-out group-hover:border-neutral-400 peer-checked:border-coollabs peer-checked:bg-coollabs peer-focus-visible:ring-2 peer-focus-visible:ring-coollabs/25 peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 dark:border-white/[0.14] dark:bg-white/[0.045] dark:shadow-none dark:group-hover:border-white/[0.22] dark:peer-checked:border-warning dark:peer-checked:bg-warning dark:peer-focus-visible:ring-warning/30 dark:peer-focus-visible:ring-offset-base"></span>
<svg class="pointer-events-none absolute inset-0 m-auto size-3 scale-75 text-white opacity-0 transition-[opacity,transform] peer-checked:scale-100 peer-checked:opacity-100 dark:text-black"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8"
@@ -132,7 +132,7 @@
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-dropdown max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
@@ -277,7 +277,7 @@
{{-- Dropdown Options --}}
<div x-show="open && !{{ $disabled ? 'true' : 'false' }}" x-transition
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg max-h-60 overflow-auto scrollbar">
class="absolute z-50 w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-dropdown max-h-60 overflow-auto scrollbar">
<template x-if="filteredOptions.length === 0">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
@@ -123,7 +123,8 @@
{{ $attributes->whereStartsWith('x-model') }}
{{ $attributes->whereStartsWith('x-effect') }}
@if ($preserveValue) wire:ignore @endif
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
@click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()"
@scroll.window.capture="open && positionPanel()">
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" @if ($tooltip) :title="current" @endif>
@@ -134,33 +135,35 @@
</svg>
</button>
@if ($portal)
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
<template x-teleport="body">
<div id="{{ $panelId }}" class="listbox-panel"
style="position: fixed; z-index: 9999; visibility: hidden" x-show="open"
x-cloak :style="{ visibility: positioned ? 'visible' : 'hidden' }"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
x-effect="if (open) requestAnimationFrame(() => positionPanel($el))" role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
:aria-selected="String(option.value) === String(value)" @click="choose(option)">
<span class="truncate" x-text="option.label"></span>
<svg x-show="String(option.value) === String(value)" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"
class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
</template>
</div>
</template>
@else
<div x-ref="panel" class="listbox-panel" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"
@@ -69,8 +69,12 @@
},
}" class="contents">
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
x-transition:enter="transition-opacity ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-75"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
:style="`left: ${x}px; top: ${y}px;`"
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
class="pointer-events-none fixed z-[10000] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
class="pointer-events-none fixed z-[10000] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-dropdown dark:border-white/10 dark:bg-raised">
</div>
</div>
@@ -211,14 +211,19 @@
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4" x-cloak>
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]">
<div x-show="modalOpen" class="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
x-transition:enter="transition-opacity ease-out duration-200"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
</div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col lg:min-w-[36rem] lg:max-w-2xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
@@ -13,12 +13,9 @@
'wireOpen' => null,
'contentClicks' => true,
'isLarge' => false,
'fixedHeight' => false,
])
@php
$modalId = 'modal-' . uniqid();
@endphp
<div x-data="{ modalOpen: @if ($wireOpen) $wire.entangle(@js($wireOpen)) @else false @endif }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
@@ -42,23 +39,24 @@
<template x-teleport="body">
<div x-show="modalOpen"
x-init="$watch('modalOpen', value => { if(value) { $nextTick(() => { const firstInput = $el.querySelector('input, textarea, select'); firstInput?.focus(); }) } })"
class="fixed inset-0 z-99 overflow-y-auto">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
class="fixed inset-0 z-99 overflow-hidden">
<div x-show="modalOpen" x-transition:enter="transition-opacity ease-out duration-200" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition-opacity ease-in duration-150"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"></div>
<div @if ($closeOutside) @click.self="modalOpen=false" @endif class="relative flex min-h-full items-start justify-center p-2 sm:items-center sm:p-4">
<div id="{{ $modalId }}" x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@class([
'application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden',
'lg:w-[95vw]! lg:max-w-7xl!' => $isLarge,
'lg:w-auto lg:min-w-2xl lg:max-w-4xl' => ! $isLarge,
'sm:h-[40rem]' => $fixedHeight,
])
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-wrap! sm:flex-nowrap!">
+1 -1
View File
@@ -240,6 +240,6 @@
</div>
<div x-show="collapsed && tooltip.show" x-cloak x-transition.opacity.duration.100ms
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
class="fixed z-[10000] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-white/10"
class="fixed z-[10000] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-dropdown border border-neutral-700 dark:border-white/10"
x-text="tooltip.text"></div>
</nav>
@@ -53,8 +53,7 @@
x-transition:leave-end="translate-y-3 opacity-0"
class="fixed right-4 z-999 {{ $position === 'top-right' ? 'top-16' : 'bottom-4' }}">
<template x-if="iconOnly">
<button type="button" @click="restore()" aria-label="Restore warning" class="flex rounded-lg p-2"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
<button type="button" @click="restore()" aria-label="Restore warning" class="surface-popover flex rounded-lg p-2">
@isset($icon)
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
@@ -65,12 +64,11 @@
</template>
<template x-if="!iconOnly">
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
<div class="surface-popover relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
:class="compact
? 'w-[calc(100vw-2rem)] max-w-sm cursor-pointer'
: 'w-[calc(100vw-2rem)] max-w-sm'"
@click="restore()"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@click="restore()">
@isset($icon)
<div
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
+1 -1
View File
@@ -11,7 +11,7 @@
{{ $customActions }}
@else
<div
class="flex lg:items-center flex-col justify-between w-full h-full max-w-4xl p-6 mx-auto bg-white border shadow-lg lg:border-t dark:border-coolgray-300 border-neutral-200 dark:bg-coolgray-100 lg:p-8 lg:flex-row sm:rounded-sm">
class="flex lg:items-center flex-col justify-between w-full h-full max-w-4xl p-6 mx-auto bg-white border shadow-modal lg:border-t dark:border-coolgray-300 border-neutral-200 dark:bg-coolgray-100 lg:p-8 lg:flex-row sm:rounded-sm">
<div
class="flex flex-col items-start h-full pb-6 text-xs lg:items-center lg:flex-row lg:pb-0 lg:pr-6 lg:space-x-5 dark:text-neutral-300">
@if (isset($icon))
@@ -9,8 +9,7 @@
</button>
<div x-show="open" x-cloak x-transition.opacity role="dialog"
class="fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
class="surface-popover fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0">
<div class="flex items-start gap-2.5">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
@@ -91,7 +91,7 @@
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 w-56 -ml-2 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="absolute z-20 top-full mt-1 w-56 -ml-2 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($projects as $project)
<a href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $project->uuid === $currentProjectUuid ? 'dark:text-warning font-semibold' : '' }}"
@@ -130,7 +130,7 @@
x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Environment List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($environments as $environment)
@php
$envDatabases = collect()
@@ -205,7 +205,7 @@
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $environment->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
class="relative w-56 bg-white dark:bg-coolgray-100 rounded-md shadow-[var(--shadow-dropdown)] py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
@@ -0,0 +1,34 @@
@props([
'title',
'subtitle' => null,
'href' => null,
'actionLabel' => 'View all',
'icon' => 'arrow-right',
])
{{--
Shared section heading: a 14px title, an optional 11px muted subtitle, and an
optional right-aligned action. Pass `href` for the default ghost "View all"
button, or an `<x-slot:actions>` for a custom control.
--}}
<div {{ $attributes->merge(['class' => 'mb-3 min-w-0']) }}>
<div class="flex items-center justify-between gap-4">
<h2 class="min-w-0 truncate text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
{{ $title }}
</h2>
@isset($actions)
<div class="shrink-0">{{ $actions }}</div>
@elseif (filled($href))
<a href="{{ $href }}" {{ wireNavigate() }}
class="group inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 text-[12px] font-medium text-neutral-600 transition-[color,background-color,transform] duration-100 ease-out hover:bg-neutral-100 hover:text-black active:scale-[0.97] dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim dark:hover:bg-white/[0.1] dark:hover:text-fg">
{{ $actionLabel }}
@if ($icon)
<x-reicon :name="$icon" class="size-3 opacity-70 transition-transform duration-150 ease-out group-hover:translate-x-0.5" />
@endif
</a>
@endisset
</div>
@if (filled($subtitle))
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">{{ $subtitle }}</p>
@endif
</div>
@@ -28,7 +28,7 @@
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($tokens as $token)
<a wire:key="{{ $provider }}-token-{{ $token->id }}"
class="group flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
class="group flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]"
href="{{ route('server.create.token', ['type' => $routeType ?: $provider, 'token_uuid' => $token->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex items-start gap-3">
@@ -5,6 +5,7 @@
'title',
'view',
'variablesLabel',
'readOnlyKeys' => [],
])
@php
@@ -105,15 +106,32 @@
<div class="data-table-header env-table-grid-shared order-[-1]">
<span>Name</span>
<span>Scope</span>
<span>Comment</span>
<span>{{ count($readOnlyKeys) ? 'Value / comment' : 'Comment' }}</span>
<span class="text-center">Multiline</span>
<span></span>
</div>
@foreach ($variables as $env)
<livewire:project.shared.environment-variable.show
wire:key="shared-variable-{{ $type }}-{{ $env->id }}" :env="$env"
:type="$type" :tableAlphabeticalOrder="$alphabeticalPositions[$env->id]"
:tableCreationOrder="$loop->index" />
@if (in_array($env->key, $readOnlyKeys))
<div wire:key="shared-variable-{{ $type }}-{{ $env->id }}" class="env-table-item"
:style="`order: ${sharedSort === 'alphabetical' ? {{ $alphabeticalPositions[$env->id] }} : {{ $loop->index }}}`"
x-show="@js(mb_strtolower($env->key . ' ' . ($env->comment ?? '') . ' ' . $type)).includes(sharedSearch.trim().toLowerCase())">
<div class="data-table-row env-table-grid-shared">
<div class="min-w-0">
<div class="env-key-label truncate font-mono text-[13px]" title="{{ $env->key }}">{{ $env->key }}</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">Built-in · Read-only</div>
</div>
<span class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ str($type)->headline() }}</span>
<span class="min-w-0 truncate font-mono text-[13px]" title="{{ $env->value }}">{{ $env->value }}</span>
<span class="data-table-cell-dash">-</span>
<span class="justify-self-end text-neutral-400 dark:text-fg-faint" title="Built-in variable, managed by Coolify"><x-reicon name="lock" class="size-3.5" /></span>
</div>
</div>
@else
<livewire:project.shared.environment-variable.show
wire:key="shared-variable-{{ $type }}-{{ $env->id }}" :env="$env"
:type="$type" :tableAlphabeticalOrder="$alphabeticalPositions[$env->id]"
:tableCreationOrder="$loop->index" />
@endif
@endforeach
<div
class="order-[9999] flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
@@ -122,6 +140,14 @@
</div>
@endif
@else
@if ($variables->whereIn('key', $readOnlyKeys)->isNotEmpty())
<div class="border-b border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="mb-2 text-[12px] text-neutral-500 dark:text-fg-dim">Built-in · Read-only</div>
@foreach ($variables->whereIn('key', $readOnlyKeys)->sortBy('key') as $env)
<div class="break-all font-mono text-[13px]">{{ $env->key }}={{ $env->value }}</div>
@endforeach
</div>
@endif
<form wire:submit="submit" class="p-4">
<x-unsaved-bar action="submit" />
<x-forms.textarea canGate="update" :canResource="$resource" rows="20"
@@ -16,22 +16,26 @@ x-init="$watch('slideOverOpen', value => {
<div x-show="slideOverOpen" @if (!$closeWithX) @keydown.window.escape="slideOverOpen=false" @endif
class="relative z-99 ">
<div x-show="slideOverOpen" @if (!$closeWithX) @click="slideOverOpen = false" @endif
x-transition:enter="transition-opacity ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-200"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="fixed inset-0 dark:bg-black/60 backdrop-blur-xs"></div>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden ">
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
<div x-show="slideOverOpen"
@if (!$closeWithX) @click.away="slideOverOpen = false" @endif
x-transition:enter="transform transition ease-in-out duration-100 sm:duration-300"
x-transition:enter="transform transition ease-[cubic-bezier(0.32,0.72,0,1)] duration-200 sm:duration-300"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-100 sm:duration-300"
x-transition:leave="transform transition ease-in duration-200"
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
@class([
'max-w-xl w-screen' => !$fullScreen,
'max-w-4xl w-screen' => $fullScreen,
])>
<div
class="flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200">
class="flex flex-col h-full py-6 overflow-hidden border-l shadow-modal bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200">
<div class="px-4 pb-4 sm:px-5">
<div class="flex items-start justify-between pb-1">
<h2 class="text-2xl leading-6" id="slide-over-title">
@@ -1,4 +1,4 @@
@props(['status', 'title' => 'Application status', 'containerName' => 'Container'])
@props(['status', 'title' => 'Application status', 'containerName' => 'Container', 'align' => 'left'])
@php
$rawStatus = str((string) $status)->lower()->trim()->value();
@@ -62,8 +62,12 @@
</span>
</x-status-badge>
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-[min(16rem,calc(100vw-1.5rem))]! min-w-0! sm:w-64! sm:min-w-64!" role="menu">
<div x-cloak x-show="open" x-transition.origin.top.{{ $align === 'right' ? 'right' : 'left' }}
@class([
'listbox-panel top-8! z-[90]! w-[min(16rem,calc(100vw-1.5rem))]! min-w-0! sm:w-64! sm:min-w-64!',
'right-auto! left-0!' => $align !== 'right',
'left-auto! right-0!' => $align === 'right',
]) role="menu">
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">{{ $title }}</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
@@ -36,7 +36,13 @@
</div>
<div x-ref="panel" x-show="open" x-cloak :style="panelStyle"
class="listbox-panel fixed! right-auto! bottom-auto! z-[90]! mt-0! {{ $panelClass }}" role="{{ $role }}"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
class="listbox-panel origin-top fixed! right-auto! bottom-auto! z-[90]! mt-0! {{ $panelClass }}" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }}
</div>
@@ -14,7 +14,7 @@
</button>
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
class="console-theme-selector absolute top-11 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
class="console-theme-selector absolute top-11 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[var(--shadow-dropdown)] dark:border-white/[0.1] dark:bg-[#111113]">
@foreach ($themes as $theme)
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
@@ -0,0 +1,185 @@
@props(['variant' => 'full'])
@php($checkPath = 'm2.5 6.25 2.1 2.1 4.9-5')
@if ($variant === 'menu')
{{-- Compact list for the profile dropdown. The parent controls visibility;
all theme state/logic comes from the shared window.themeControls(). --}}
<div x-data="themeControls()" class="grid gap-0.5">
@foreach ([
['value' => 'light', 'label' => 'Light'],
['value' => 'system', 'label' => 'System'],
['value' => 'dark', 'label' => 'Dark'],
['value' => 'custom', 'label' => 'Custom'],
] as $option)
@if ($option['value'] === 'custom')
<div class="relative" @click.outside="pickerOpen = false">
<button type="button" @click="chooseCustom()" aria-haspopup="dialog" :aria-expanded="pickerOpen"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span class="flex items-center gap-2">
<span class="size-3.5 rounded-full border border-white/20"
:style="`background: ${themeColor}`"></span>
Custom
</span>
<svg x-show="theme === 'custom'" class="size-3.5 text-coollabs dark:text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<x-theme-controls.picker position="left-full top-0 ml-2" />
</div>
@else
<button type="button" @click="setTheme('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="theme === '{{ $option['value'] }}'"
class="size-3.5 text-coollabs dark:text-warning" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endif
@endforeach
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<div class="px-2 pt-1 pb-0.5 text-[10px] font-medium tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Page width
</div>
@foreach ([
['value' => 'full', 'label' => 'Full width'],
['value' => 'centered', 'label' => 'Centered'],
] as $option)
<button type="button" @click="setWidth('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="pageWidth === '{{ $option['value'] }}'"
class="size-3.5 text-coollabs dark:text-warning" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
@else
{{-- Full card grid for the Appearance settings page. --}}
<div x-data="themeControls()" class="mt-8 flex w-full max-w-none flex-col gap-6 lg:mt-3">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Color theme</h2>
<p>Choose the color scheme used in this browser.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ([
['value' => 'light', 'label' => 'Light', 'description' => 'Bright surfaces and dark text.', 'preview' => 'bg-white'],
['value' => 'system', 'label' => 'System', 'description' => 'Follow your operating system.', 'preview' => 'bg-gradient-to-r from-white via-neutral-400 to-[#050505]'],
['value' => 'dark', 'label' => 'Dark', 'description' => 'Dark surfaces and soft contrast.', 'preview' => 'bg-[#181818]'],
['value' => 'custom', 'label' => 'Custom', 'description' => 'Tint light or dark surfaces with any color.', 'preview' => ''],
] as $option)
@if ($option['value'] === 'custom')
{{-- Clicking the card opens the custom color picker popover (color + light/dark). --}}
<div class="relative" @click.outside="pickerOpen = false">
<div role="button" tabindex="0" @click="chooseCustom()" @keydown.enter.prevent="chooseCustom()"
aria-haspopup="dialog" :aria-expanded="pickerOpen"
class="group block w-full overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:border-white/[0.12]"
:class="theme === 'custom'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="h-20 border-b border-neutral-200 dark:border-white/[0.07]"
:style="`background: color-mix(in oklab, ${themeColor} 28%, ${customMode === 'light' ? 'oklch(97% 0 0)' : 'oklch(17.35% 0.0020 286.18)'})`">
<div class="flex h-full items-center justify-center">
<div class="h-10 w-20 rounded-md border border-white/15 p-1 shadow-sm">
<div class="h-full w-full rounded-sm" :style="`background: ${themeColor}`"></div>
</div>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="theme === 'custom'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
</div>
<x-theme-controls.picker position="left-0 top-full mt-2" />
</div>
@else
<div role="button" tabindex="0"
@click="setTheme('{{ $option['value'] }}')"
@keydown.enter.prevent="setTheme('{{ $option['value'] }}')"
class="group relative overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:border-white/[0.12]"
:class="theme === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="h-20 {{ $option['preview'] }} border-b border-neutral-200 dark:border-white/[0.07]">
<div class="flex h-full items-center justify-center">
<div
class="h-8 w-20 rounded-md border border-black/10 bg-neutral-100/80 shadow-sm dark:border-white/10 dark:bg-black/20">
</div>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">
{{ $option['label'] }}
</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="theme === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
</div>
@endif
@endforeach
</div>
</section>
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Page width</h2>
<p>Choose how content uses the available browser width.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2">
@foreach ([
['value' => 'full', 'label' => 'Full width', 'description' => 'Use all available space for page content.'],
['value' => 'centered', 'label' => 'Centered', 'description' => 'Keep content centered at a comfortable maximum width.'],
] as $option)
<button type="button" @click="setWidth('{{ $option['value'] }}')"
class="group overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.05] dark:hover:border-white/[0.12]"
:class="pageWidth === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="flex h-20 items-center border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
<div class="flex h-11 w-full gap-1.5 rounded-md border border-neutral-300 bg-white p-1.5 dark:border-white/15 dark:bg-[#181818]">
<div class="w-3 shrink-0 rounded-sm bg-neutral-200 dark:bg-white/10"></div>
<div @class([
'h-full rounded-sm bg-neutral-200 dark:bg-white/10',
'w-full' => $option['value'] === 'full',
'mx-auto w-2/3' => $option['value'] === 'centered',
])></div>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="pageWidth === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
</div>
</button>
@endforeach
</div>
</section>
</div>
@endif

Some files were not shown because too many files have changed in this diff Show More