mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Merge remote-tracking branch 'origin/main' into feat/services
This commit is contained in:
@@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
|
||||
BUNNY_API_KEY=
|
||||
# For asset uploads
|
||||
BUNNY_STORAGE_API_KEY=
|
||||
AVATAR_CDN_URL=
|
||||
|
||||
@@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
|
||||
PUSHER_APP_ID=coolify
|
||||
PUSHER_APP_KEY=coolify
|
||||
PUSHER_APP_SECRET=coolify
|
||||
AVATAR_CDN_URL=
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -39,7 +39,7 @@ class ScheduledTaskExecution extends BaseModel
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'duration' => 'decimal:2',
|
||||
'duration' => 'float',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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'),
|
||||
|
||||
+28
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+28
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.3.17"
|
||||
"version": "4.3.19"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.4-rc.1"
|
||||
|
||||
+683
-252
File diff suppressed because it is too large
Load Diff
+23
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user