Compare commits

..

9 commits

Author SHA1 Message Date
rosslh
3ead7b661c security: coolify-base-branch-migration — fix version injection for env() constant shape, guard all version extractions (H7 partial)
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m2s
2026-09-08 08:51:01 -04:00
rosslh
c608e51fce feat(cleanup): default to threshold-mode docker cleanup every 10 minutes
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m3s
2026-08-29 09:57:49 -04:00
rosslh
ed6ca5dcc8 chore(ci): use Forgejo build and CDN publishing
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 41s
2026-08-26 17:31:03 -04:00
rosslh
8ef425d1b0 feat(auth): add dashboard-managed Coolify access 2026-08-26 17:31:03 -04:00
rosslh
4bef17a9eb fix(dns): use Canadian Shield DNS defaults 2026-08-26 17:31:03 -04:00
rosslh
c7354b0b55 fix(telemetry): disable upstream telemetry 2026-08-26 17:31:03 -04:00
rosslh
621777098e fix(update): use MapleDeploy CDN and registry artifacts 2026-08-26 17:31:03 -04:00
rosslh
f5a80f5caf style(theme): apply MapleDeploy palette and fonts 2026-08-26 17:31:03 -04:00
rosslh
00e5a39750 feat(branding): apply MapleDeploy UI branding 2026-08-26 17:31:03 -04:00
566 changed files with 4115 additions and 23464 deletions

View file

@ -1,84 +1,7 @@
# Lessons
## Confirm which surface becomes the modal
- When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal.
- Do not make one child page a modal inside the other child page when the user wants both child URLs removed.
- When the modal itself supplies the title and subtitle, do not repeat page-style section cards inside it. Use a flat input layout and one footer for actions.
- Put destructive actions on the footer's left. Put conversion and the primary Save action on the right, with Save last.
- Do not repeat domain-port guidance in a resource settings modal when domain ports have their own input in the domain editor.
- A flat modal form can still use a bordered summary box for a distinct linked resource, such as the domain count and Manage domains action.
- For compact modal headers, show the descriptive subtitle as hover text on an underlined title instead of adding a second visible line.
- Reuse `x-helper` and the plain `underline underline-offset-4` trigger for title help. Do not use a native `title` tooltip or a dotted underline when the project already has a shared title-tooltip pattern.
## Alpine x-transition + tw-animate-css exit animations flash at the end
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
## Displayed defaults must not become stored overrides
- When an edit form shows an inherited or computed default, trace an unchanged save and a related-field edit through persistence.
- Preserve the inherited state when the displayed value still equals the computed default; store an override only when the user selects a different value.
## Prove regressions against the unchanged baseline
- For a bug fix, run the same regression test before and after the production change. Use a stash when requested so the failure and success come from the exact same test.
## Apply shared domain UX to every supported resource type
- When a user asks for domain-management behavior, inventory every resource that can edit domains before implementation.
- Do not stop at the resource type named in the original report when the requested UX is meant to be consistent across Coolify.
## Verify manual and generated domain paths separately
- Domain regeneration and manual hostname edits must start the same post-save DNS check.
- Add explicit regression coverage for both entry paths across every active domain editor.
## Do not treat a runtime restart as behavior verification
- A healthy restarted container proves only that the process started.
- For a reported UI failure, verify the exact user flow and inspect the resulting persisted state before claiming the fix works.
## Prove the reported live flow before reporting a UI fix
- Do not use unit tests or a healthy process as proof for a reported live UI failure.
- After the user repeats the flow, inspect the exact persisted record, request logs, queue state, and deployed source before stating that it works.
## Start DNS checks only for DNS-relevant edits
- Compare the previous and saved scheme and hostname before a post-save DNS check.
- Do not restart DNS checks for indexing, redirect, path, or internal-port-only changes.
## Include automatically added domains in post-save DNS checks
- Compare the configured domain list before and after Save.
- Start checks for each newly added counterpart, even when the edited domain itself did not change.
## Use one DNS progress pattern
- All DNS check entry points must set the domain badge to the same `checking` state.
- Do not use separate loading feedback on Check all or per-domain action buttons when the badge is the progress indicator.
- Verify the rendered badge uses the spinner slot instead of the default status dot.
## Confirm whether old reports still apply before changing code
- For an old issue, first test the current branch and inspect later fixes. Do not assume that the historical reproduction still needs a new code change.
## Compare routing identity, not complete domain URLs
- Domain-conflict checks must treat `http://host` and `https://host` as the same routing identity.
- Reproduce reports with the exact stored schemes before stating that duplicate detection works.
## Verify reported fixes against the running development app
- When a user asks for before-and-after verification, test the unchanged and fixed production code against the same Jean Run environment.
- Cover each requested interface, such as UI and API, and record the exact URL, response, persisted state, and relevant logs.
## Do not infer that “Pro” means paid
- When the user calls a setting “Pro,” confirm whether it means advanced-user functionality or a subscription entitlement.
- Do not add billing or Cloud-only checks unless the user explicitly requests them.
## Verify compound status layouts visually
- When a status component can render more than one badge, give its root an explicit horizontal flex layout.
- Inspect the real top-bar layout with every conditional badge visible before calling a UI change complete.
## Keep a requested security control at its stated scope
- If the user specifies one team-level redaction flag, do not introduce per-secret policy questions.
- Explain storage constraints as implementation details, then preserve the requested single control.
## Use shared section title helpers in edit modals
- When modal section descriptions should appear on hover, use `x-application.settings-section` instead of a manual heading and visible paragraph.
- Keep text labels for direct actions such as Back up now. Use a standard icon button with a tooltip for familiar secondary actions such as settings.
## Keep modal actions in the footer
- When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action.

1
.gitignore vendored
View file

@ -40,7 +40,6 @@ CHANGELOG.md
/.workspaces
/.superpowers/
/docs/superpowers/plans/
/.ai/todo.md
tests/Browser/Screenshots
tests/v4/Browser/Screenshots
ref

View file

@ -148,11 +148,6 @@ ### Authorization
- Custom gates: `createAnyResource`, `canAccessTerminal`
- Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods
- Multi-tenancy via Teams — team auto-initializes notification settings on creation
- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security.
- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team.
- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data.
- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team.
- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data.
### Event Broadcasting
- Soketi WebSocket server for real-time updates (ports 6001-6002 in dev)
@ -196,23 +191,6 @@ ### Laravel 10 Structure (NOT Laravel 11+ slim structure)
- Exception handler: `app/Exceptions/Handler.php`
- Service providers in `app/Providers/`
## Livewire conventions
### Dynamic lists and snapshot errors
When an add, delete, or conversion leaves controls unresponsive and the browser reports `Snapshot missing on Livewire component`, inspect both component keys and refresh events. Stable keys alone may not fix it.
- Give every Livewire component rendered in a loop a stable key based on the record ID, UUID, filename, or another immutable identity. Never include a collection count, `$loop->index`, or a reindexed array position in the key.
- Pass the same stable identity to edit/delete actions. A keyed row can survive reordering while a `wire:ignore` or teleported Alpine modal keeps its original `submitAction`; an action such as `removeItem($index)` then targets a stale position after the first deletion. Resolve the current row server-side from an ID, UUID, or stable row hash instead.
- Do not broadcast one refresh event to both a parent list component and children that the parent may insert, remove, or hide during the same operation. This can queue a child update after its snapshot has been removed from the DOM.
- Split refresh responsibilities into targeted events. Refresh the parent for counts and tab visibility, and refresh an existing child list with a separate event. Use `$this->dispatch('event')->to(Component::class)` instead of a page-wide event when possible.
- Before targeting a child list, confirm that it existed before the mutation, still exists afterward, and is on the active tab. A newly inserted child loads current data during `mount()` and does not need an immediate refresh. A removed or hidden child must not receive one.
- A child that deletes itself should finish its own update, then target only the parent to refresh counts. The parent should not send a refresh back to that child when the list became empty.
- Apply the same pattern to file, directory, conversion, and external reload paths such as Compose edits. One remaining broad event can reproduce the race.
- Add regression tests that assert the scoped event names, assert the old broad event is not dispatched, and verify that keys do not depend on counts or positions. Manually repeat add/delete operations while watching the browser console.
The persistent-storage implementation is the reference pattern: `Project\Service\Storage` handles `storageCountsChanged`, while `Project\Shared\Storages\All` handles `refreshVolumeList`.
## Key Conventions
- Use `php artisan make:*` commands with `--no-interaction` to create files

View file

@ -34,9 +34,7 @@ ## 1. Visual direction
- near-neutral layered surfaces instead of large bordered boxes;
- 1314px UI typography and 32px controls;
- 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;
- hairline rings instead of heavy borders;
- full-width data tables for dense collections;
- outline Reicon glyphs through `<x-reicon>`;
- the Coolify purple brand accent in light mode;
@ -51,19 +49,6 @@ ## 1. Visual direction
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
@ -104,14 +89,14 @@ ## 3. Tokens and color behavior
| Token | Light | Dark | Use |
|---|---|---|---|
| `--coollabs-canvas` | 97% off-white | 10% neutral | page canvas (kept below card fills so cards lift) |
| `--coollabs-canvas` | near white | 10% neutral | page canvas |
| `--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` | 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) |
| `--coollabs-hairline` | 93.5% neutral | 26.9% neutral | shell rings |
| `--coollabs-subtle` | 55.6% neutral | 70.8% neutral | labels and muted titles |
Accent behavior is intentionally theme-aware:
@ -131,38 +116,6 @@ ## 3. Tokens and color behavior
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
@ -466,12 +419,6 @@ ### Dropdowns
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.
@ -573,10 +520,7 @@ ### Unsaved changes
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. 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.
instant-save pattern.
---
@ -623,15 +567,6 @@ ## 7. Dense tables
---
### 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
@ -691,9 +626,8 @@ ### Toasts
- Reicon status tile for success, info, warning, danger, or default;
- title plus optional description;
- dismiss and copy-details actions;
- normally up to four stacked notifications, without evicting persistent notices;
- up to four stacked notifications;
- 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.

View file

@ -13,9 +13,8 @@ class StopApplication
public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainers = true): ?string
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
{
$containerPresent = ! $removeContainers;
$servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) {
$servers = $servers->merge($application->additional_servers);
@ -27,7 +26,6 @@ public function handle(Application $application, bool $previewDeployments = fals
}
if ($server->isSwarm()) {
$containerPresent = false;
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
continue;
@ -41,15 +39,13 @@ public function handle(Application $application, bool $previewDeployments = fals
$timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
$commands = [dockerStopCommand($timeout, $containerName, $server)];
if ($removeContainers) {
$commands[] = "docker rm -f $containerName";
}
instant_remote_process(command: $commands, server: $server, throwError: false);
instant_remote_process(command: [
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
if ($removeContainers && $application->build_pack === 'dockercompose') {
if ($application->build_pack === 'dockercompose') {
$application->deleteConnectedNetworks();
}
@ -61,22 +57,16 @@ public function handle(Application $application, bool $previewDeployments = fals
}
}
$status = [
'status' => 'exited',
'container_present' => $containerPresent,
];
$status = ['status' => 'exited'];
if ($resetRestartCount) {
$status = array_merge($status, [
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
}
$application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
return null;
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Actions\Application;
use App\Events\ServiceStatusChanged;
use App\Models\ApplicationPreview;
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplicationPreview
{
use AsAction;
public function handle(ApplicationPreview $preview, bool $resetRestartCount = true, bool $removeContainer = true): void
{
$application = $preview->application;
$server = $application->destination->server;
$containers = getCurrentApplicationContainerStatus($server, $application->id, $preview->pull_request_id);
foreach ($containers->pluck('Names') as $containerName) {
$commands = [dockerStopCommand($application->settings->stopGracePeriodSeconds(), $containerName, $server)];
if ($removeContainer) {
$commands[] = "docker rm -f $containerName";
}
instant_remote_process($commands, $server, false);
}
$preview->update(['status' => 'exited']);
if ($resetRestartCount) {
$preview->resetRestartLimit();
}
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}

View file

@ -28,11 +28,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
switch ($database->getMorphClass()) {
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);

View file

@ -4,7 +4,6 @@
use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\BaseModel;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -19,7 +18,7 @@ class StopDatabase
{
use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true, bool $resetRestartCount = true, bool $removeContainer = true): string
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
{
try {
$server = $database->destination->server;
@ -27,17 +26,15 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
return 'Server is not functional';
}
$this->stopContainer($database, $database->uuid, 30, $removeContainer);
$this->stopContainer($database, $database->uuid, 30);
// Reset restart tracking when database is manually stopped
$database->update(['status' => 'exited']);
if ($resetRestartCount) {
$database->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
@ -56,13 +53,12 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
}
private function stopContainer(BaseModel $database, string $containerName, int $timeout = 30, bool $removeContainer = true): void
private function stopContainer($database, string $containerName, int $timeout = 30): void
{
$server = $database->destination->server;
$commands = [dockerStopCommand($timeout, $containerName, $server)];
if ($removeContainer) {
$commands[] = "docker rm -f $containerName";
}
instant_remote_process(command: $commands, server: $server, throwError: false);
instant_remote_process(command: [
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
}

View file

@ -3,19 +3,15 @@
namespace App\Actions\Docker;
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck;
use App\Events\ServiceChecked;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator;
use App\Services\RestartCountTracker;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@ -41,12 +37,8 @@ class GetContainersStatus
protected ?Collection $applicationContainerRestartCounts;
protected ?Collection $previewContainerRestartCounts;
protected ?Collection $serviceContainerStatuses;
protected ?Collection $serviceContainerRestartCounts;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@ -125,9 +117,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$containerStatus = "$containerStatus:$healthSuffix";
}
$labels = Arr::undot(format_docker_labels_to_json($labels));
if (filter_var(data_get($labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) {
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
@ -144,12 +133,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
} else {
$preview->update(['last_online_at' => now()]);
}
$key = $applicationId.':'.$pullRequestId;
$this->previewContainerRestartCounts ??= collect();
$this->previewContainerRestartCounts->push([
'key' => $key,
'count' => (int) data_get($container, 'RestartCount', 0),
]);
} else {
// Notify user that this container should not be there.
}
@ -157,9 +140,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$application = $this->applications->where('id', $applicationId)->first();
if ($application) {
$foundApplications[] = $application->id;
if ($application->container_present !== true) {
$application->update(['container_present' => true]);
}
// Store container status for aggregation
if (! isset($this->applicationContainerStatuses)) {
$this->applicationContainerStatuses = collect();
@ -240,22 +220,23 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
// Track restart count for databases (single-container)
$restartCount = data_get($container, 'RestartCount', 0);
$previousRestartCount = $database->restart_count ?? 0;
if ($statusFromDb !== $containerStatus) {
$updateData = ['status' => $containerStatus];
} else {
$updateData = ['last_online_at' => now()];
}
$database->update($updateData);
if ($restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
// Update restart tracking if restart count increased
if ($restartCount > $previousRestartCount) {
$updateData['restart_count'] = $restartCount;
$updateData['last_restart_at'] = now();
$updateData['last_restart_type'] = 'crash';
}
$database->update($updateData);
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
@ -311,11 +292,6 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->serviceContainerRestartCounts ??= collect();
if (! $this->serviceContainerRestartCounts->has($key)) {
$this->serviceContainerRestartCounts->put($key, collect());
}
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) data_get($container, 'RestartCount', 0));
}
// Mark service as found
@ -359,34 +335,43 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
if ($exitedService instanceof ServiceDatabase) {
$exitedService->update(['status' => 'exited']);
} elseif (! $exitedService->stoppedAfterRestartLimit()) {
$exitedService->update([
'status' => 'exited',
'restart_count' => 0,
'restart_limit_reached' => false,
'last_restart_at' => null,
'last_restart_type' => null,
]);
$name = data_get($exitedService, 'name');
$fqdn = data_get($exitedService, 'fqdn');
if ($name) {
if ($fqdn) {
$containerName = "$name, available at $fqdn";
} else {
$containerName = $name;
}
} else {
if ($fqdn) {
$containerName = $fqdn;
} else {
$containerName = null;
}
}
$projectUuid = data_get($service, 'environment.project.uuid');
$serviceUuid = data_get($service, 'uuid');
$environmentName = data_get($service, 'environment.name');
if ($projectUuid && $serviceUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
$exitedService->update(['status' => 'exited']);
}
$notRunningApplications = $this->applications->pluck('id')->diff($foundApplications);
foreach ($notRunningApplications as $applicationId) {
$application = $this->applications->where('id', $applicationId)->first();
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
if (str($application->status)->startsWith('exited')) {
continue;
}
if (str($application->status)->startsWith('exited')) {
$application->update([
'container_present' => false,
'restart_limit_reached' => false,
]);
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
@ -403,11 +388,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
// Reset restart count when application exits completely
$application->update([
'status' => 'exited',
'container_present' => false,
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
'restart_limit_reached' => false,
]);
}
}
@ -450,9 +433,22 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
StopDatabaseProxy::run($database);
}
}
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');
$this->trackPreviewRestartCounts($previews);
$containerName = $name;
$projectUuid = data_get($database, 'environment.project.uuid');
$environmentName = data_get($database, 'environment.name');
$databaseUuid = data_get($database, 'uuid');
if ($projectUuid && $databaseUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
}
// Aggregate multi-container application statuses
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
@ -474,21 +470,21 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0;
$restartState = (new RestartCountTracker)->evaluate(
previousRestartCount: $previousRestartCount,
observedRestartCount: $maxRestartCount,
maxRestartCount: $application->max_restart_count ?? 0,
);
if ($restartState['restart_count_changed']) {
$hasCrashRestarts = $restartState['restart_count'] > 0;
if ($maxRestartCount > $previousRestartCount) {
// Restart count increased - this is a crash restart
$application->update([
'restart_count' => $restartState['restart_count'],
'last_restart_at' => $hasCrashRestarts ? now() : null,
'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
'restart_count' => $maxRestartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
// Check if restart limit has been reached
$maxAllowedRestarts = $application->max_restart_count ?? 0;
if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
$restartLimitReached = true;
}
}
$restartLimitReached = $restartState['restart_limit_reached'];
// Aggregate status after tracking restart counts
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
@ -503,22 +499,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
});
if ($restartLimitReached) {
$restartLimitClaimed = Application::query()
->whereKey($application->getKey())
->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
$application->refresh();
StopApplication::dispatch(
application: $application,
previewDeployments: false,
dockerCleanup: false,
resetRestartCount: false,
removeContainers: false,
);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
$application->refresh();
StopApplication::dispatch($application, false, true, false);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
}
@ -579,16 +562,6 @@ private function aggregateServiceContainerStatuses($services)
continue;
}
$restartCount = isset($this->serviceContainerRestartCounts)
? ($this->serviceContainerRestartCounts->get($key)?->max() ?? 0)
: 0;
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -629,24 +602,4 @@ private function aggregateServiceContainerStatuses($services)
}
}
}
private function trackPreviewRestartCounts(Collection $previews): void
{
if (! isset($this->previewContainerRestartCounts)) {
return;
}
$this->previewContainerRestartCounts
->groupBy('key')
->each(function (Collection $counts, string $key) use ($previews): void {
[$applicationId, $pullRequestId] = explode(':', $key);
$preview = $previews->first(fn (ApplicationPreview $preview): bool => (string) $preview->application_id === $applicationId
&& (string) $preview->pull_request_id === $pullRequestId
);
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
StopApplicationPreview::dispatch($preview, false, false);
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
}
});
}
}

View file

@ -49,7 +49,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
}
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
instant_remote_process([
'docker rm -f coolify-sentinel || true',

View file

@ -24,7 +24,6 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$service->applications()->get()->each->resetRestartLimit();
$workdir = $service->workdir();
// $commands[] = "cd {$workdir}";
$commands[] = "echo 'Saved configuration files to {$workdir}.'";

View file

@ -49,13 +49,8 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false,
$this->stopContainersInParallel($containersToStop, $server);
}
$applications->each(function ($application): void {
$application->update(['status' => 'exited']);
$application->resetRestartLimit();
});
$dbs->each(function ($database): void {
$database->update(['status' => 'exited']);
});
$applications->each->update(['status' => 'exited']);
$dbs->each->update(['status' => 'exited']);
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();

View file

@ -13,23 +13,17 @@ class StopServiceApplication
public string $jobQueue = 'high';
public function handle(ServiceApplication|ServiceDatabase $serviceApplication, bool $resetRestartCount = true, bool $removeContainer = false): void
public function handle(ServiceApplication|ServiceDatabase $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
if ($removeContainer) {
$commands = ["docker rm -f {$containerName}"];
} else {
$commands = ["docker stop {$containerName}"];
}
instant_remote_process($commands, $server, throwError: ! $removeContainer);
instant_remote_process([
"docker stop {$containerName}",
], $server);
$serviceApplication->update(['status' => 'exited']);
if ($resetRestartCount && $serviceApplication instanceof ServiceApplication) {
$serviceApplication->resetRestartLimit();
}
ServiceStatusChanged::dispatch($service->environment->project->team->id);
}
}

View file

@ -56,7 +56,7 @@ public function execute(ServiceApplication $serviceApplication, Request $request
}
}
$serviceApplication->setEditableUrls($parsed['normalized']);
$serviceApplication->fqdn = $parsed['normalized'];
}
if (array_key_exists('noindex_domains', $payload)) {
@ -92,11 +92,6 @@ public function execute(ServiceApplication $serviceApplication, Request $request
$serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('max_restart_count', $payload)) {
$serviceApplication->max_restart_count = $payload['max_restart_count'];
$serviceApplication->restart_limit_reached = false;
}
if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server;

View file

@ -1,221 +0,0 @@
<?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;
}
}

View file

@ -50,22 +50,12 @@ public function handle(Team $team, User $user): ?Team
->get()
->each(function (User $member) use ($team): void {
$member->teams()->detach($team);
$member->clearStoredTeamIfMatches($team->id);
DB::table('sessions')->where('user_id', $member->id)->delete();
});
// The deleting owner is excluded from the loop above; clear their
// stored team too so the deleted id is not restored on next login.
$user->clearStoredTeamIfMatches($team->id);
$team->delete();
// Resolve the next active team the same way login does: the user's
// stored choice when still valid, or their sole remaining team.
// Returns null for a multi-team user whose active team was just
// deleted, so refreshSession sends them to the selection screen
// instead of silently dropping them into an arbitrary first team.
return User::query()->find($user->id)?->resolveStoredTeam();
return $user->teams()->first();
});
Cache::forget("user:{$user->id}:team:{$team->id}");

View file

@ -9,7 +9,6 @@
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class ScheduledJobDiagnostics extends Command
@ -204,6 +203,7 @@ private function inspectServerJobs(?string $serverFilter): void
}
$dedupKeys = [
"sentinel-restart:{$server->id}" => '0 0 * * *',
"server-patch-check:{$server->id}" => '0 0 * * 0',
"server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *',
"server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'),
@ -235,7 +235,7 @@ private function inspectServerJobs(?string $serverFilter): void
$this->newLine();
}
private function getServers(?string $serverFilter): Collection
private function getServers(?string $serverFilter): \Illuminate\Support\Collection
{
$query = Server::with('settings')->where('ip', '!=', '1.2.3.4');

View file

@ -5,7 +5,6 @@
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;
@ -48,13 +47,11 @@ protected function schedule(Schedule $schedule): void
->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('cleanup:stucked-resources')
->dailyAt('03:17')
->daily()
->onOneServer()
->withoutOverlapping(60)
->runInBackground();
->withoutOverlapping(60);
$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

View file

@ -1,18 +0,0 @@
<?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);
}
}

View file

@ -4,11 +4,9 @@
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;
@ -71,11 +69,6 @@ protected function unauthenticated($request, AuthenticationException $exception)
*/
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()) {

View file

@ -23,11 +23,9 @@
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
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;
@ -1456,17 +1454,9 @@ private function create_application(Request $request, $type)
$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;
}
$gitRepository = $application->git_repository;
$httpsRepository = scpStyleGitUrlToHttps($gitRepository);
if (is_string($httpsRepository)) {
$gitRepository = $httpsRepository;
$application->git_repository = $httpsRepository;
}
$repository_url_parsed = Url::fromString($gitRepository);
$repository_url_parsed = Url::fromString($request->git_repository);
$git_host = $repository_url_parsed->getHost();
if ($git_host === 'github.com') {
$application->source_type = GithubApp::class;
@ -1628,7 +1618,11 @@ private function create_application(Request $request, $type)
return response()->json(['message' => 'Failed to generate Github App token.'], 400);
}
$gitRepository = gitRepositorySlug($request->git_repository);
$gitRepository = $request->git_repository;
if (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) {
$gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', '');
}
$gitRepository = str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString();
// Use direct API call to verify repository access instead of loading all repositories
// This is much faster and avoids timeouts for GitHub Apps with many repositories
@ -1724,9 +1718,7 @@ private function create_application(Request $request, $type)
$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();
@ -1957,9 +1949,7 @@ private function create_application(Request $request, $type)
$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;
@ -2497,256 +2487,6 @@ public function logs_by_uuid(Request $request)
]);
}
#[OA\Patch(
summary: 'Update Preview Domains',
description: 'Replace domains for a preview deployment. Use domains for regular applications or docker_compose_domains for Docker Compose applications. Ports are stored as internal overrides while public domains remain portless.',
path: '/applications/{uuid}/previews/{pull_request_id}',
operationId: 'update-preview-domains-by-pull-request-id',
security: [['bearerAuth' => []]],
tags: ['Applications'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'pull_request_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(
properties: [
new OA\Property(property: 'domains', type: 'string', nullable: true, example: 'https://pr.example.com:3000'),
new OA\Property(
property: 'docker_compose_domains',
type: 'array',
nullable: true,
items: new OA\Items(properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'domain', type: 'string', nullable: true),
new OA\Property(property: 'redirect', type: 'string', nullable: true, enum: ['www', 'non-www', 'both']),
], type: 'object'),
),
new OA\Property(property: 'force_domain_override', type: 'boolean', default: false),
],
)),
responses: [
new OA\Response(response: 200, description: 'Preview domains updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, ref: '#/components/responses/403'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Domain conflict.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update_preview_by_pull_request_id(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$preview = ApplicationPreview::where('application_id', $application->id)
->where('pull_request_id', (int) $pullRequestIdRaw)
->first();
if (! $preview) {
return response()->json(['message' => 'Preview not found.'], 404);
}
$isCompose = $application->build_pack === BuildPackTypes::DOCKERCOMPOSE->value;
$validationRules = ['force_domain_override' => 'boolean'];
if ($isCompose) {
$validationRules = array_merge($validationRules, [
'domains' => 'missing',
'docker_compose_domains' => 'present|array',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'required|string|distinct',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
]);
} else {
$validationRules['domains'] = ['present', ...ValidationPatterns::applicationDomainRules()];
$validationRules['docker_compose_domains'] = 'missing';
}
$validator = Validator::make($request->all(), $validationRules);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$dockerComposeDomains = null;
$dockerComposeDomainsResponse = null;
if ($isCompose) {
try {
$compose = Yaml::parse($application->docker_compose_raw ?? '');
} catch (\Throwable) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration could not be parsed.'],
], 422);
}
$services = data_get($compose, 'services');
if (! is_array($services) || $services === []) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'The Docker Compose configuration must define at least one service.'],
], 422);
}
$composeServices = collect($services)
->reject(fn (mixed $service): bool => isDatabaseImage(data_get($service, 'image')))
->keys()
->map(fn (mixed $name): string => (string) $name)
->values();
$requestedServices = collect($request->input('docker_compose_domains'))->pluck('name');
if ($requestedServices->diff($composeServices)->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_compose_domains' => 'One or more Docker Compose services are invalid.'],
], 422);
}
$existingComposeDomains = json_decode($preview->docker_compose_domains ?? '[]', true) ?: [];
$dockerComposeDomains = $composeServices
->mapWithKeys(function (string $service) use ($existingComposeDomains): array {
$entry = ['domain' => ''];
$redirect = $existingComposeDomains[$service]['redirect'] ?? null;
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
return [$service => $entry];
})
->all();
foreach ($request->input('docker_compose_domains') as $item) {
$entry = ['domain' => ValidationPatterns::normalizeApplicationDomains(data_get($item, 'domain')) ?? ''];
$redirect = array_key_exists('redirect', $item)
? data_get($item, 'redirect')
: ($existingComposeDomains[data_get($item, 'name')]['redirect'] ?? null);
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomains[data_get($item, 'name')] = $entry;
}
$domains = collect($dockerComposeDomains)
->pluck('domain')
->filter()
->implode(',') ?: null;
} else {
$domains = ValidationPatterns::normalizeApplicationDomains($request->input('domains'));
}
$submittedUrls = collect(ValidationPatterns::applicationDomainList($domains))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain));
if ($submittedUrls->duplicates()->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
$isCompose ? 'docker_compose_domains' : 'domains' => 'The same domain cannot be configured more than once.',
],
], 422);
}
$normalized = DomainPortOverrides::normalize($domains, null);
$portlessDomains = $normalized['fqdn'];
if ($isCompose) {
foreach ($dockerComposeDomains as $service => $entry) {
$dockerComposeDomains[$service]['domain'] = collect(ValidationPatterns::applicationDomainList($entry['domain']))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->implode(',');
}
$dockerComposeDomainsResponse = collect($dockerComposeDomains)
->map(fn (array $entry, string $name): array => ['name' => $name, ...$entry])
->values()
->all();
}
$urls = collect(ValidationPatterns::applicationDomainList($portlessDomains));
$conflicts = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId);
if (isset($conflicts['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [$isCompose ? 'docker_compose_domains' : 'domains' => $conflicts['error']],
], 422);
}
if ($conflicts['hasConflicts'] && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $conflicts['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
$hostCandidates = $urls
->map(fn (string $url): string => (string) parse_url($url, PHP_URL_HOST))
->filter();
$conflictingPreview = null;
if ($hostCandidates->isNotEmpty()) {
$conflictingPreview = ApplicationPreview::query()
->whereIn('application_id', Application::ownedByCurrentTeamAPI($teamId)
->withoutGlobalScope('withRelations')
->reorder()
->select('applications.id'))
->whereKeyNot($preview->id)
->whereNotNull('fqdn')
->where(function ($query) use ($hostCandidates): void {
foreach ($hostCandidates as $host) {
$query->orWhere('fqdn', 'like', '%'.$host.'%');
}
})
->get(['uuid', 'pull_request_id', 'fqdn'])
->first(fn (ApplicationPreview $otherPreview): bool => collect(ValidationPatterns::applicationDomainList($otherPreview->fqdn))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->intersect($urls)
->isNotEmpty());
}
if ($conflictingPreview && ! $request->boolean('force_domain_override')) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => [[
'domain' => collect(ValidationPatterns::applicationDomainList($conflictingPreview->fqdn))
->map(fn (string $domain): string => DomainPortOverrides::withoutPort($domain))
->intersect($urls)
->first(),
'resource_name' => 'Preview deployment #'.$conflictingPreview->pull_request_id,
'resource_uuid' => $conflictingPreview->uuid,
'resource_type' => 'application',
'message' => 'Domain is already in use by another preview deployment.',
]],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
$preview->domain_port_overrides = $normalized['overrides'];
$preview->fqdn = $portlessDomains;
if ($isCompose) {
$preview->docker_compose_domains = json_encode($dockerComposeDomains);
}
$preview->save();
auditLog('api.application.preview_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'pull_request_id' => $preview->pull_request_id,
'changed_fields' => [$isCompose ? 'docker_compose_domains' : 'domains'],
]);
return response()->json([
'uuid' => $preview->uuid,
'pull_request_id' => $preview->pull_request_id,
'domains' => $preview->fqdn,
'docker_compose_domains' => $dockerComposeDomainsResponse,
'domain_port_overrides' => $preview->domain_port_overrides,
]);
}
#[OA\Delete(
summary: 'Delete',
description: 'Delete application by UUID.',
@ -3078,7 +2818,6 @@ public function update_by_uuid(Request $request)
'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string',
'include_source_commit_in_build' => 'boolean',
'ports_exposes' => 'nullable|string|regex:/^(\d+)(,\d+)*$/',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@ -3087,10 +2826,10 @@ public function update_by_uuid(Request $request)
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
// Validate ports_exposes
if ($request->filled('ports_exposes')) {
if ($request->has('ports_exposes')) {
$ports = explode(',', $request->ports_exposes);
foreach ($ports as $port) {
if (! is_numeric($port) || (int) $port < 1 || (int) $port > 65535) {
if (! is_numeric($port)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@ -3378,12 +3117,7 @@ public function update_by_uuid(Request $request)
}
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');
@ -5418,7 +5152,7 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$this->authorize('delete', $application);
$pullRequestIdRaw = $request->route('pull_request_id');
if (! ctype_digit((string) $pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
return response()->json(['message' => 'Invalid pull_request_id.'], 422);
}
$pullRequestId = (int) $pullRequestIdRaw;
@ -6130,28 +5864,4 @@ public function remove_destination(Request $request): JsonResponse
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']];
}
}

View file

@ -769,7 +769,6 @@ public function update_by_uuid(Request $request)
'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],
],
),
)
@ -806,7 +805,7 @@ public function update_by_uuid(Request $request)
)]
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', 'missing_backup_notification_days'];
$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'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -834,7 +833,6 @@ public function create_backup(Request $request)
'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()) {
@ -1027,7 +1025,6 @@ public function create_backup(Request $request)
'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],
],
),
)
@ -1057,7 +1054,7 @@ public function create_backup(Request $request)
)]
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', 'missing_backup_notification_days'];
$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'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -1083,7 +1080,6 @@ public function update_backup(Request $request)
'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([
@ -4889,8 +4885,6 @@ function () use ($database) {
'id',
'created_at',
'updated_at',
'last_execution_at',
'missing_backup_notification_sent_at',
])->fill([
'uuid' => new_public_id(),
'database_id' => $newDatabase->id,

View file

@ -15,7 +15,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class NotificationsController extends Controller
@ -46,7 +45,6 @@ private function channelConfig(string $channel): array
'deployment_success_email_notifications' => 'sometimes|boolean',
'deployment_failure_email_notifications' => 'sometimes|boolean',
'status_change_email_notifications' => 'sometimes|boolean',
'restart_limit_reached_email_notifications' => 'sometimes|boolean',
'backup_success_email_notifications' => 'sometimes|boolean',
'backup_failure_email_notifications' => 'sometimes|boolean',
'scheduled_task_success_email_notifications' => 'sometimes|boolean',
@ -68,7 +66,6 @@ private function channelConfig(string $channel): array
'deployment_success_discord_notifications' => 'sometimes|boolean',
'deployment_failure_discord_notifications' => 'sometimes|boolean',
'status_change_discord_notifications' => 'sometimes|boolean',
'restart_limit_reached_discord_notifications' => 'sometimes|boolean',
'backup_success_discord_notifications' => 'sometimes|boolean',
'backup_failure_discord_notifications' => 'sometimes|boolean',
'scheduled_task_success_discord_notifications' => 'sometimes|boolean',
@ -91,7 +88,6 @@ private function channelConfig(string $channel): array
'deployment_success_slack_notifications' => 'sometimes|boolean',
'deployment_failure_slack_notifications' => 'sometimes|boolean',
'status_change_slack_notifications' => 'sometimes|boolean',
'restart_limit_reached_slack_notifications' => 'sometimes|boolean',
'backup_success_slack_notifications' => 'sometimes|boolean',
'backup_failure_slack_notifications' => 'sometimes|boolean',
'scheduled_task_success_slack_notifications' => 'sometimes|boolean',
@ -114,7 +110,6 @@ private function channelConfig(string $channel): array
'deployment_success_telegram_notifications' => 'sometimes|boolean',
'deployment_failure_telegram_notifications' => 'sometimes|boolean',
'status_change_telegram_notifications' => 'sometimes|boolean',
'restart_limit_reached_telegram_notifications' => 'sometimes|boolean',
'backup_success_telegram_notifications' => 'sometimes|boolean',
'backup_failure_telegram_notifications' => 'sometimes|boolean',
'scheduled_task_success_telegram_notifications' => 'sometimes|boolean',
@ -129,7 +124,6 @@ private function channelConfig(string $channel): array
'telegram_notifications_deployment_success_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_deployment_failure_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_status_change_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_restart_limit_reached_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_backup_success_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_backup_failure_thread_id' => 'sometimes|nullable|string|max:255',
'telegram_notifications_scheduled_task_success_thread_id' => 'sometimes|nullable|string|max:255',
@ -152,7 +146,6 @@ private function channelConfig(string $channel): array
'deployment_success_pushover_notifications' => 'sometimes|boolean',
'deployment_failure_pushover_notifications' => 'sometimes|boolean',
'status_change_pushover_notifications' => 'sometimes|boolean',
'restart_limit_reached_pushover_notifications' => 'sometimes|boolean',
'backup_success_pushover_notifications' => 'sometimes|boolean',
'backup_failure_pushover_notifications' => 'sometimes|boolean',
'scheduled_task_success_pushover_notifications' => 'sometimes|boolean',
@ -174,7 +167,6 @@ private function channelConfig(string $channel): array
'deployment_success_webhook_notifications' => 'sometimes|boolean',
'deployment_failure_webhook_notifications' => 'sometimes|boolean',
'status_change_webhook_notifications' => 'sometimes|boolean',
'restart_limit_reached_webhook_notifications' => 'sometimes|boolean',
'backup_success_webhook_notifications' => 'sometimes|boolean',
'backup_failure_webhook_notifications' => 'sometimes|boolean',
'scheduled_task_success_webhook_notifications' => 'sometimes|boolean',
@ -257,7 +249,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse
$body = $request->json()->all();
$config = $this->channelConfig($channel);
$validator = Validator::make($body, $config['rules']);
$validator = customApiValidator($body, $config['rules']);
$extraFields = array_diff(array_keys($body), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {

View file

@ -138,7 +138,7 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
/**
* Build a stable hash of container state.
*
* Covers [name, state, restart_count] only metrics, filesystem_usage_root, and
* Covers [name, state] only metrics, filesystem_usage_root, and
* health_status are excluded on purpose. Disk % churns constantly, and
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
@ -153,7 +153,6 @@ private function containerStateHash(array $data): string
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
'restart_count' => data_get($c, 'restart_count'),
])
->sortBy('name')
->values()

View file

@ -12,6 +12,7 @@
class ServerSentinelController extends Controller
{
private const ALLOWED_FIELDS = [
'is_sentinel_enabled',
'is_metrics_enabled',
'is_sentinel_debug_enabled',
'sentinel_token',
@ -35,7 +36,7 @@ private function transform(Server $server): array
{
$settings = $server->settings;
$payload = [
'is_sentinel_enabled' => $server->isSentinelEnabled(),
'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled,
'is_metrics_enabled' => (bool) $settings->is_metrics_enabled,
'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled,
'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds,
@ -68,7 +69,7 @@ private function transform(Server $server): array
description: 'Sentinel settings.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'),
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'),
@ -117,6 +118,7 @@ public function show(Request $request): JsonResponse
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'),
new OA\Property(property: 'is_metrics_enabled', type: 'boolean'),
new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'),
new OA\Property(property: 'sentinel_token', type: 'string'),
@ -156,6 +158,7 @@ public function update(Request $request): JsonResponse
$this->authorize('update', $server);
$validator = customApiValidator($request->all(), [
'is_sentinel_enabled' => 'boolean',
'is_metrics_enabled' => 'boolean',
'is_sentinel_debug_enabled' => 'boolean',
'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'],
@ -186,12 +189,29 @@ public function update(Request $request): JsonResponse
}
$settings = $server->settings;
$enablingSentinel = $request->has('is_sentinel_enabled')
&& $request->boolean('is_sentinel_enabled')
&& ! $settings->is_sentinel_enabled;
if ($enablingSentinel && $server->isBuildServer()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']],
], 422);
}
foreach (self::ALLOWED_FIELDS as $field) {
if ($request->has($field)) {
$settings->{$field} = $request->input($field);
}
}
// Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel).
if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) {
$settings->is_metrics_enabled = false;
$settings->is_sentinel_debug_enabled = false;
}
$settings->save();
auditLog('api.server.sentinel.updated', [

View file

@ -9,7 +9,6 @@
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;
@ -258,7 +257,6 @@ public function show(Request $request): JsonResponse
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true),
'max_restart_count' => new OA\Property(property: 'max_restart_count', type: 'integer', minimum: 0, nullable: true, description: 'Maximum Docker restart count before Coolify stops the container. Set to 0 to disable the limit.'),
]
)
)
@ -332,11 +330,10 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_force_https_enabled',
'max_restart_count',
];
$validationRules = [
'url' => ValidationPatterns::applicationDomainRules(),
'url' => 'nullable|string',
'noindex_domains' => 'sometimes|array|nullable',
'noindex_domains.*' => 'string',
'human_name' => 'nullable|string|max:255',
@ -347,7 +344,6 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
'is_force_https_enabled' => 'sometimes|boolean',
'max_restart_count' => 'sometimes|integer|min:0',
];
$validator = Validator::make($payload, $validationRules);

View file

@ -386,7 +386,7 @@ public function create_service(Request $request)
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
@ -602,7 +602,7 @@ public function create_service(Request $request)
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
@ -1187,7 +1187,7 @@ public function update_by_uuid(Request $request)
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => ValidationPatterns::applicationDomainRules(),
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];

View file

@ -44,14 +44,6 @@ public function callback(string $provider)
}
Auth::login($user);
$team = $user->resolveStoredTeam();
if (! $team && $user->teams()->count() === 0) {
$team = $user->recreate_personal_team();
}
if ($team) {
session(['currentTeam' => $user->currentTeam = $team]);
}
return redirect('/');
} catch (\Exception $e) {
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';

View file

@ -14,7 +14,7 @@ public function __invoke(AvatarStorageService $avatarStorage): Response
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
'Cache-Control' => 'private, max-age=300',
]);
}
}

View file

@ -15,9 +15,6 @@ public function __invoke(string $project_uuid, ProjectIconStorageService $iconSt
abort_if($contents === null, 404);
return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
return response($contents)->header('Content-Type', 'image/jpeg');
}
}

View file

@ -5,6 +5,7 @@
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications
{
@ -78,8 +79,12 @@ protected function canonicalManualWebhookRepository(?string $gitRepository): ?st
if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path');
} elseif (($scp = parseScpStyleGitUrl($gitRepository)) !== null) {
$path = $scp['path'];
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':');
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else {
$path = $gitRepository;
}

View file

@ -83,10 +83,7 @@ public function manual(Request $request)
}
}
if ($x_github_event === 'pull_request') {
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $this->manualWebhookApplications($applications, $full_name);
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@ -337,10 +334,7 @@ public function normal(Request $request)
}
}
if ($x_github_event === 'pull_request') {
if ($action !== 'closed') {
$applications->where('git_branch', $base_branch);
}
$applications = $applications->get();
$applications = $applications->where('git_branch', $base_branch)->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}

View file

@ -28,24 +28,9 @@ public function handle(Request $request, Closure $next): Response
}
if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam());
// A team is already active; the selection screen no longer applies.
if ($request->routeIs('team.select')) {
return redirect()->route('dashboard');
}
} elseif (auth()?->user()?->teams?->count() > 0) {
// No active team in the session (fresh login or invalidated selection).
// Restore the last active team, or the sole team of a single-team user.
$resolvedTeam = auth()->user()->resolveStoredTeam();
if ($resolvedTeam) {
refreshSession($resolvedTeam);
} elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) {
// Ambiguous choice: let the user pick a team on the selection screen.
// Livewire's update endpoint must pass through too, otherwise the
// selection action's AJAX call is redirected to HTML and never runs.
return $next($request);
} else {
return redirect()->route('team.select');
}
// User's session team is invalid (e.g., removed from team), switch to first available team
refreshSession(auth()->user()->teams->first());
}
if (! auth()->user() || ! isCloud()) {
if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) {

View file

@ -44,10 +44,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env';
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
@ -205,10 +201,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerSecretsSupported = false;
private bool $dockerSecretsAvailable = false;
private bool $useBuildtimeEnvironmentLauncher = false;
private bool $skip_build = false;
private Collection|string $build_secrets;
@ -269,7 +261,14 @@ public function __construct(public int $application_deployment_queue_id)
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->container_name = $this->resolveContainerName();
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
if ($this->application->settings->custom_internal_name && ! $this->application->settings->is_consistent_container_name_enabled) {
if ($this->pull_request_id === 0) {
$this->container_name = $this->application->settings->custom_internal_name;
} else {
$this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
}
$this->saved_outputs = collect();
@ -426,11 +425,6 @@ public function handle(): void
private function detectBuildKitCapabilities(): void
{
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@ -481,19 +475,18 @@ private function detectBuildKitCapabilities(): void
}
}
if ($this->dockerBuildkitSupported) {
// If build secrets are enabled and BuildKit is available, verify --secret flag support
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
$this->dockerSecretsAvailable = true;
if ($this->application->settings->use_build_secrets) {
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
}
} elseif ($this->application->settings->use_build_secrets) {
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
@ -501,7 +494,6 @@ private function detectBuildKitCapabilities(): void
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
}
@ -808,8 +800,6 @@ private function deploy_docker_compose_buildpack()
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->pull_docker_compose_images();
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@ -913,26 +903,6 @@ private function deploy_docker_compose_buildpack()
$this->application_deployment_queue->addLogEntry('New container started.');
}
private function pull_docker_compose_images(): void
{
$this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.');
if ($this->use_build_server) {
$this->write_deployment_configurations();
$this->server = $this->mainServer;
$workdir = $this->application->workdir();
$command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable";
} else {
$workdir = $this->workdir;
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable");
}
$this->execute_remote_command([
$command,
'hidden' => true,
]);
}
private function deploy_dockerfile_buildpack()
{
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
@ -1668,14 +1638,11 @@ private function generate_buildtime_environment_variables()
}
foreach ($planVariables as $key => $value) {
$key = (string) $key;
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
continue;
}
$key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan');
$escapedValue = escapeBashEnvValue($value);
$envs_dict[$key] = $escapedValue;
@ -1863,7 +1830,6 @@ private function generate_buildtime_environment_variables()
// Convert dictionary back to collection in KEY=VALUE format
$envs = collect([]);
foreach ($envs_dict as $key => $value) {
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment');
$envs->push($key.'='.$value);
}
@ -1877,128 +1843,42 @@ private function generate_buildtime_environment_variables()
return $envs;
}
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
{
try {
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
}
return $key;
} catch (\InvalidArgumentException $exception) {
$this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin);
throw new DeploymentException(
"Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.',
previous: $exception,
);
}
}
private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void
{
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
$this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr');
$this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr');
$this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info');
if ($origin === 'the Nixpacks plan') {
$this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info');
$this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info');
$this->logSuggestedShellEnvironmentVariableKey($key);
$this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info');
$this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info');
} else {
$this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info');
$this->logSuggestedShellEnvironmentVariableKey($key);
}
$this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr');
}
private function logSuggestedShellEnvironmentVariableKey(string $key): void
{
$suggestedKey = str_replace('.', '_', $key);
if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) {
return;
}
$displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey);
$this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info');
}
private function save_buildtime_environment_variables()
{
// Generate build-time environment variables locally
$environment_variables = $this->generate_buildtime_environment_variables();
[$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool {
[$key] = explode('=', $environmentVariable, 2);
return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1;
});
// Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
if ($dotted_environment_variables->isEmpty()) {
$this->useBuildtimeEnvironmentLauncher = false;
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
$this->execute_remote_command([
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
]);
]
);
if (isDev()) {
$this->execute_remote_command([
if (isDev()) {
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_TIME_ENV_PATH),
'hidden' => true,
]);
}
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
]);
]
);
}
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
return;
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH),
]
);
}
$this->useBuildtimeEnvironmentLauncher = true;
$launcher = [
'#!/bin/bash',
'set -a',
'source '.self::BUILD_TIME_SHELL_ENV_PATH,
'set +a',
];
$launcher[] = 'exec env \\';
foreach ($dotted_environment_variables as $environmentVariable) {
$launcher[] = " {$environmentVariable} \\";
}
$launcher[] = ' "$@"';
$files = [
self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"),
self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"),
self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n",
];
$this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true);
foreach ($files as $path => $contents) {
$contents_base64 = base64_encode($contents);
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"),
]);
}
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH),
]);
}
private function elixir_finetunes()
@ -2106,19 +1986,6 @@ private function rolling_update()
}
}
private function resolveContainerName(): string
{
if (str($this->application->settings->custom_internal_name)->isEmpty()) {
return generateApplicationContainerName($this->application, $this->pull_request_id);
}
if ($this->pull_request_id === 0) {
return $this->application->settings->custom_internal_name;
}
return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
}
private function health_check()
{
try {
@ -2384,8 +2251,12 @@ private function deploy_to_additional_destinations()
destination: $destination,
no_questions_asked: true,
);
$deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: ".route('project.application.deployment.show', [
'project_uuid' => data_get($this->application, 'environment.project.uuid'),
'application_uuid' => data_get($this->application, 'uuid'),
'deployment_uuid' => $deployment_uuid,
'environment_uuid' => data_get($this->application, 'environment.uuid'),
]));
}
}
@ -2403,20 +2274,15 @@ private function set_coolify_variables()
$fqdn = $this->preview->fqdn;
}
if (isset($fqdn)) {
$domains = str($fqdn)->explode(',')
->map(fn (string $domain) => trim($domain))
->filter()
->filter(fn (string $domain) => isValidDomainUrl($domain));
if ($domains->isNotEmpty()) {
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
$fqdn = $domains->map(fn (string $domain) => Url::fromString($domain)->getHost())->implode(',');
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
$url = Url::fromString($fqdn);
$fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
if (isset($this->application->git_branch)) {
@ -3562,9 +3428,6 @@ private function generate_compose_file()
$custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options);
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
$docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name];
if ($this->container_name !== $this->application->uuid) {
unset($docker_compose['services'][$this->container_name]);
}
if (count($custom_compose) > 0) {
$ipv4 = data_get($custom_compose, 'ip.0');
$ipv6 = data_get($custom_compose, 'ip6.0');
@ -3781,13 +3644,7 @@ private function build_static_image()
*/
private function wrap_build_command_with_env_export(string $build_command): string
{
if (! $this->useBuildtimeEnvironmentLauncher) {
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
}
$escapedBuildCommand = escapeBashEnvValue($build_command);
return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}";
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
}
private function build_image()
@ -4170,10 +4027,7 @@ private function stop_running_container(bool $force = false)
$this->application_deployment_queue->addLogEntry('Removing old containers.');
if ($this->newVersionIsHealthy || $force) {
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
$this->graceful_shutdown_container($containerName);
});
$this->graceful_shutdown_container($this->container_name);
} else {
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($this->pull_request_id === 0) {
@ -4212,16 +4066,6 @@ private function stop_running_container(bool $force = false)
}
}
private function containerNamesToRemove(Collection $containers): Collection
{
return $containers
->pluck('Names')
->push($this->container_name)
->filter()
->unique()
->values();
}
private function start_by_compose_file()
{
try {
@ -4310,21 +4154,6 @@ private function generate_build_env_variables()
$this->analyzeBuildTimeVariables($variables);
}
$requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks'
&& $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.'));
if ($requiresDottedEnvironmentSecrets) {
if (! $this->dockerSecretsAvailable) {
$dottedKeys = $variables->keys()
->filter(fn ($key): bool => str_contains((string) $key, '.'))
->implode(', ');
throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.");
}
$this->dockerSecretsSupported = true;
}
if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
@ -4589,7 +4418,7 @@ private function add_build_env_variables_to_dockerfile()
private function modify_dockerfile_for_secrets($dockerfile_path)
{
// Only process if build secrets are enabled and we have secrets to mount
if (empty($this->build_secrets)) {
if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) {
return;
}
@ -4613,51 +4442,18 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
$this->generate_env_variables();
}
$variables = $this->application->build_pack === 'nixpacks'
? collect($this->nixpacks_plan_json->get('variables'))
: $this->env_args;
$variables = $this->env_args;
if ($variables->isEmpty()) {
return;
}
$dottedKeys = $variables->keys()
->map(fn ($key): string => (string) $key)
->filter(fn (string $key): bool => str_contains($key, '.'));
if ($dottedKeys->isNotEmpty()) {
$originalDockerfile = $dockerfile;
$dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string {
$trimmedLine = trim($line);
if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) {
return $line;
}
[$instruction, $arguments] = explode(' ', $trimmedLine, 2);
$filteredArguments = collect(preg_split('/\s+/', $arguments))
->reject(function (string $argument) use ($dottedKeys): bool {
$key = str($argument)->before('=')->toString();
return $dottedKeys->contains($key);
});
if ($filteredArguments->isEmpty()) {
return null;
}
return $instruction.' '.$filteredArguments->implode(' ');
})->filter()->values();
$modified = $dockerfile->all() !== $originalDockerfile->values()->all();
}
// Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified ??= false;
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@ -5154,21 +4950,11 @@ private function handleSuccessfulDeployment(): void
// Reset restart count after successful deployment
// This is done here (not in Livewire) to avoid race conditions
// with GetContainersStatus reading old container restart counts
$restartState = [
$this->application->update([
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
];
if ($this->pull_request_id === 0) {
$restartState['restart_limit_reached'] = false;
}
if ($this->pull_request_id === 0) {
$this->application->update($restartState);
} else {
$this->preview?->resetRestartLimit();
}
]);
try {
$this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);

View file

@ -4,7 +4,6 @@
use App\Actions\Shared\CheckDomainDns;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceApplication;
use Illuminate\Bus\Queueable;
@ -24,7 +23,7 @@ class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
public int $timeout = 30;
public function __construct(
public Application|ApplicationPreview|ServiceApplication $resource,
public Application|ServiceApplication $resource,
public string $statusKey,
public string $url,
public ?Server $server,

View file

@ -1,59 +0,0 @@
<?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();
}
}

View file

@ -2,8 +2,6 @@
namespace App\Jobs;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
@ -35,13 +33,8 @@ public function __construct(
*/
public function handle(): void
{
$this->server->refresh();
$this->clearOutdatedInfo();
if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) {
return;
}
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
@ -123,10 +116,7 @@ public function handle(): void
private function clearOutdatedInfo(): void
{
$this->server->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
$this->server->update(['traefik_outdated_info' => null]);
}
/**

View file

@ -19,20 +19,6 @@ class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
Server::query()
->where(function ($query) {
$query->whereNull('proxy')
->orWhere('proxy->type', '!=', ProxyTypes::TRAEFIK->value);
})
->where(function ($query) {
$query->whereNotNull('detected_traefik_version')
->orWhereNotNull('traefik_outdated_info');
})
->update([
'detected_traefik_version' => null,
'traefik_outdated_info' => null,
]);
// Load versions from cached data
$traefikVersions = get_traefik_versions();

View file

@ -19,11 +19,6 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
public function __construct(public Server $server) {}
private static function helperContainersCommand(): string
{
return 'docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image|test("(^|/)coollabsio/coolify-helper(:|@)")))\'';
}
public function handle(): void
{
try {
@ -41,7 +36,7 @@ public function handle(): void
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout([self::helperContainersCommand()], $this->server, false);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {

View file

@ -322,7 +322,6 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
if ($database === '*') {
@ -344,7 +343,6 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
$this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -359,7 +357,6 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
$this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -374,7 +371,6 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mariadb($database);
} elseif ($this->database instanceof StandaloneClickhouse) {
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
@ -386,7 +382,6 @@ public function handle(): void
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_clickhouse($database);
} else {
throw new \Exception('Unsupported database type');
@ -485,14 +480,14 @@ public function handle(): void
} catch (Throwable $e) {
throw $e;
} finally {
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
if ($this->backup_log) {
$this->backup_log->update([
'finished_at' => Carbon::now()->toImmutable(),
]);
}
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
}
}

View file

@ -2,14 +2,11 @@
namespace App\Jobs;
use App\Actions\Application\StopApplication;
use App\Actions\Application\StopApplicationPreview;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Actions\Server\StartLogDrain;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Shared\ComplexStatusCheck;
use App\Models\Application;
use App\Models\ApplicationPreview;
@ -26,10 +23,8 @@
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\SwarmDocker;
use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator;
use App\Services\RestartCountTracker;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -100,14 +95,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $applicationContainerStatuses;
public Collection $applicationContainerRestartCounts;
public Collection $serviceContainerStatuses;
public Collection $previewContainerRestartCounts;
public Collection $serviceContainerRestartCounts;
public bool $foundProxy = false;
public bool $foundLogDrainContainer = false;
@ -133,10 +122,7 @@ public function __construct(public Server $server, public $data)
$this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect();
$this->applicationContainerRestartCounts = collect();
$this->serviceContainerStatuses = collect();
$this->previewContainerRestartCounts = collect();
$this->serviceContainerRestartCounts = collect();
$this->allApplicationIds = collect();
$this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect();
@ -154,10 +140,7 @@ public function handle()
{
// Defensive initialization for Collection properties to handle queue deserialization edge cases
$this->serviceContainerStatuses ??= collect();
$this->previewContainerRestartCounts ??= collect();
$this->serviceContainerRestartCounts ??= collect();
$this->applicationContainerStatuses ??= collect();
$this->applicationContainerRestartCounts ??= collect();
$this->foundApplicationIds ??= collect();
$this->foundDatabaseUuids ??= collect();
$this->foundServiceApplicationIds ??= collect();
@ -248,9 +231,6 @@ public function handle()
if (! $coolify_managed) {
continue;
}
if (filter_var($labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
@ -261,10 +241,6 @@ public function handle()
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
try {
if ($pullRequestId === '0') {
$application = $this->applicationsById->get((string) $applicationId);
if ($application && $application->container_present !== true) {
$application->update(['container_present' => true]);
}
if ($this->allApplicationIds->contains($applicationId)) {
$this->foundApplicationIds->push($applicationId);
}
@ -275,13 +251,6 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
$restartCount = data_get($container, 'restart_count');
if (is_numeric($restartCount)) {
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
$this->applicationContainerRestartCounts->put($applicationId, collect());
}
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, (int) $restartCount);
}
}
} else {
$previewKey = $applicationId.':'.$pullRequestId;
@ -289,13 +258,6 @@ public function handle()
$this->foundApplicationPreviewsIds->push($previewKey);
}
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
$restartCount = data_get($container, 'restart_count');
if (is_numeric($restartCount)) {
$this->previewContainerRestartCounts->push([
'key' => $previewKey,
'count' => (int) $restartCount,
]);
}
}
} catch (\Exception $e) {
}
@ -316,7 +278,6 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
}
} elseif ($subType === 'database') {
$this->foundServiceDatabaseIds->push($subId);
@ -328,7 +289,6 @@ public function handle()
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->storeServiceRestartCount($key, $containerName, data_get($container, 'restart_count'));
}
}
} else {
@ -342,9 +302,9 @@ public function handle()
$this->foundDatabaseUuids->push($uuid);
// TCP proxy should only be started/managed when database is actually running
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: true);
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
$this->updateDatabaseStatus($uuid, $containerStatus, data_get($container, 'restart_count'), tcpProxy: false);
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
}
}
}
@ -357,9 +317,6 @@ public function handle()
$this->updateProxyStatus();
Application::whereIn('id', $this->foundApplicationIds->unique())
->update(['container_present' => true]);
$this->updateNotFoundApplicationStatus();
$this->updateNotFoundApplicationPreviewStatus();
$this->updateNotFoundDatabaseStatus();
@ -367,8 +324,6 @@ public function handle()
$this->updateAdditionalServersStatus();
$this->trackPreviewRestartCounts();
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
@ -394,18 +349,11 @@ private function loadApplications(): Collection
'uuid',
'name',
'status',
'container_present',
'build_pack',
'docker_compose_raw',
'environment_id',
'destination_id',
'destination_type',
'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
])
->withCount('additional_servers')
->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
@ -424,18 +372,11 @@ private function loadApplications(): Collection
'uuid',
'name',
'status',
'container_present',
'build_pack',
'docker_compose_raw',
'environment_id',
'destination_id',
'destination_type',
'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
])
->withCount('additional_servers')
->whereIn('id', $additionalApplicationIds)
@ -461,11 +402,6 @@ private function loadPreviews(): Collection
'pull_request_id',
'status',
'last_online_at',
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
])
->whereIn('application_id', $applicationIds)
->get();
@ -481,7 +417,7 @@ private function loadServices(): Collection
'docker_compose_raw',
])
->with([
'applications:id,service_id,status,last_online_at,restart_count,max_restart_count,restart_limit_reached,last_restart_at,last_restart_type',
'applications:id,service_id,status,last_online_at',
'databases:id,service_id,status,last_online_at,is_public,name',
])
->get();
@ -559,53 +495,6 @@ private function aggregateMultiContainerStatuses()
continue;
}
$maxRestartCount = 0;
$restartCountsAvailable = $this->applicationContainerRestartCounts->has($applicationId);
if ($restartCountsAvailable) {
$maxRestartCount = $this->applicationContainerRestartCounts->get($applicationId)->max() ?? 0;
$restartState = (new RestartCountTracker)->evaluate(
previousRestartCount: $application->restart_count ?? 0,
observedRestartCount: $maxRestartCount,
maxRestartCount: $application->max_restart_count ?? 0,
);
if ($restartState['restart_count_changed']) {
$hasCrashRestarts = $restartState['restart_count'] > 0;
$application->update([
'restart_count' => $restartState['restart_count'],
'last_restart_at' => $hasCrashRestarts ? now() : null,
'last_restart_type' => $hasCrashRestarts ? 'crash' : null,
]);
}
if ($restartState['restart_limit_reached']) {
$restartLimitClaimed = Application::query()
->whereKey($application->getKey())
->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
$application->refresh();
StopApplication::dispatch(
application: $application,
previewDeployments: false,
dockerCleanup: false,
resetRestartCount: false,
removeContainers: false,
);
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
}
if ($application->stoppedAfterRestartLimit() && $containerStatuses->every(
fn (string $status): bool => str($status)->contains('exited')
)) {
$application->update(['status' => 'exited']);
continue;
}
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -630,7 +519,7 @@ private function aggregateMultiContainerStatuses()
// Use ContainerStatusAggregator service for state machine logic
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
@ -671,14 +560,6 @@ private function aggregateServiceContainerStatuses()
continue;
}
$restartCount = $this->serviceContainerRestartCounts->get($key)?->max() ?? 0;
if (! $subResource instanceof ServiceDatabase && $subResource->trackRestartCount($restartCount)) {
StopServiceApplication::dispatch($subResource, false, false);
$subResource->team()?->notify(new ApplicationRestartLimitReached($subResource));
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
@ -700,9 +581,10 @@ private function aggregateServiceContainerStatuses()
}
// Use ContainerStatusAggregator service for state machine logic
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, $restartCount, preserveRestarting: true);
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
@ -745,11 +627,8 @@ private function updateNotFoundApplicationStatus()
// Batch update: mark all not-found applications as exited (excluding already exited ones)
Application::whereIn('id', $notFoundApplicationIds)
->update([
'status' => 'exited',
'container_present' => false,
'restart_limit_reached' => false,
]);
->where('status', 'not like', 'exited%')
->update(['status' => 'exited']);
}
private function updateNotFoundApplicationPreviewStatus()
@ -808,7 +687,7 @@ private function updateProxyStatus()
}
}
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, mixed $restartCount = null, bool $tcpProxy = false): void
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{
$database = $this->databasesByUuid->get($databaseUuid);
if (! $database) {
@ -818,13 +697,6 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus;
$database->save();
}
if (is_numeric($restartCount) && $restartCount > ($database->restart_count ?? 0)) {
$database->update([
'restart_count' => (int) $restartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
}
if (! $this->isCompleteSnapshot()) {
return;
}
@ -847,30 +719,6 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
}
}
private function storeServiceRestartCount(string $key, string $containerName, mixed $restartCount): void
{
if (! is_numeric($restartCount)) {
return;
}
if (! $this->serviceContainerRestartCounts->has($key)) {
$this->serviceContainerRestartCounts->put($key, collect());
}
$this->serviceContainerRestartCounts->get($key)->put($containerName, (int) $restartCount);
}
private function trackPreviewRestartCounts(): void
{
$this->previewContainerRestartCounts
->groupBy('key')
->each(function (Collection $counts, string $key): void {
$preview = $this->previewsByKey->get($key);
if ($preview?->trackRestartCount((int) $counts->max('count'))) {
StopApplicationPreview::dispatch($preview, false, false);
$preview->application->environment->project->team?->notify(new ApplicationRestartLimitReached($preview));
}
});
}
private function updateNotFoundDatabaseStatus()
{
$notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids);
@ -904,9 +752,8 @@ private function updateNotFoundServiceStatus()
// Batch update service applications
if ($notFoundServiceApplicationIds->isNotEmpty()) {
ServiceApplication::whereIn('id', $notFoundServiceApplicationIds)
->where('restart_limit_reached', false)
->where('status', '!=', 'exited')
->update(['status' => 'exited', 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null]);
->update(['status' => 'exited']);
}
// Batch update service databases

View file

@ -66,10 +66,7 @@ public function handle()
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
);
$resource = $certificate->database;
if ($resource) {
$regenerated->push($resource);
}
$regenerated->push($certificate);
} catch (\Exception $e) {
Log::error('Failed to regenerate SSL certificate: '.$e->getMessage());
}

View file

@ -166,6 +166,14 @@ private function processServerTasks(Server $server): void
}
}
$isSentinelEnabled = $server->isSentinelEnabled();
$shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime);
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($shouldRestartSentinel) {
CheckAndStartSentinelJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
// When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data
if ($sentinelOutOfSync) {
@ -187,6 +195,7 @@ private function processServerTasks(Server $server): void
ServerPatchCheckJob::dispatch($server);
}
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}

View file

@ -74,7 +74,7 @@ public function handle(): void
// 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 = Subscription::updateOrCreate(
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@ -83,12 +83,6 @@ public function handle(): void
'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');
@ -224,7 +218,7 @@ public function handle(): void
// 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 = Subscription::firstOrCreate(
Subscription::updateOrCreate(
['team_id' => $teamId],
[
'stripe_subscription_id' => $subscriptionId,
@ -232,11 +226,6 @@ public function handle(): void
'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');

View file

@ -202,9 +202,6 @@ public function handle(): void
// Broadcast events to update UI
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
ServerReachabilityChanged::dispatch($this->server);
if ($this->server->isSentinelEnabled()) {
CheckAndStartSentinelJob::dispatch($this->server);
}
} catch (\Throwable $e) {
Log::error('ValidateAndInstallServer: Exception occurred', [

View file

@ -73,7 +73,6 @@ public function handle(): void
$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();
@ -87,17 +86,11 @@ public function handle(): void
$compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage);
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
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);
}
$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);
@ -111,23 +104,21 @@ public function handle(): void
}
}
$archiveOutput = instant_remote_process(array_filter([
instant_remote_process([
$verifySourceCommand,
$streamToS3 ? null : 'mkdir -p '.escapeshellarg($backupDirectory),
'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 = $streamToS3
? (int) str($archiveOutput)->trim()->afterLast("\n")->toString()
: (int) instant_remote_process(
['du -b '.escapeshellarg($backupLocation).' | cut -f1'],
$server,
disableMultiplexing: true,
);
$size = (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.');
@ -136,12 +127,9 @@ public function handle(): void
$warning = null;
$s3Uploaded = null;
$s3CleanupPending = false;
$localStorageDeleted = $streamToS3;
$localStorageDeleted = false;
if ($streamToS3) {
$s3Uploaded = true;
$this->execution->update(['s3_cleanup_pending' => false]);
} elseif ($this->backup->save_s3) {
if ($this->backup->save_s3) {
$s3CleanupPending = true;
$this->execution->update(['s3_cleanup_pending' => true]);
@ -193,23 +181,13 @@ public function handle(): void
}
} catch (Throwable $exception) {
$recoveryError = $this->recoverIncompleteBackup($this->execution);
$archiveDeleted = $streamToS3;
$archiveDeleted = false;
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();
}
try {
deleteBackupsLocally($backupLocation, $server, throwError: true);
$archiveDeleted = true;
} catch (Throwable $cleanupException) {
$recoveryError .= ' Archive cleanup failed: '.$cleanupException->getMessage();
}
$s3CleanupPending = $this->execution->fresh()->s3_cleanup_pending;
@ -217,9 +195,7 @@ public function handle(): void
$this->execution->update([
'status' => 'failed',
'message' => $exception->getMessage().$recoveryError,
'filename' => $streamToS3
? ($s3CleanupPending ? $backupLocation : null)
: ($archiveDeleted && ! $s3CleanupPending ? null : $backupLocation),
'filename' => $archiveDeleted && ! $s3CleanupPending ? null : $backupLocation,
'local_storage_deleted' => $archiveDeleted,
]);
@ -362,34 +338,6 @@ private function uploadToS3(string $backupLocation, string $backupDirectory, Ser
}
}
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()) {

View file

@ -33,7 +33,7 @@ public function back()
if (session('impersonating')) {
session()->forget('impersonating');
$user = User::find(0);
$team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
$team_to_switch_to = $user->teams->first();
Auth::login($user);
refreshSession($team_to_switch_to);
@ -69,7 +69,7 @@ public function switchUser(int $user_id)
if (! $user) {
abort(404);
}
$team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first();
$team_to_switch_to = $user->teams->first();
Auth::login($user);
refreshSession($team_to_switch_to);

View file

@ -209,20 +209,19 @@ protected function serverIpsForDnsHints(): array
}
}
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) {
//
// 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 ($ipv6 === null && is_string($publicV6) && filter_var($publicV6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ipv6 = $publicV6;
}
} catch (\Throwable) {
//
}
return [$ipv4, $ipv6];
@ -254,7 +253,5 @@ protected function serverIpForDomainConnect(): ?string
return null;
}
abstract protected function usesInstanceNetworkAddressesForDnsHints(): bool;
abstract protected function authorizeUpdateForDomainConnect(): void;
}

View file

@ -43,7 +43,7 @@ public function mount(string $destination_uuid)
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -85,7 +85,7 @@ public function delete()
}
$this->destination->delete();
return redirectRoute($this, 'destination.index');
return redirect()->route('destination.index');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -1507,7 +1507,8 @@ public function getServicesProperty()
'type' => 'one-click-service-'.$serviceKey,
'category' => 'Services',
'resourceType' => 'service',
] + service_logo_urls(data_get($service, 'logo')) + array_filter([
'logo' => data_get($service, 'logo'),
] + array_filter([
'amd_only' => data_get($service, 'amd_only') ? true : null,
'arm_only' => data_get($service, 'arm_only') ? true : null,
]));

View file

@ -8,7 +8,6 @@ trait TogglesNotificationEvents
'deploymentSuccess',
'deploymentFailure',
'statusChange',
'restartLimitReached',
'backupSuccess',
'backupFailure',
'scheduledTaskSuccess',

View file

@ -34,9 +34,6 @@ class Discord extends Component
#[Validate(['boolean'])]
public bool $statusChangeDiscordNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedDiscordNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessDiscordNotifications = false;
@ -85,17 +82,17 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->discord_enabled = $this->discordEnabled;
$this->settings->discord_webhook_url = $this->discordWebhookUrl;
$this->settings->deployment_success_discord_notifications = $this->deploymentSuccessDiscordNotifications;
$this->settings->deployment_failure_discord_notifications = $this->deploymentFailureDiscordNotifications;
$this->settings->status_change_discord_notifications = $this->statusChangeDiscordNotifications;
$this->settings->restart_limit_reached_discord_notifications = $this->restartLimitReachedDiscordNotifications;
$this->settings->backup_success_discord_notifications = $this->backupSuccessDiscordNotifications;
$this->settings->backup_failure_discord_notifications = $this->backupFailureDiscordNotifications;
$this->settings->scheduled_task_success_discord_notifications = $this->scheduledTaskSuccessDiscordNotifications;
@ -121,7 +118,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications;
$this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications;
$this->statusChangeDiscordNotifications = $this->settings->status_change_discord_notifications;
$this->restartLimitReachedDiscordNotifications = $this->settings->restart_limit_reached_discord_notifications;
$this->backupSuccessDiscordNotifications = $this->settings->backup_success_discord_notifications;
$this->backupFailureDiscordNotifications = $this->settings->backup_failure_discord_notifications;
$this->scheduledTaskSuccessDiscordNotifications = $this->settings->scheduled_task_success_discord_notifications;
@ -173,7 +169,6 @@ public function instantSaveDiscordEnabled()
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@ -184,7 +179,6 @@ public function submit()
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@ -194,8 +188,6 @@ public function submit()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');

View file

@ -79,9 +79,6 @@ class Email extends Component
#[Validate(['boolean'])]
public bool $statusChangeEmailNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedEmailNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessEmailNotifications = false;
@ -132,11 +129,12 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
@ -157,7 +155,6 @@ private function syncData(bool $toModel = false): void
$this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications;
$this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications;
$this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications;
$this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications;
$this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications;
$this->settings->backup_failure_email_notifications = $this->backupFailureEmailNotifications;
$this->settings->scheduled_task_success_email_notifications = $this->scheduledTaskSuccessEmailNotifications;
@ -196,7 +193,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications;
$this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications;
$this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications;
$this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications;
$this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications;
$this->backupFailureEmailNotifications = $this->settings->backup_failure_email_notifications;
$this->scheduledTaskSuccessEmailNotifications = $this->settings->scheduled_task_success_email_notifications;
@ -223,8 +219,6 @@ public function submit()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->dispatch('success', 'Email notifications settings updated.');
}

View file

@ -41,9 +41,6 @@ class Pushover extends Component
#[Validate(['boolean'])]
public bool $statusChangePushoverNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedPushoverNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessPushoverNotifications = false;
@ -89,10 +86,11 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->pushover_enabled = $this->pushoverEnabled;
$this->settings->pushover_user_key = $this->pushoverUserKey;
$this->settings->pushover_api_token = $this->pushoverApiToken;
@ -100,7 +98,6 @@ private function syncData(bool $toModel = false): void
$this->settings->deployment_success_pushover_notifications = $this->deploymentSuccessPushoverNotifications;
$this->settings->deployment_failure_pushover_notifications = $this->deploymentFailurePushoverNotifications;
$this->settings->status_change_pushover_notifications = $this->statusChangePushoverNotifications;
$this->settings->restart_limit_reached_pushover_notifications = $this->restartLimitReachedPushoverNotifications;
$this->settings->backup_success_pushover_notifications = $this->backupSuccessPushoverNotifications;
$this->settings->backup_failure_pushover_notifications = $this->backupFailurePushoverNotifications;
$this->settings->scheduled_task_success_pushover_notifications = $this->scheduledTaskSuccessPushoverNotifications;
@ -128,7 +125,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications;
$this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications;
$this->statusChangePushoverNotifications = $this->settings->status_change_pushover_notifications;
$this->restartLimitReachedPushoverNotifications = $this->settings->restart_limit_reached_pushover_notifications;
$this->backupSuccessPushoverNotifications = $this->settings->backup_success_pushover_notifications;
$this->backupFailurePushoverNotifications = $this->settings->backup_failure_pushover_notifications;
$this->scheduledTaskSuccessPushoverNotifications = $this->settings->scheduled_task_success_pushover_notifications;
@ -166,7 +162,6 @@ public function instantSavePushoverEnabled()
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@ -179,7 +174,6 @@ public function submit()
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@ -189,8 +183,6 @@ public function submit()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');

View file

@ -39,9 +39,6 @@ class Slack extends Component
#[Validate(['boolean'])]
public bool $statusChangeSlackNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedSlackNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessSlackNotifications = false;
@ -87,17 +84,17 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->slack_enabled = $this->slackEnabled;
$this->settings->slack_webhook_url = $this->slackWebhookUrl;
$this->settings->deployment_success_slack_notifications = $this->deploymentSuccessSlackNotifications;
$this->settings->deployment_failure_slack_notifications = $this->deploymentFailureSlackNotifications;
$this->settings->status_change_slack_notifications = $this->statusChangeSlackNotifications;
$this->settings->restart_limit_reached_slack_notifications = $this->restartLimitReachedSlackNotifications;
$this->settings->backup_success_slack_notifications = $this->backupSuccessSlackNotifications;
$this->settings->backup_failure_slack_notifications = $this->backupFailureSlackNotifications;
$this->settings->scheduled_task_success_slack_notifications = $this->scheduledTaskSuccessSlackNotifications;
@ -121,7 +118,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications;
$this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications;
$this->statusChangeSlackNotifications = $this->settings->status_change_slack_notifications;
$this->restartLimitReachedSlackNotifications = $this->settings->restart_limit_reached_slack_notifications;
$this->backupSuccessSlackNotifications = $this->settings->backup_success_slack_notifications;
$this->backupFailureSlackNotifications = $this->settings->backup_failure_slack_notifications;
$this->scheduledTaskSuccessSlackNotifications = $this->settings->scheduled_task_success_slack_notifications;
@ -157,7 +153,6 @@ public function instantSaveSlackEnabled()
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@ -170,7 +165,6 @@ public function submit()
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@ -180,8 +174,6 @@ public function submit()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');

View file

@ -41,9 +41,6 @@ class Telegram extends Component
#[Validate(['boolean'])]
public bool $statusChangeTelegramNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedTelegramNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessTelegramNotifications = false;
@ -86,9 +83,6 @@ class Telegram extends Component
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsStatusChangeThreadId = null;
#[Validate(['nullable', 'string', 'max:255'])]
public ?string $telegramNotificationsRestartLimitReachedThreadId = null;
#[Validate(['nullable', 'string'])]
public ?string $telegramNotificationsBackupSuccessThreadId = null;
@ -134,10 +128,11 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->telegram_enabled = $this->telegramEnabled;
$this->settings->telegram_token = $this->telegramToken;
$this->settings->telegram_chat_id = $this->telegramChatId;
@ -145,7 +140,6 @@ private function syncData(bool $toModel = false): void
$this->settings->deployment_success_telegram_notifications = $this->deploymentSuccessTelegramNotifications;
$this->settings->deployment_failure_telegram_notifications = $this->deploymentFailureTelegramNotifications;
$this->settings->status_change_telegram_notifications = $this->statusChangeTelegramNotifications;
$this->settings->restart_limit_reached_telegram_notifications = $this->restartLimitReachedTelegramNotifications;
$this->settings->backup_success_telegram_notifications = $this->backupSuccessTelegramNotifications;
$this->settings->backup_failure_telegram_notifications = $this->backupFailureTelegramNotifications;
$this->settings->scheduled_task_success_telegram_notifications = $this->scheduledTaskSuccessTelegramNotifications;
@ -161,7 +155,6 @@ private function syncData(bool $toModel = false): void
$this->settings->telegram_notifications_deployment_success_thread_id = $this->telegramNotificationsDeploymentSuccessThreadId;
$this->settings->telegram_notifications_deployment_failure_thread_id = $this->telegramNotificationsDeploymentFailureThreadId;
$this->settings->telegram_notifications_status_change_thread_id = $this->telegramNotificationsStatusChangeThreadId;
$this->settings->telegram_notifications_restart_limit_reached_thread_id = $this->telegramNotificationsRestartLimitReachedThreadId;
$this->settings->telegram_notifications_backup_success_thread_id = $this->telegramNotificationsBackupSuccessThreadId;
$this->settings->telegram_notifications_backup_failure_thread_id = $this->telegramNotificationsBackupFailureThreadId;
$this->settings->telegram_notifications_scheduled_task_success_thread_id = $this->telegramNotificationsScheduledTaskSuccessThreadId;
@ -180,21 +173,6 @@ private function syncData(bool $toModel = false): void
if (auth()->user()->can('update', $this->settings)) {
$this->telegramToken = $this->settings->telegram_token;
$this->telegramChatId = $this->settings->telegram_chat_id;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
$this->telegramNotificationsRestartLimitReachedThreadId = $this->settings->telegram_notifications_restart_limit_reached_thread_id;
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
} else {
$this->telegramToken = null;
$this->telegramChatId = null;
@ -203,7 +181,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications;
$this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications;
$this->statusChangeTelegramNotifications = $this->settings->status_change_telegram_notifications;
$this->restartLimitReachedTelegramNotifications = $this->settings->restart_limit_reached_telegram_notifications;
$this->backupSuccessTelegramNotifications = $this->settings->backup_success_telegram_notifications;
$this->backupFailureTelegramNotifications = $this->settings->backup_failure_telegram_notifications;
$this->scheduledTaskSuccessTelegramNotifications = $this->settings->scheduled_task_success_telegram_notifications;
@ -216,13 +193,26 @@ private function syncData(bool $toModel = false): void
$this->serverPatchTelegramNotifications = $this->settings->server_patch_telegram_notifications;
$this->traefikOutdatedTelegramNotifications = $this->settings->traefik_outdated_telegram_notifications;
$this->telegramNotificationsDeploymentSuccessThreadId = $this->settings->telegram_notifications_deployment_success_thread_id;
$this->telegramNotificationsDeploymentFailureThreadId = $this->settings->telegram_notifications_deployment_failure_thread_id;
$this->telegramNotificationsStatusChangeThreadId = $this->settings->telegram_notifications_status_change_thread_id;
$this->telegramNotificationsBackupSuccessThreadId = $this->settings->telegram_notifications_backup_success_thread_id;
$this->telegramNotificationsBackupFailureThreadId = $this->settings->telegram_notifications_backup_failure_thread_id;
$this->telegramNotificationsScheduledTaskSuccessThreadId = $this->settings->telegram_notifications_scheduled_task_success_thread_id;
$this->telegramNotificationsScheduledTaskFailureThreadId = $this->settings->telegram_notifications_scheduled_task_failure_thread_id;
$this->telegramNotificationsDockerCleanupSuccessThreadId = $this->settings->telegram_notifications_docker_cleanup_success_thread_id;
$this->telegramNotificationsDockerCleanupFailureThreadId = $this->settings->telegram_notifications_docker_cleanup_failure_thread_id;
$this->telegramNotificationsServerDiskUsageThreadId = $this->settings->telegram_notifications_server_disk_usage_thread_id;
$this->telegramNotificationsServerReachableThreadId = $this->settings->telegram_notifications_server_reachable_thread_id;
$this->telegramNotificationsServerUnreachableThreadId = $this->settings->telegram_notifications_server_unreachable_thread_id;
$this->telegramNotificationsServerPatchThreadId = $this->settings->telegram_notifications_server_patch_thread_id;
$this->telegramNotificationsTraefikOutdatedThreadId = $this->settings->telegram_notifications_traefik_outdated_thread_id;
}
}
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@ -235,7 +225,6 @@ public function submit()
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@ -265,8 +254,6 @@ public function instantSaveTelegramEnabled()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();
$this->dispatch('success', 'Settings saved.');

View file

@ -34,9 +34,6 @@ class Webhook extends Component
#[Validate(['boolean'])]
public bool $statusChangeWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $restartLimitReachedWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $backupSuccessWebhookNotifications = false;
@ -82,17 +79,17 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->webhook_enabled = $this->webhookEnabled;
$this->settings->webhook_url = $this->webhookUrl;
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
$this->settings->restart_limit_reached_webhook_notifications = $this->restartLimitReachedWebhookNotifications;
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
@ -116,7 +113,6 @@ private function syncData(bool $toModel = false): void
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
$this->restartLimitReachedWebhookNotifications = $this->settings->restart_limit_reached_webhook_notifications;
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
@ -151,7 +147,6 @@ public function instantSaveWebhookEnabled()
public function instantSave()
{
try {
$this->authorize('update', $this->settings);
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
@ -162,7 +157,6 @@ public function submit()
{
try {
$this->resetErrorBag();
$this->authorize('update', $this->settings);
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
@ -172,8 +166,6 @@ public function submit()
public function saveModel()
{
$this->authorize('update', $this->settings);
$this->syncData(true);
refreshSession();

View file

@ -47,7 +47,7 @@ public function uploadAvatar(AvatarStorageService $avatarStorage): bool
$avatarStorage->store(Auth::user(), $this->avatar);
$this->reset('avatar');
$this->dispatch('avatar-updated', url: profile_avatar_url(Auth::user()->fresh()));
$this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp]));
$this->dispatch('success', 'Profile picture updated.');
return true;

View file

@ -27,6 +27,12 @@ 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;
@ -93,7 +99,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -101,6 +107,8 @@ private function syncData(bool $toModel = false): void
$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;
@ -128,6 +136,8 @@ private function syncData(bool $toModel = false): void
$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;

View file

@ -8,7 +8,6 @@
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -58,17 +57,7 @@ class Domains extends Component
public ?string $editingService = null;
public string $editingIndexing = 'index';
public string $editingRedirect = 'both';
public string $editingOriginalRedirect = 'both';
public bool $editingDomainWasRegenerated = false;
public ?string $editingGeneratedHost = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
public array $domainRows = [];
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */
@ -81,14 +70,6 @@ class Domains extends Component
public bool $showDomainConflictModal = false;
public bool $showPortWarningModal = false;
public bool $forceUseUnknownPort = false;
public ?int $unrecognizedPort = null;
public ?string $pendingPortAction = null;
public bool $forceSaveDomains = false;
public bool $forceSaveDns = false;
@ -132,8 +113,6 @@ protected function rules(): array
return [
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|required|in:index,noindex',
'editingRedirect' => 'string|required|in:both,www,non-www',
'redirect' => 'string|required|in:both,www,non-www',
'isForceHttpsEnabled' => 'boolean',
'serviceRedirects' => 'array',
@ -162,15 +141,7 @@ public function mount(): void
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
@ -247,9 +218,7 @@ public function loadDomainState(): void
$this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
if ($this->pendingAction !== 'redirect' || $this->isCompose) {
$this->redirect = $this->application->redirect ?? 'both';
}
$this->redirect = $this->application->redirect ?? 'both';
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings();
@ -276,9 +245,6 @@ public function loadDomainState(): void
}
$this->composeServices = [];
$pendingRedirect = $this->pendingRedirectService !== null
? ($this->serviceRedirects[$this->serviceRedirectWireKey($this->pendingRedirectService)] ?? null)
: null;
$this->serviceRedirects = [];
if ($this->isCompose) {
try {
@ -315,9 +281,7 @@ public function loadDomainState(): void
$serviceEntry = $domains[$serviceName] ?? null;
$storedRedirect = is_array($serviceEntry) ? ($serviceEntry['redirect'] ?? null) : null;
$this->serviceRedirects[$this->serviceRedirectWireKey($serviceName)] = $this->normalizeRedirect(
$this->pendingAction === 'redirect' && $serviceName === $this->pendingRedirectService
? $pendingRedirect
: (is_string($storedRedirect) ? $storedRedirect : null)
is_string($storedRedirect) ? $storedRedirect : null
);
}
}
@ -521,19 +485,32 @@ protected function suggestedDomainMeta(bool $suggestedIsWww, ?string $redirectOv
/**
* @param array<string, array{status?: string, message?: string, expected_ip?: ?string, checked_at?: ?string}> $stored
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool, internal_port: ?int, has_port_override: bool}
* @return array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at: ?string, is_suggested: bool, suggested_for: ?string, suggestion_label: ?string, needs_force_add: bool}
*/
protected function domainRowFromStored(string $url, ?string $service, array $stored): array
{
$key = $this->domainDnsStatusKey($url, $service);
$entry = $stored[$key] ?? null;
$port = $this->effectiveDomainInternalPort($url, $service);
$row = [
if (is_array($entry) && filled(data_get($entry, 'status'))) {
return [
'url' => $url,
'service' => $service,
'dns_status' => (string) data_get($entry, 'status', 'pending'),
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
'needs_force_add' => false,
];
}
return [
'url' => $url,
'service' => $service,
'internal_port' => $port['internal_port'],
'has_port_override' => $port['has_port_override'],
'dns_status' => 'pending',
'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp,
@ -544,119 +521,6 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
'suggestion_label' => null,
'needs_force_add' => false,
];
if (is_array($entry) && filled(data_get($entry, 'status'))) {
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
$row['checked_at'] = data_get($entry, 'checked_at');
$row['check_id'] = data_get($entry, 'check_id');
}
return $row;
}
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
protected function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->application->domain_port_overrides ?? [];
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
$hasMapEntry = array_key_exists($canonical, $overrides);
if ($hasMapEntry) {
return [
'internal_port' => (int) $overrides[$canonical],
'has_port_override' => true,
];
}
if ($legacyPort !== null) {
return [
'internal_port' => $legacyPort,
'has_port_override' => true,
];
}
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
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]
: null;
return [
'internal_port' => $defaultPort,
'has_port_override' => false,
];
}
/**
* @param array{scheme: string, host: string, port: string, path: string} $parts
*/
protected function portFromParts(array $parts): ?int
{
$port = trim((string) ($parts['port'] ?? ''));
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
return null;
}
return (int) $port;
}
protected function currentRowPort(string $url): ?int
{
$canonical = DomainPortOverrides::withoutPort($url);
$override = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($override) && (int) $override > 0) {
return (int) $override;
}
$legacy = DomainUrlParts::split($url)['port'] ?? '';
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
protected function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
}
if ($currentPort !== null && $port === $currentPort) {
return false;
}
return $this->application->portRequiresConfirmation($port, $serviceName);
}
protected function openPortWarning(?int $port, string $action): void
{
$this->unrecognizedPort = $port;
$this->pendingPortAction = $action;
$this->showPortWarningModal = true;
}
/**
@ -695,17 +559,45 @@ protected function authorizeUpdateForDomainConnect(): void
$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);
foreach ($this->domainRows as $row) {
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
$this->isCheckingDns = true;
try {
$server = $this->application->destination?->server;
$skipDns = ! $this->dnsValidationEnabled
|| ! $server
|| $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$reason = ! $this->dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: ($this->application->additional_servers->count() > 0
? 'DNS check skipped for multi-server applications.'
: 'No server available for DNS validation.');
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = $reason;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
continue;
}
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistDomainDnsStatuses();
} finally {
$this->isCheckingDns = false;
}
}
@ -717,8 +609,18 @@ public function checkDomainDns(int $index): void
return;
}
$row = $this->domainRows[$index];
$this->queueUrlsDns([$row['url']], $row['service'] ?? null);
$server = $this->application->destination?->server;
if (! $server || ! $this->dnsValidationEnabled || $this->application->additional_servers->count() > 0) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->persistDomainDnsStatuses();
return;
}
$this->applyDnsStatus($index, $server);
$this->persistDomainDnsStatuses();
}
protected function applyDnsStatus(int $index, Server $server): void
@ -922,31 +824,6 @@ public function confirmDomainUsage(): void
$this->addDomain();
}
public function confirmUseUnknownPort(): void
{
$this->authorize('update', $this->application);
$this->forceUseUnknownPort = true;
$this->showPortWarningModal = false;
$action = $this->pendingPortAction;
$this->pendingPortAction = null;
if ($action === 'update') {
$this->updateDomain();
return;
}
$this->addDomain();
}
public function cancelUseUnknownPort(): void
{
$this->showPortWarningModal = false;
$this->forceUseUnknownPort = false;
$this->unrecognizedPort = null;
$this->pendingPortAction = null;
}
/**
* Clear pending conflict state when the modal is dismissed without confirmation.
* confirmDomainUsage sets forceSaveDomains before closing the modal.
@ -957,13 +834,7 @@ public function updatedShowDomainConflictModal(bool $value): void
return;
}
$this->authorize('update', $this->application);
$wasRedirect = $this->pendingAction === 'redirect';
$this->pendingAction = null;
$this->pendingRedirectService = null;
if ($wasRedirect) {
$this->refreshDomains();
}
}
public function addDomain(): void
@ -977,7 +848,7 @@ public function addDomain(): void
return;
}
if ($this->newDomainPartsChanged || filled($this->newDomainParts['host'] ?? null)) {
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain');
@ -996,24 +867,15 @@ public function addDomain(): void
->values()
->all();
$current = $this->currentDomainList($this->newDomainService);
$currentCanonicalDomains = $current->map(
fn (string $url): string => DomainPortOverrides::withoutPort($url)
);
foreach ($newUrls as $url) {
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
if ($current->contains($url)) {
$this->addError('newDomain', "Domain {$url} is already configured.");
return;
}
}
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add';
if (! $this->saveDomainList($merged, $this->newDomainService)) {
@ -1022,7 +884,6 @@ public function addDomain(): void
$this->forceSaveDomains = false;
$this->pendingAction = null;
$this->forceUseUnknownPort = false;
$serviceForCheck = $this->newDomainService;
$this->resetAddDomainForm();
$this->dispatch('close-modal');
@ -1146,7 +1007,6 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
$skipDns = ! $this->dnsValidationEnabled
|| ! $server
|| $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
$url = $row['url'] ?? null;
@ -1183,34 +1043,6 @@ protected function checkUrlsDns(array $urls, ?string $service = null): void
$this->persistDomainDnsStatuses();
}
/**
* @param array<int, string> $urls
*/
protected function queueUrlsDns(array $urls, ?string $service = null): void
{
foreach ($this->dnsEntriesForUrls($urls, $service) as $statusKey => $url) {
$checkId = new_public_id();
$this->markUrlsAsChecking([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
try {
CheckDomainDnsJob::dispatch(
$this->application,
$statusKey,
$url,
$this->application->destination?->server,
$this->serverIp,
$checkId,
$this->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->markUrlsDnsCheckUnavailable([$url], $service, $checkId);
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
}
}
}
protected function shouldValidateDnsForAdd(): bool
{
if (! $this->dnsValidationEnabled) {
@ -1277,18 +1109,8 @@ public function startEdit(int $index): void
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$canonical = DomainPortOverrides::withoutPort($this->editingDomain);
$savedPort = ($this->application->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
}
$this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service'];
$this->editingIndexing = $this->application->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->editingRedirect = $this->serviceRedirectFor($this->editingService);
$this->editingOriginalRedirect = $this->editingRedirect;
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
@ -1374,11 +1196,6 @@ public function cancelEdit(): void
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingService = null;
$this->editingIndexing = 'index';
$this->editingRedirect = 'both';
$this->editingOriginalRedirect = 'both';
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
if ($this->pendingAction === 'update') {
@ -1394,39 +1211,6 @@ public function confirmUpdateDomainDespiteDns(): void
$this->updateDomain();
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->application);
if ($this->labelsAreWritable || $this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = data_get($this->application, 'destination.server');
if (! $server) {
$this->dispatch('error', 'No server found for this application.');
return;
}
$generatedHost = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($generatedHost) || $generatedHost === '') {
$this->dispatch('error', 'Could not generate a domain.');
return;
}
$currentHost = (string) ($this->editingDomainParts['host'] ?? '');
$this->editingGeneratedHost = $generatedHost;
$this->editingDomainParts['host'] = str_starts_with(strtolower($currentHost), 'www.')
? 'www.'.$generatedHost
: $generatedHost;
$this->editingDomainPartsChanged = true;
$this->editingDomainWasRegenerated = true;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
}
public function updateDomain(): void
{
try {
@ -1442,12 +1226,10 @@ public function updateDomain(): void
return;
}
if ($this->editingDomainPartsChanged || filled($this->editingDomainParts['host'] ?? null)) {
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$this->validateOnly('editingIndexing');
$this->validateOnly('editingRedirect');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
if (blank($normalized) || count($this->splitDomains($normalized)) !== 1) {
@ -1459,104 +1241,47 @@ public function updateDomain(): void
$newUrl = $this->splitDomains($normalized)[0];
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$service = $this->editingService;
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
$portOverrides = $this->application->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
$this->application->domain_port_overrides = $portOverrides ?: null;
}
$wasNoindexed = $this->application->isDomainNoindexed($oldUrl);
$current = $this->currentDomainList($service);
$otherCanonicalDomains = $current
->reject(fn (string $url): bool => $url === $oldUrl)
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
$this->addError('editingDomain', "Domain {$newUrl} is already configured.");
return;
}
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $service)) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
if (! $this->forceSaveEditDns && $this->shouldValidateDnsForAdd()) {
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = str_replace('add it anyway', 'save it anyway', $dnsFailure);
$this->showEditDomainModal = true;
return;
}
$replacements = [$oldUrl => $newUrl];
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
if (is_string($oldCounterpart)) {
$counterpartParts = DomainUrlParts::split($oldCounterpart);
$counterpartPort = $this->currentRowPort($oldCounterpart);
if ($counterpartPort !== null) {
$counterpartParts['port'] = (string) $counterpartPort;
}
$counterpartParts['host'] = str_starts_with(strtolower($counterpartParts['host']), 'www.')
? 'www.'.$this->editingGeneratedHost
: $this->editingGeneratedHost;
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$counterpartParts);
return;
}
}
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
foreach ($updated->all() as $url) {
$counterpart = $this->wwwCounterpartUrl($url, true);
$counterpartHost = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
$hasCounterpart = filled($counterpartHost) && $updated->contains(
fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $counterpartHost
);
if (filled($counterpart) && ! $hasCounterpart) {
$updated->push($counterpart);
}
}
}
$urlsToCheck = $updated
->reject(fn (string $url): bool => $current->contains(
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->unique()
->values()
->all();
$noindexDomains = $this->application->noindexDomains();
foreach ($replacements as $previousUrl => $replacementUrl) {
$wasNoindexed = $previousUrl === $oldUrl
? $this->editingIndexing === 'noindex'
: $this->application->isDomainNoindexed($previousUrl);
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
if ($wasNoindexed) {
$noindexDomains->push($replacementUrl);
}
}
if ($this->isCompose) {
$allDomains = json_decode($this->application->docker_compose_domains ?: '[]', true);
$existing = is_array($allDomains[$service] ?? null) ? $allDomains[$service] : [];
$allDomains[$service] = array_merge($existing, ['redirect' => $this->editingRedirect]);
$this->application->docker_compose_domains = json_encode($allDomains);
} else {
$this->application->redirect = $this->editingRedirect;
}
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainList($updated, $service, noindexDomains: $noindexDomains)) {
if (! $this->saveDomainList($updated, $service)) {
return;
}
$noindexDomains = $this->application->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($wasNoindexed) {
$noindexDomains->push($newUrl);
}
$this->application->setNoindexDomains($noindexDomains);
$this->application->save();
$this->resetDefaultLabels();
$this->forceSaveDomains = false;
$this->pendingAction = null;
$this->forceUseUnknownPort = false;
$this->cancelEdit();
$this->dispatch('edit-domain-saved');
$this->dispatch('success', 'Domain updated.');
$this->refreshDomains();
if ($urlsToCheck !== []) {
$this->queueUrlsDns($urlsToCheck, $service);
}
$this->checkUrlsDns([$newUrl], $service);
} catch (\Throwable $e) {
handleError($e, $this);
}
@ -1597,28 +1322,6 @@ public function removeDomain(int $index): void
}
}
public function removeDomainByKey(string $domainKey): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
&& hash_equals($domainKey, $this->domainRowKey($row))
);
if ($index === false) {
return;
}
$this->removeDomain((int) $index);
}
/**
* @param array{url: string, service?: ?string} $row
*/
private function domainRowKey(array $row): string
{
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
}
public function generateDomain(?string $serviceName = null): void
{
try {
@ -1722,7 +1425,7 @@ public function setRedirect(): void
$this->resetDefaultLabels();
$this->dispatch('success', 'Redirect updated.');
$this->refreshDomains();
$this->queueUrlsDns($addedDomains);
$this->checkUrlsDns($addedDomains);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@ -1819,7 +1522,7 @@ public function setServiceRedirect(string $serviceName, mixed ...$modalArgs): vo
$this->dispatch('success', "Redirect updated for {$serviceName}.");
}
$this->refreshDomains();
$this->queueUrlsDns($addedDomains, $serviceName);
$this->checkUrlsDns($addedDomains, $serviceName);
$this->pruneDomainDnsStatusesToCurrentDomains();
} catch (\Throwable $e) {
handleError($e, $this);
@ -2084,7 +1787,6 @@ protected function saveDomainList(
Collection $domains,
?string $serviceName = null,
bool $checkConflicts = true,
?Collection $noindexDomains = null,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@ -2098,8 +1800,6 @@ protected function saveDomainList(
}
}
$intendedComposeOverrides = null;
if ($this->isCompose) {
if (blank($serviceName)) {
$this->dispatch('error', 'A service is required for compose domains.');
@ -2115,15 +1815,6 @@ protected function saveDomainList(
$allDomains = [];
}
$previousServiceUrls = $this->currentDomainList($serviceName);
$normalizedPorts = DomainPortOverrides::normalize($domainString, $this->application->domain_port_overrides);
$domainString = $normalizedPorts['fqdn'];
$intendedComposeOverrides = $this->mergeComposeDomainPortOverrides(
$previousServiceUrls,
$domainString,
$normalizedPorts['overrides'] ?? null,
);
$existing = is_array($allDomains[$serviceName] ?? null) ? $allDomains[$serviceName] : [];
// Preserve stored redirect only — pending Direction dropdown values must not
// persist until setServiceRedirect() runs.
@ -2132,16 +1823,11 @@ protected function saveDomainList(
]);
$this->application->docker_compose_domains = json_encode($allDomains);
$this->application->domain_port_overrides = $intendedComposeOverrides;
$this->application->fqdn = null;
} else {
$this->application->fqdn = $domainString;
}
if ($noindexDomains !== null) {
$this->application->setNoindexDomains($noindexDomains);
}
if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
if ($result['hasConflicts']) {
@ -2163,47 +1849,12 @@ protected function saveDomainList(
}
$this->application->save();
if ($this->isCompose && ($this->application->domain_port_overrides ?? null) !== $intendedComposeOverrides) {
$this->application->domain_port_overrides = $intendedComposeOverrides;
$this->application->save();
}
$this->resetDefaultLabels();
$this->dispatch('configurationChanged');
return true;
}
/**
* @param Collection<int, string> $previousServiceUrls
* @param array<string, int>|null $incomingOverrides
* @return array<string, int>|null
*/
protected function mergeComposeDomainPortOverrides(
Collection $previousServiceUrls,
?string $newDomainString,
?array $incomingOverrides,
): ?array {
$merged = $this->application->domain_port_overrides ?? [];
$newCanonical = collect($this->splitDomains($newDomainString))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->all();
foreach ($previousServiceUrls as $url) {
$canonical = DomainPortOverrides::withoutPort($url);
if (! in_array($canonical, $newCanonical, true)) {
unset($merged[$canonical]);
}
}
foreach ($incomingOverrides ?? [] as $url => $port) {
$merged[$url] = (int) $port;
}
return $merged ?: null;
}
protected function resetDefaultLabels(): void
{
try {

View file

@ -4,7 +4,6 @@
use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
@ -145,9 +144,7 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => isset($this->application) && $this->fqdn === $this->application->fqdn
? ['nullable']
: ValidationPatterns::applicationDomainRules(),
'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
@ -323,6 +320,17 @@ public function mount()
}
}
$this->initialDockerComposeLocation = $this->application->docker_compose_location;
if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) {
// Only load compose file if user has update permission
try {
$this->authorize('update', $this->application);
$this->initLoadingCompose = true;
$this->dispatch('info', 'Loading docker compose file.');
} catch (AuthorizationException $e) {
// User doesn't have update permission, skip loading compose file
}
}
if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) {
$this->dispatch('configurationChanged');
}
@ -332,7 +340,7 @@ public function mount()
$this->syncData();
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
@ -522,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
} catch (\Throwable $e) {
// Refresh model to get restored values from Application::loadComposeFile
@ -599,9 +607,14 @@ public function updatedBuildPack()
$this->resetDefaultLabels(false);
}
if ($this->buildPack === 'dockercompose') {
if (blank($this->dockerComposeLocation)) {
$this->dockerComposeLocation = '/docker-compose.yaml';
$this->application->docker_compose_location = $this->dockerComposeLocation;
// Only update if user has permission
try {
$this->authorize('update', $this->application);
$this->fqdn = null;
$this->application->fqdn = null;
$this->application->settings->save();
} catch (AuthorizationException $e) {
// User doesn't have update permission, just continue without saving
}
}
if ($this->buildPack === 'static') {
@ -653,8 +666,6 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false)
{
$this->authorize('update', $this->application);
try {
if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return;
@ -759,11 +770,8 @@ public function submit($showToaster = true)
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
$oldBaseDirectory = $this->application->base_directory;
$fqdnChanged = $this->fqdn !== $this->application->fqdn;
if ($fqdnChanged) {
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
}
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -1,744 +0,0 @@
<?php
namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Models\ApplicationPreview;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class PreviewDomains extends Component
{
use AuthorizesRequests;
public ApplicationPreview $preview;
public array $domainRows = [];
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public ?string $newDomainService = null;
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public ?int $editingIndex = null;
public bool $showPortWarningModal = false;
public bool $forceUseUnknownPort = false;
public ?int $unrecognizedPort = null;
public ?string $pendingPortAction = null;
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;
}
}
public function render()
{
return view('livewire.project.application.preview-domains', [
'isCompose' => $this->preview->application->build_pack === 'dockercompose',
'composeServices' => $this->composeServices(),
]);
}
public function addDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->preview->application->build_pack === 'dockercompose'
&& ($this->newDomainService === null || ! in_array($this->newDomainService, $this->composeServices(), true))) {
$this->addError('newDomainService', 'Select a valid Compose service.');
return;
}
$domain = $this->validatedDomain($this->newDomainParts, 'newDomainParts.host');
if ($domain === null) {
return;
}
$canonicalDomain = DomainPortOverrides::withoutPort($domain);
if (collect($this->domainRows)->contains(
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === $canonicalDomain
&& $row['service'] === $this->newDomainService
)) {
$this->addError('newDomainParts.host', 'This domain is already configured.');
return;
}
if ($this->shouldConfirmPort($this->portFromParts($this->newDomainParts), serviceName: $this->newDomainService)) {
$this->openPortWarning($this->portFromParts($this->newDomainParts), 'add');
return;
}
$this->domainRows[] = $this->makeRow($domain, $this->newDomainService);
$index = array_key_last($this->domainRows);
$checkId = new_public_id();
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
if (! $this->persistDomains()) {
return;
}
$domain = $this->domainRows[$index]['url'] ?? DomainPortOverrides::withoutPort($domain);
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainService = $this->preview->application->build_pack === 'dockercompose'
? ($this->composeServices()[0] ?? null)
: null;
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-add', previewId: $this->preview->id);
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($domain, $this->domainRows[$index]['service']),
$domain,
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
$this->dispatch('success', 'Domain added. DNS check started.');
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
$this->dispatch('error', 'Domain added, but the DNS check could not be started. Try again from the preview domains list.');
}
}
public function generateDomain(): void
{
$this->authorize('update', $this->preview->application);
$this->preview->refresh();
if ($this->preview->application->build_pack === 'dockercompose') {
if ($this->newDomainService === null && $this->domainRows === []) {
$this->preview->generate_preview_fqdn_compose(generateWithoutApplicationDomain: true);
} else {
$service = $this->newDomainService ?? data_get($this->domainRows, '0.service');
foreach ($this->generateComposeDomains((string) $service) as $domain) {
$alreadyExists = collect($this->domainRows)->contains(
fn (array $row): bool => DomainPortOverrides::withoutPort($row['url']) === DomainPortOverrides::withoutPort($domain)
&& $row['service'] === $service
);
if (! $alreadyExists) {
$this->domainRows[] = $this->makeRow($domain, $service);
}
}
if (! $this->persistDomains()) {
return;
}
}
} else {
$this->preview->generate_preview_fqdn(generateWithoutApplicationDomain: true);
}
$this->refreshDomains();
$this->dispatch('success', 'Domain generated.');
}
public function startEdit(int $index): void
{
$this->authorize('update', $this->preview->application);
if (! isset($this->domainRows[$index])) {
return;
}
$this->editingIndex = $index;
$this->editingDomainParts = DomainUrlParts::split($this->domainRows[$index]['url']);
$canonical = DomainPortOverrides::withoutPort($this->domainRows[$index]['url']);
$savedPort = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($savedPort)) {
$this->editingDomainParts['port'] = (string) $savedPort;
}
$this->resetErrorBag('editingDomainParts.host');
$this->dispatch('open-preview-domain-edit', previewId: $this->preview->id);
}
public function updateDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$domain = $this->validatedDomain($this->editingDomainParts, 'editingDomainParts.host');
if ($domain === null) {
return;
}
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$dnsRelevantChange = DomainUrlParts::hasDnsRelevantChange($oldUrl, $domain);
if ($this->shouldConfirmPort($this->portFromParts($this->editingDomainParts), $this->currentRowPort($oldUrl), $this->domainRows[$this->editingIndex]['service'])) {
$this->openPortWarning($this->portFromParts($this->editingDomainParts), 'update');
return;
}
if (blank(DomainUrlParts::split($domain)['port'] ?? null)) {
$portOverrides = $this->preview->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
unset($portOverrides[DomainPortOverrides::withoutPort($domain)]);
$this->preview->domain_port_overrides = $portOverrides ?: null;
}
$this->domainRows[$this->editingIndex]['url'] = $domain;
$checkId = $dnsRelevantChange ? new_public_id() : null;
if ($dnsRelevantChange) {
$this->domainRows[$this->editingIndex]['dns_status'] = 'checking';
$this->domainRows[$this->editingIndex]['dns_message'] = 'Checking DNS...';
$this->domainRows[$this->editingIndex]['check_id'] = $checkId;
}
$index = $this->editingIndex;
$this->editingIndex = null;
if (! $this->persistDomains()) {
return;
}
$domain = $this->domainRows[$index]['url'];
$this->forceUseUnknownPort = false;
$this->dispatch('close-preview-domain-edit', previewId: $this->preview->id);
if (! $dnsRelevantChange) {
$this->dispatch('success', 'Domain updated.');
return;
}
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($domain, $this->domainRows[$index]['service']),
$domain,
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
$this->dispatch('success', 'Domain updated. DNS check started.');
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
$this->dispatch('error', 'Domain updated, but the DNS check could not be started. Try again from the preview domains list.');
}
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->preview->application);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex])) {
return;
}
$server = $this->preview->application->destination?->server;
if (! $server) {
$this->dispatch('error', 'No server found for this preview.');
return;
}
$host = parse_url(generateUrl(server: $server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return;
}
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
}
public function cancelEdit(): void
{
$this->editingIndex = null;
$this->editingDomainParts = DomainUrlParts::empty();
$this->resetErrorBag('editingDomainParts.host');
}
public function confirmUseUnknownPort(): void
{
$this->authorize('update', $this->preview->application);
$this->forceUseUnknownPort = true;
$this->showPortWarningModal = false;
$action = $this->pendingPortAction;
$this->pendingPortAction = null;
if ($action === 'update') {
$this->updateDomain();
return;
}
$this->addDomain();
}
public function cancelUseUnknownPort(): void
{
$this->showPortWarningModal = false;
$this->forceUseUnknownPort = false;
$this->unrecognizedPort = null;
$this->pendingPortAction = null;
}
public function removeDomain(int $index): void
{
$this->authorize('update', $this->preview->application);
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()) {
return;
}
$this->dispatch('success', 'Domain removed.');
}
public function removeDomainByKey(string $domainKey): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => hash_equals($domainKey, $this->statusKey($row['url'], $row['service']))
);
if ($index === false) {
return;
}
$this->removeDomain((int) $index);
}
public function checkAllDns(): void
{
$this->authorize('update', $this->preview->application);
foreach (array_keys($this->domainRows) as $index) {
$this->queueDnsCheck($index);
}
}
public function checkDomainDns(int $index): void
{
$this->authorize('update', $this->preview->application);
$this->queueDnsCheck($index);
}
private function queueDnsCheck(int $index): void
{
if (! isset($this->domainRows[$index])) {
return;
}
$row = $this->domainRows[$index];
$checkId = new_public_id();
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
$this->persistDnsStatuses();
try {
$server = $this->preview->application->destination?->server;
CheckDomainDnsJob::dispatch(
$this->preview,
$this->statusKey($row['url'], $row['service']),
$row['url'],
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$checkId,
$this->preview->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['check_id'] = null;
$this->persistDnsStatuses();
}
}
public function pollDnsChecks(): void
{
$this->authorize('view', $this->preview->application);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
private function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
private function applyDnsCheck(int $index): void
{
if (! isset($this->domainRows[$index])) {
return;
}
$result = $this->checkUrlDns($this->domainRows[$index]['url'], (string) $index);
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
}
private function checkUrlDns(string $url, string $key = 'domain'): array
{
$server = $this->preview->application->destination?->server;
return CheckDomainDns::run(
[$key => $url],
$server,
$server ? serverDnsTargetIp($server) ?? $server->ip : null,
$this->preview->application->additional_servers->count() > 0,
)[$key];
}
private function refreshDomains(): void
{
$editingRow = $this->editingIndex !== null ? ($this->domainRows[$this->editingIndex] ?? null) : null;
$this->preview->refresh();
$statuses = $this->preview->domain_dns_statuses ?? [];
$rows = [];
if ($this->preview->application->build_pack === 'dockercompose') {
foreach (json_decode($this->preview->docker_compose_domains ?: '[]', true) ?: [] as $service => $entry) {
foreach ($this->splitDomains(composeDomainEntryString($entry)) as $url) {
$rows[] = $this->makeRow($url, (string) $service, $statuses);
}
}
} else {
foreach ($this->splitDomains($this->preview->fqdn) as $url) {
$rows[] = $this->makeRow($url, null, $statuses);
}
}
$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
{
if ($this->preview->application->build_pack === 'dockercompose') {
try {
$composeServices = $this->composeServices(failOnError: true);
} catch (\Throwable) {
$this->refreshDomains();
$this->dispatch('error', 'Compose configuration could not be parsed. Preview domains were not changed.');
return false;
}
$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(',');
}
$this->preview->docker_compose_domains = json_encode($domains);
$this->preview->fqdn = $validRows->pluck('url')->implode(',') ?: null;
} else {
$this->preview->fqdn = collect($this->domainRows)->pluck('url')->implode(',') ?: null;
}
$normalized = DomainPortOverrides::normalize($this->preview->fqdn, $this->preview->domain_port_overrides);
$this->preview->fqdn = $normalized['fqdn'];
$this->preview->domain_port_overrides = $normalized['overrides'];
if ($this->preview->application->build_pack === 'dockercompose' && is_array($domains ?? null)) {
foreach ($domains as $service => $entry) {
$serviceDomains = $this->splitDomains(composeDomainEntryString($entry));
$domains[$service]['domain'] = collect($serviceDomains)
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->implode(',');
}
$this->preview->docker_compose_domains = json_encode($domains);
}
foreach ($this->domainRows as $index => $row) {
$this->domainRows[$index]['url'] = DomainPortOverrides::withoutPort($row['url']);
}
$this->preview->save();
$this->persistDnsStatuses();
$this->refreshDomains();
$this->dispatch('update_links');
$this->dispatch('previewDomainsChanged');
return true;
}
private function persistDnsStatuses(): void
{
$statuses = [];
foreach ($this->domainRows as $row) {
$statuses[$this->statusKey($row['url'], $row['service'])] = [
'status' => $row['dns_status'],
'message' => $row['dns_message'],
'check_id' => $row['check_id'] ?? null,
];
}
DB::transaction(function () use (&$statuses): void {
$preview = ApplicationPreview::query()->lockForUpdate()->findOrFail($this->preview->id);
$storedStatuses = $preview->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$storedStatus = $storedStatuses[$key] ?? null;
if (! is_array($storedStatus)) {
continue;
}
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatus['check_id'] ?? null;
if (($storedCheckId !== null && $localCheckId !== $storedCheckId)
|| ($status['status'] === 'checking' && ($storedStatus['status'] ?? null) !== 'checking')) {
$statuses[$key] = $storedStatus;
}
}
$preview->domain_dns_statuses = $statuses ?: null;
$preview->save();
});
$this->preview->domain_dns_statuses = $statuses ?: null;
}
private function validatedDomain(array $parts, string $errorKey): ?string
{
$domain = DomainUrlParts::compose(...$parts);
$validator = validator(['domain' => $domain], ['domain' => ValidationPatterns::applicationDomainRules()]);
if ($validator->fails()) {
$this->addError($errorKey, $validator->errors()->first('domain'));
return null;
}
return ValidationPatterns::normalizeApplicationDomains($domain);
}
private function makeRow(string $url, ?string $service, array $statuses = []): array
{
$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',
'dns_message' => $status['message'] ?? 'DNS has not been checked yet.',
'check_id' => $status['check_id'] ?? null,
];
}
/**
* @param array{scheme: string, host: string, port: string, path: string} $parts
*/
private function portFromParts(array $parts): ?int
{
$port = trim((string) ($parts['port'] ?? ''));
if ($port === '' || ! ctype_digit($port) || (int) $port <= 0) {
return null;
}
return (int) $port;
}
private function currentRowPort(string $url): ?int
{
$canonical = DomainPortOverrides::withoutPort($url);
$override = ($this->preview->domain_port_overrides ?? [])[$canonical] ?? null;
if (filled($override) && (int) $override > 0) {
return (int) $override;
}
$legacy = DomainUrlParts::split($url)['port'] ?? '';
return $legacy !== '' && ctype_digit($legacy) ? (int) $legacy : null;
}
private function shouldConfirmPort(?int $port, ?int $currentPort = null, ?string $serviceName = null): bool
{
if ($this->forceUseUnknownPort || $port === null) {
return false;
}
if ($currentPort !== null && $port === $currentPort) {
return false;
}
return $this->preview->application->portRequiresConfirmation($port, $serviceName);
}
private function openPortWarning(?int $port, string $action): void
{
$this->unrecognizedPort = $port;
$this->pendingPortAction = $action;
$this->showPortWarningModal = true;
}
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
private function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->preview->domain_port_overrides ?? [];
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
$hasMapEntry = array_key_exists($canonical, $overrides);
if ($hasMapEntry) {
return [
'internal_port' => (int) $overrides[$canonical],
'has_port_override' => true,
];
}
if ($legacyPort !== null) {
return [
'internal_port' => $legacyPort,
'has_port_override' => true,
];
}
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
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]
: null;
return [
'internal_port' => $defaultPort,
'has_port_override' => false,
];
}
private function statusKey(string $url, ?string $service): string
{
return hash('sha256', $url.'|'.($service ?? ''));
}
private function splitDomains(?string $domains): array
{
return str($domains)->explode(',')->map(fn ($domain) => trim((string) $domain))->filter()->values()->all();
}
private function generateComposeDomains(string $service): array
{
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domainString = getComposeServiceDomainString($applicationDomains, $service);
if (empty($domainString)) {
$domainString = generateUrl(
server: $this->preview->application->destination->server,
random: str($service)->slug().'-'.$this->preview->application->uuid,
);
}
return collect($this->splitDomains($domainString))->map(function (string $domain): string {
$generated = $this->preview->generatedPreviewDomain($domain);
if (filled($generated['port'])) {
$overrides = $this->preview->domain_port_overrides ?? [];
$overrides[$generated['url']] = $generated['port'];
$this->preview->domain_port_overrides = $overrides;
}
return $generated['url'];
})->all();
}
private function composeServices(bool $failOnError = false): array
{
try {
$parsedCompose = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$services = data_get($parsedCompose, 'services', []);
if (! is_iterable($services)) {
return [];
}
$previewSuffix = '-pr-'.$this->preview->pull_request_id;
$serviceNames = [];
foreach ($services as $serviceName => $service) {
if (isDatabaseImage(data_get($service, 'image'))) {
continue;
}
$serviceName = (string) $serviceName;
if (str_ends_with($serviceName, $previewSuffix)) {
$serviceName = substr($serviceName, 0, -strlen($previewSuffix));
}
$serviceNames[] = $serviceName;
}
return array_values(array_unique($serviceNames));
} catch (\Throwable $exception) {
if ($failOnError) {
throw $exception;
}
return [];
}
}
}

View file

@ -7,6 +7,7 @@
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
@ -15,14 +16,8 @@ class Previews extends Component
{
use AuthorizesRequests;
protected $listeners = ['previewDomainsChanged' => 'refreshPreviewDomains'];
public Application $application;
public bool $isPreviewDeploymentsEnabled = false;
public bool $isPrDeploymentsPublicEnabled = false;
public string $deployment_uuid;
public array $parameters;
@ -31,6 +26,16 @@ class Previews extends Component
public int $rate_limit_remaining;
public $domainConflicts = [];
public $showDomainConflictModal = false;
public $forceSaveDomains = false;
public $pendingPreviewId = null;
public array $previewFqdns = [];
public array $previewDockerTags = [];
public ?int $manualPullRequestId = null;
@ -38,6 +43,7 @@ class Previews extends Component
public ?string $manualDockerTag = null;
protected $rules = [
'previewFqdns.*' => 'string|nullable',
'previewDockerTags.*' => 'string|nullable',
'manualPullRequestId' => 'integer|min:1|nullable',
'manualDockerTag' => 'string|nullable',
@ -45,51 +51,33 @@ 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();
$this->syncData(false);
}
public function savePreviewSettings(): void
private function syncData(bool $toModel = false): 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 = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
if ($toModel) {
foreach ($this->previewFqdns as $key => $fqdn) {
$preview = $this->application->previews->get($key);
if ($preview) {
$preview->fqdn = $fqdn;
if ($this->application->build_pack === 'dockerimage') {
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
}
}
}
} else {
$this->previewFqdns = [];
$this->previewDockerTags = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewFqdns[$key] = $preview->fqdn;
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
}
}
}
public function refreshPreviewDomains(): void
{
$this->application->refresh();
$this->syncDockerTags();
}
public function load_prs()
{
try {
@ -104,28 +92,103 @@ public function load_prs()
}
}
public function confirmDomainUsage()
{
$this->forceSaveDomains = true;
$this->showDomainConflictModal = false;
if ($this->pendingPreviewId) {
$this->save_preview($this->pendingPreviewId);
$this->pendingPreviewId = null;
}
}
public function save_preview($preview_id)
{
try {
$this->authorize('update', $this->application);
$success = true;
$preview = $this->application->previews->find($preview_id);
if (! $preview) {
throw new \Exception('Preview not found');
}
// Find the key for this preview in the collection
$previewKey = $this->application->previews->search(function ($item) use ($preview_id) {
return $item->id == $preview_id;
});
if ($previewKey === false) {
throw new \Exception('Preview not found');
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', 'Validating DNS failed.', "{$guidance}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$success = false;
}
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application, domain: $fqdn);
if ($result['hasConflicts']) {
$this->domainConflicts = $result['conflicts'];
$this->showDomainConflictModal = true;
$this->pendingPreviewId = $preview_id;
return;
}
} else {
// Reset the force flag after using it
$this->forceSaveDomains = false;
}
}
}
$this->validateOnly("previewDockerTags.{$previewKey}");
$preview->docker_registry_image_tag = $this->previewDockerTags[$previewKey] ?? null;
$preview->save();
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
if ($success) {
$this->syncData(true);
$preview->save();
$this->dispatch('success', 'Preview saved.<br><br>Do not forget to redeploy the preview to apply the changes.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function generate_preview($preview_id)
{
try {
$this->authorize('update', $this->application);
$preview = $this->application->previews->find($preview_id);
if (! $preview) {
$this->dispatch('error', 'Preview not found.');
return;
}
if ($this->application->build_pack === 'dockercompose') {
$preview->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('success', 'Domain generated.');
return;
}
$preview->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -148,7 +211,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
}
$found->generate_preview_fqdn_compose();
$this->application->refresh();
$this->syncDockerTags();
$this->syncData(false);
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
@ -164,9 +227,9 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null,
$found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save();
}
$found->generate_preview_fqdn(generateWithoutApplicationDomain: true);
$found->generate_preview_fqdn();
$this->application->refresh();
$this->syncDockerTags();
$this->syncData(false);
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
}

View file

@ -0,0 +1,165 @@
<?php
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PreviewsCompose extends Component
{
use AuthorizesRequests;
public $service;
public $serviceName;
public ApplicationPreview $preview;
public ?string $domain = null;
public function mount()
{
$this->domain = data_get($this->service, 'domain');
}
public function render()
{
return view('livewire.project.application.previews-compose');
}
public function save()
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function generate()
{
try {
$this->authorize('update', $this->preview->application);
$applicationDomains = json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: [];
$domain_string = getComposeServiceDomainString($applicationDomains, (string) $this->serviceName);
// If no domain is set in the main application, generate a default domain
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
// Generate a unique domain like main app services do
$generated_fqdn = generateUrl(server: $server, random: $random);
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
foreach ($domain_list as $single_domain) {
$single_domain = trim($single_domain);
if (empty($single_domain)) {
continue;
}
$url = Url::fromString($single_domain);
$host = $url->getHost();
$schema = $url->getScheme();
$portInt = $url->getPort();
$port = $portInt !== null ? ':'.$portInt : '';
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
}
$preview_fqdn = implode(',', $preview_fqdns);
}
$this->domain = $preview_fqdn;
$this->persistPreviewDomain($this->domain);
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function persistPreviewDomain(?string $domain): void
{
$docker_compose_domains = json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: [];
$serviceNames = $this->previewServiceNames($docker_compose_domains);
$storageKey = findComposeServiceName((string) $this->serviceName, $serviceNames)
?? (string) $this->serviceName;
$docker_compose_domains = putComposeServiceDomain(
$docker_compose_domains,
$storageKey,
$domain,
$serviceNames,
);
$docker_compose_domains = rekeyComposeDomainsToServiceNames($docker_compose_domains, $serviceNames);
$this->serviceName = $storageKey;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
}
/**
* @param array<string, mixed> $previewDomains
* @return list<string>
*/
private function previewServiceNames(array $previewDomains): array
{
$parsedServices = $this->preview->application->parse(pull_request_id: $this->preview->pull_request_id);
$fromCompose = collect(data_get($parsedServices, 'services', []))
->keys()
->map(function ($serviceName) {
return str((string) $serviceName)
->replaceLast('-pr-'.$this->preview->pull_request_id, '')
->toString();
})
->all();
$domainKeys = collect(array_keys($previewDomains))
->merge(array_keys(json_decode($this->preview->application->docker_compose_domains ?: '[]', true) ?: []))
->map(fn ($name) => (string) $name);
$unmapped = $domainKeys
->reject(fn (string $key) => findComposeServiceName($key, $fromCompose) !== null)
->all();
return collect($fromCompose)
->merge(preferredComposeServiceNamesFromDomainKeys(
$fromCompose === [] ? $domainKeys->all() : $unmapped
))
->unique()
->values()
->all();
}
}

View file

@ -65,7 +65,7 @@ public function updatedGitCommitSha()
$this->gitCommitSha = trim($this->gitCommitSha);
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();

View file

@ -31,7 +31,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();

View file

@ -85,9 +85,6 @@ 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.
@ -131,7 +128,7 @@ public function refreshStatus(): void
$this->status = $database->status;
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->backup->enabled = $this->backupEnabled;
@ -155,7 +152,6 @@ private function syncData(bool $toModel = false): void
$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 {
@ -174,7 +170,6 @@ private function syncData(bool $toModel = false): void
$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;
}
}
@ -217,14 +212,14 @@ public function delete($password, $selectedActions = [])
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
return redirectRoute($this, 'project.service.database.backups', [
return redirect()->route('project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $serviceDatabase->service->uuid,
'stack_service_uuid' => $serviceDatabase->uuid,
]);
} else {
return redirectRoute($this, 'project.database.backup.index', [
return redirect()->route('project.database.backup.index', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'database_uuid' => $this->parameters['database_uuid'],
@ -242,14 +237,6 @@ public function backupNow()
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.');

View file

@ -6,6 +6,7 @@
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BackupExecutions extends Component
@ -36,12 +37,12 @@ class BackupExecutions extends Component
public $delete_backup_sftp = false;
public function getListeners(): array
public function getListeners()
{
$teamId = currentTeam()->id;
$userId = Auth::id();
return [
"echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions',
"echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions',
];
}

View file

@ -17,13 +17,6 @@ public function backupNow()
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) {

View file

@ -121,7 +121,7 @@ protected function messages(): array
);
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -199,7 +199,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -85,10 +85,11 @@ public function submit()
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($database->getMorphClass() === ServiceDatabase::class) {
$service = $database->service;
$this->redirectRoute('project.service.volume-backups.index', [
$this->redirectRoute('project.service.database.backup.show', [
'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 {

View file

@ -115,7 +115,7 @@ protected function messages(): array
);
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -191,7 +191,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -35,12 +35,6 @@ public function getListeners()
public function activityFinished()
{
if (auth()->user()->cannot('update', $this->database)) {
$this->dispatch('refresh');
return;
}
try {
// Only set started_at if database is actually running
if ($this->database->isRunning()) {

View file

@ -34,7 +34,7 @@ public function mount(): void
$this->syncData();
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();

View file

@ -582,7 +582,7 @@ public function checkS3File()
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
@ -664,7 +664,7 @@ public function restoreFromS3(string $password = ''): bool|string
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}

View file

@ -22,9 +22,6 @@ class InitScript extends Component
#[Locked]
public int $index;
#[Locked]
public string $originalFilename;
#[Validate(['nullable', 'string'])]
public ?string $filename = null;
@ -36,7 +33,6 @@ public function mount()
try {
$this->index = data_get($this->script, 'index');
$this->filename = data_get($this->script, 'filename');
$this->originalFilename = (string) data_get($this->script, 'filename');
$this->content = data_get($this->script, 'content');
} catch (Exception $e) {
return handleError($e, $this);
@ -51,7 +47,7 @@ public function submit()
$this->script['index'] = $this->index;
$this->script['content'] = $this->content;
$this->script['filename'] = $this->filename;
$this->dispatch('save_init_script', $this->script, $this->originalFilename);
$this->dispatch('save_init_script', $this->script);
} catch (Exception $e) {
return handleError($e, $this);
}

View file

@ -118,7 +118,7 @@ protected function messages(): array
);
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -196,7 +196,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -136,7 +136,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -244,7 +244,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -128,7 +128,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -237,7 +237,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -136,7 +136,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -244,7 +244,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -149,7 +149,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -240,7 +240,6 @@ public function instantSave(?bool $isPublic = null)
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@ -248,16 +247,16 @@ public function instantSave(?bool $isPublic = null)
}
}
public function save_init_script($script, string $originalFilename)
public function save_init_script($script)
{
$this->authorize('update', $this->database);
$initScripts = collect($this->initScripts ?? []);
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
$oldScript = $initScripts->firstWhere('filename', $originalFilename);
$oldScript = $initScripts->firstWhere('index', $script['index']);
if ($existingScript && $script['filename'] !== $originalFilename) {
if ($existingScript && $existingScript['index'] !== $script['index']) {
$this->dispatch('error', 'A script with this filename already exists.');
return;
@ -286,10 +285,11 @@ public function save_init_script($script, string $originalFilename)
}
}
$index = $initScripts->search(fn ($item) => $item['filename'] === $originalFilename);
$index = $initScripts->search(function ($item) use ($script) {
return $item['index'] === $script['index'];
});
if ($index !== false) {
$script['index'] = $oldScript['index'];
$initScripts[$index] = $script;
} else {
$initScripts->push($script);

View file

@ -127,7 +127,7 @@ public function mount()
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
@ -235,7 +235,6 @@ public function instantSave()
}
$this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->authorize('update', $this->database);
$this->isPublic = ! $this->isPublic;
$this->syncData(true);

View file

@ -77,7 +77,7 @@ public function mount(string $project_uuid)
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();

View file

@ -48,7 +48,7 @@ public function mount(string $project_uuid, string $environment_uuid)
}
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();

View file

@ -53,7 +53,10 @@ public function render(): View
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'iconUrl' => $project->icon_path ? project_icon_url($project) : null,
'iconUrl' => $project->icon_path ? route('project.icon', [
'project_uuid' => $project->uuid,
'v' => $project->updated_at->timestamp,
]) : null,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,

View file

@ -216,14 +216,6 @@ private function get_git_source()
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
}
if (($scp = parseScpStyleGitUrl($this->repository_url)) !== null) {
$this->git_host = $scp['host'];
$this->git_repository = $this->repository_url;
$this->git_source = 'other';
return;
}
$this->repository_url_parsed = Url::fromString($this->repository_url);
$this->git_host = $this->repository_url_parsed->getHost();
$this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2);

View file

@ -137,9 +137,10 @@ public function loadBranch()
throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url'));
}
$httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url);
if (is_string($httpsRepositoryUrl)) {
$this->repository_url = $httpsRepositoryUrl;
if (str($this->repository_url)->startsWith('git@')) {
$github_instance = str($this->repository_url)->after('git@')->before(':');
$repository = str($this->repository_url)->after(':')->before('.git');
$this->repository_url = 'https://'.str($github_instance).'/'.$repository;
}
if (
(str($this->repository_url)->startsWith('https://') ||

View file

@ -111,14 +111,38 @@ public function loadServices()
$templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services);
$services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) {
$default_logo = 'svgs/default.webp';
$logo = data_get($service, 'logo');
if (is_string($logo) && str_starts_with($logo, 'svg/')) {
$normalizedLogo = 'svgs/'.str($logo)->after('svg/');
if (file_exists(public_path($normalizedLogo))) {
$logo = $normalizedLogo;
}
}
$hasLogo = is_string($logo)
&& basename($logo) !== basename($default_logo)
&& file_exists(public_path($logo));
if (! $hasLogo) {
$logo = $default_logo;
}
$local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [
'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'has_logo' => $hasLogo,
'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + service_logo_urls(data_get($service, 'logo')) + (array) $service;
] + (array) $service;
})->all();
// Extract unique categories from services

View file

@ -187,11 +187,6 @@ private function toSearchableArray(Collection $items, string $type, string $type
'fqdn' => $item->fqdn ?? null,
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'restartLimitReached' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit(),
'restartCount' => method_exists($item, 'stoppedAfterRestartLimit') && $item->stoppedAfterRestartLimit()
? max($item->restart_count ?? 0, $item->max_restart_count ?? 0)
: ($item->restart_count ?? 0),
'maxRestartCount' => $item->max_restart_count ?? 0,
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [

View file

@ -1,156 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledVolumeBackup;
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;
public function getListeners(): array
{
$teamId = currentTeam()->id;
return [
'modalClosed' => 'closeExecutionModal',
"echo-private:team.{$teamId},BackupCreated" => '$refresh',
];
}
public function mount(Service $service): void
{
abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404);
$this->service = $service;
$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->authorize('view', $this->service);
$execution = $this->executionQuery($executionUuid)->first();
abort_unless($execution, 404);
$this->selectedExecution = $this->formatExecutions(collect([$execution]))->first();
$this->executionModalOpen = true;
}
public function closeExecutionModal(): void
{
$this->executionModalOpen = false;
$this->selectedExecution = null;
}
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' => $executions,
]);
}
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))
->select('id');
$volumeScheduleIds = ScheduledVolumeBackup::query()
->forService($this->service)
->select('id');
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->select('id', 'uuid', 'created_at')
->selectRaw("'database' as type")
->whereIn('scheduled_database_backup_id', $databaseScheduleIds)
->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' => $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($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id)
: null,
];
});
}
}

View file

@ -22,6 +22,8 @@ class DatabaseBackups extends Component
public array $query;
public bool $isImportSupported = false;
public ?ScheduledDatabaseBackup $backup = null;
public string $section = 'index';
@ -30,7 +32,7 @@ class DatabaseBackups extends Component
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount(): mixed
public function mount()
{
try {
$this->parameters = array_filter(
@ -65,13 +67,10 @@ public function mount(): mixed
return redirect()->route('project.service.index', $this->parameters);
}
if (! request()->route('backup_uuid')) {
return redirect()->route('project.service.volume-backups.index', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
if (request()->route('backup_uuid')) {
$this->backup = $this->serviceDatabase->scheduledBackups()
@ -86,14 +85,6 @@ public function mount(): mixed
'project.service.database.backup.danger' => 'danger',
default => 'general',
};
$routeParameters = [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
];
return redirect()->route('project.service.volume-backups.index', $routeParameters);
}
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -9,7 +9,6 @@
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Support\DomainPortOverrides;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -63,16 +62,6 @@ class Domains extends Component
public ?int $editingServiceApplicationId = null;
public string $editingIndexing = 'index';
public string $editingRedirect = 'both';
public string $editingOriginalRedirect = 'both';
public bool $editingDomainWasRegenerated = false;
public ?string $editingGeneratedHost = null;
public bool $showEditDomainModal = false;
public bool $forceSaveDomains = false;
@ -123,8 +112,6 @@ protected function rules(): array
return [
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'editingIndexing' => 'string|required|in:index,noindex',
'editingRedirect' => 'string|required|in:both,www,non-www',
'newServiceApplicationId' => 'nullable|integer',
'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www',
@ -141,17 +128,9 @@ public function mount(): void
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
@ -259,14 +238,9 @@ public function loadDomainState(): void
])
->all();
$pendingRedirect = $this->serviceRedirects[$this->pendingRedirectServiceApplicationId] ?? null;
$this->serviceRedirects = [];
foreach ($this->service->applications as $app) {
$this->serviceRedirects[$app->id] = $this->normalizeRedirect(
$this->pendingAction === 'redirect' && $app->id === $this->pendingRedirectServiceApplicationId
? $pendingRedirect
: $app->redirect
);
$this->serviceRedirects[$app->id] = $this->normalizeRedirect($app->redirect ?? null);
}
if ($this->newServiceApplicationId === null && count($this->serviceApps) > 0) {
@ -332,15 +306,30 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
{
$entry = $stored[$url] ?? null;
$displayName = $app->human_name ?: $app->name;
$port = $this->effectiveDomainInternalPort($url, $app);
$row = [
if (is_array($entry) && filled(data_get($entry, 'status'))) {
return [
'service_application_id' => $app->id,
'service_name' => $displayName,
'service_image' => $app->image,
'url' => $url,
'dns_status' => (string) data_get($entry, 'status', 'pending'),
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false,
'suggested_for' => null,
'suggestion_label' => null,
'needs_force_add' => false,
];
}
return [
'service_application_id' => $app->id,
'service_name' => $displayName,
'service_image' => $app->image,
'url' => $url,
'internal_port' => $port['internal_port'],
'has_port_override' => $port['has_port_override'],
'dns_status' => 'pending',
'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp,
@ -351,48 +340,6 @@ protected function domainRowFromStored(string $url, ServiceApplication $app, arr
'suggestion_label' => null,
'needs_force_add' => false,
];
if (is_array($entry) && filled(data_get($entry, 'status'))) {
$row['dns_status'] = (string) data_get($entry, 'status', 'pending');
$row['dns_message'] = (string) data_get($entry, 'message', 'Not checked yet.');
$row['expected_ip'] = data_get($entry, 'expected_ip') ?: $this->serverIp;
$row['checked_at'] = data_get($entry, 'checked_at');
$row['check_id'] = data_get($entry, 'check_id');
}
return $row;
}
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
protected function effectiveDomainInternalPort(string $url, ServiceApplication $app): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $app->domain_port_overrides ?? [];
$legacyPortPart = DomainUrlParts::split($url)['port'] ?? '';
$legacyPort = $legacyPortPart !== '' ? (int) $legacyPortPart : null;
if (array_key_exists($canonical, $overrides)) {
return [
'internal_port' => (int) $overrides[$canonical],
'has_port_override' => true,
];
}
if ($legacyPort !== null && $legacyPort > 0) {
return [
'internal_port' => $legacyPort,
'has_port_override' => true,
];
}
$requiredPort = $app->getRequiredPort();
return [
'internal_port' => ($requiredPort !== null && $requiredPort > 0) ? $requiredPort : null,
'has_port_override' => false,
];
}
/**
@ -484,20 +431,39 @@ protected function authorizeUpdateForDomainConnect(): void
$this->authorize('update', $this->service);
}
protected function usesInstanceNetworkAddressesForDnsHints(): bool
{
return $this->service->server?->id === 0;
}
public function checkAllDns(): void
{
$this->authorize('update', $this->service);
foreach ($this->domainRows as $row) {
$application = $this->findServiceApp((int) $row['service_application_id']);
if ($application) {
$this->queueUrlsDns([$row['url']], $application);
$this->isCheckingDns = true;
try {
$server = $this->service->server;
$skipDns = ! $this->dnsValidationEnabled || ! $server;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if ($skipDns) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = ! $this->dnsValidationEnabled
? 'DNS validation is disabled in instance settings.'
: 'No server available for DNS validation.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
continue;
}
$indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
}
$this->persistAllDomainDnsStatuses();
} finally {
$this->isCheckingDns = false;
}
}
@ -509,11 +475,19 @@ public function checkDomainDns(int $index): void
return;
}
$row = $this->domainRows[$index];
$application = $this->findServiceApp((int) $row['service_application_id']);
if ($application) {
$this->queueUrlsDns([$row['url']], $application);
$server = $this->service->server;
if (! $server || ! $this->dnsValidationEnabled) {
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check skipped.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index);
$this->persistAllDomainDnsStatuses();
return;
}
$this->applyDnsStatus($index, $server);
$this->persistAllDomainDnsStatuses();
}
protected function applyDnsStatus(int $index, Server $server): void
@ -817,7 +791,7 @@ public function setServiceRedirect(int $serviceApplicationId, mixed ...$modalArg
$this->dispatch('configurationChanged');
$this->pruneDomainDnsStatusesToCurrentDomains();
$this->refreshDomains();
$this->queueUrlsDns($addedDomains, $app);
$this->checkUrlsDns($addedDomains, $serviceApplicationId);
} catch (\Throwable $e) {
handleError($e, $this);
}
@ -917,7 +891,6 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
}
$toAdd = collect();
$portOverrides = $app->domain_port_overrides ?? [];
foreach ($current as $url) {
$counterpart = $this->wwwCounterpartUrl($url, forRedirectPairing: true);
if ($counterpart === null) {
@ -934,11 +907,6 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
continue;
}
$port = $this->effectiveDomainInternalPort($url, $app);
if ($port['has_port_override']) {
$portOverrides[DomainPortOverrides::withoutPort($counterpart)] = $port['internal_port'];
}
$knownHosts[$hostKey] = true;
$toAdd->push($counterpart);
}
@ -947,13 +915,12 @@ protected function ensureWwwNonWwwPairsConfigured(ServiceApplication $app): bool
return true;
}
$app->domain_port_overrides = $portOverrides ?: null;
$merged = $current->merge($toAdd)->unique()->values();
$this->pendingAction = 'redirect';
$this->pendingRedirectServiceApplicationId = $app->id;
// Counterparts inherit an existing port, so only domain conflicts need confirmation.
if (! $this->saveDomainListForApp($app, $merged, checkPorts: false)) {
// Skip DNS: pairing for redirects must still be configured even when DNS is not ready.
if (! $this->saveDomainListForApp($app, $merged)) {
return false;
}
@ -980,24 +947,11 @@ public function confirmRemovePort(): void
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;
@ -1036,11 +990,8 @@ public function addDomain(): void
->all()
: [];
$current = collect($this->splitDomains($app->fqdn));
$currentCanonicalDomains = $current->map(
fn (string $url): string => DomainPortOverrides::withoutPort($url)
);
foreach ($newUrls as $url) {
if ($currentCanonicalDomains->contains(DomainPortOverrides::withoutPort($url))) {
if ($current->contains($url)) {
$this->addError('newDomain', "Domain {$url} is already configured for this service.");
return;
@ -1160,18 +1111,8 @@ public function startEdit(int $index): void
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$internalPort = $this->domainRows[$index]['internal_port'] ?? null;
if (filled($internalPort)) {
$this->editingDomainParts['port'] = (string) $internalPort;
}
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
$app = $this->findServiceApp($this->editingServiceApplicationId);
$this->editingIndexing = $app?->isDomainNoindexed($this->editingDomain) ? 'noindex' : 'index';
$this->editingRedirect = $this->serviceRedirectFor($this->editingServiceApplicationId);
$this->editingOriginalRedirect = $this->editingRedirect;
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
@ -1188,11 +1129,6 @@ public function cancelEdit(): void
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = null;
$this->editingIndexing = 'index';
$this->editingRedirect = 'both';
$this->editingOriginalRedirect = 'both';
$this->editingDomainWasRegenerated = false;
$this->editingGeneratedHost = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
@ -1208,20 +1144,10 @@ public function updateDomain(): void
return;
}
$editingDomainParts = $this->editingDomainParts;
$editingRow = $this->domainRows[$this->editingIndex];
if (
! ($editingRow['has_port_override'] ?? false)
&& (string) ($editingDomainParts['port'] ?? '') === (string) ($editingRow['internal_port'] ?? '')
) {
$editingDomainParts['port'] = '';
}
if ($this->editingDomainPartsChanged || filled($editingDomainParts['host'] ?? null)) {
$this->editingDomain = DomainUrlParts::compose(...$editingDomainParts);
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$this->validateOnly('editingIndexing');
$this->validateOnly('editingRedirect');
$app = $this->findServiceApp($this->editingServiceApplicationId);
if (! $app) {
@ -1238,66 +1164,39 @@ public function updateDomain(): void
$newUrl = $this->splitDomains($normalized)[0];
$oldUrl = $this->domainRows[$this->editingIndex]['url'];
$current = collect($this->splitDomains($app->fqdn));
if (blank(DomainUrlParts::split($newUrl)['port'] ?? null)) {
$portOverrides = $app->domain_port_overrides ?? [];
unset($portOverrides[DomainPortOverrides::withoutPort($oldUrl)]);
unset($portOverrides[DomainPortOverrides::withoutPort($newUrl)]);
$app->domain_port_overrides = $portOverrides ?: null;
}
$wasNoindexed = $app->isDomainNoindexed($oldUrl);
$otherCanonicalDomains = $current
->reject(fn (string $url): bool => $url === $oldUrl)
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url));
if ($otherCanonicalDomains->contains(DomainPortOverrides::withoutPort($newUrl))) {
if ($newUrl !== $oldUrl && $current->contains($newUrl)) {
$this->addError('editingDomain', "Domain {$newUrl} is already configured for this service.");
return;
}
$replacements = [$oldUrl => $newUrl];
if ($this->editingDomainWasRegenerated && filled($this->editingGeneratedHost) && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
$oldCounterpartHost = parse_url((string) $this->wwwCounterpartUrl($oldUrl, true), PHP_URL_HOST);
$oldCounterpart = $current->first(fn (string $url): bool => parse_url($url, PHP_URL_HOST) === $oldCounterpartHost);
if (is_string($oldCounterpart)) {
$parts = DomainUrlParts::split($oldCounterpart);
$port = ($app->domain_port_overrides ?? [])[DomainPortOverrides::withoutPort($oldCounterpart)] ?? null;
$parts['port'] = filled($port) ? (string) $port : $parts['port'];
$parts['host'] = str_starts_with(strtolower($parts['host']), 'www.') ? 'www.'.$this->editingGeneratedHost : $this->editingGeneratedHost;
$replacements[$oldCounterpart] = DomainUrlParts::compose(...$parts);
}
}
$updated = $current->map(fn (string $url) => $replacements[$url] ?? $url)->unique()->values();
if ($this->editingRedirect !== $this->editingOriginalRedirect && in_array($this->editingRedirect, ['www', 'non-www'], true)) {
foreach ($updated->all() as $url) {
$counterpart = $this->wwwCounterpartUrl($url, true);
$host = is_string($counterpart) ? parse_url($counterpart, PHP_URL_HOST) : null;
if (filled($counterpart) && ! $updated->contains(fn (string $candidate): bool => parse_url($candidate, PHP_URL_HOST) === $host)) {
$updated->push($counterpart);
}
}
}
$urlsToCheck = $updated
->reject(fn (string $url): bool => $current->contains(
fn (string $existingUrl): bool => ! DomainUrlParts::hasDnsRelevantChange($existingUrl, $url)
))
->map(fn (string $url): string => DomainPortOverrides::withoutPort($url))
->unique()
->values()
->all();
$noindexDomains = $app->noindexDomains();
foreach ($replacements as $previousUrl => $replacementUrl) {
$isNoindexed = $previousUrl === $oldUrl ? $this->editingIndexing === 'noindex' : $app->isDomainNoindexed($previousUrl);
$noindexDomains = $noindexDomains->reject(fn (string $domain): bool => $domain === $previousUrl);
if ($isNoindexed) {
$noindexDomains->push($replacementUrl);
if (! $this->forceSaveEditDns && $this->shouldValidateDns()) {
$dnsFailure = $this->findDnsFailureMessage([$newUrl]);
if ($dnsFailure !== null) {
$this->editDomainDnsFailed = true;
$this->editDomainDnsMessage = $dnsFailure;
$this->showEditDomainModal = true;
return;
}
}
$updated = $current->map(fn (string $url) => $url === $oldUrl ? $newUrl : $url)->unique()->values();
$this->pendingAction = 'update';
if (! $this->saveDomainListForApp($app, $updated, noindexDomains: $noindexDomains, redirect: $this->editingRedirect)) {
if (! $this->saveDomainListForApp($app, $updated)) {
return;
}
$noindexDomains = $app->noindexDomains()->reject(fn (string $domain) => $domain === $oldUrl);
if ($wasNoindexed) {
$noindexDomains->push($newUrl);
}
$app->setNoindexDomains($noindexDomains);
$app->save();
$this->cancelEdit();
$this->dispatch('edit-domain-saved');
$this->forceSaveDomains = false;
@ -1305,9 +1204,7 @@ public function updateDomain(): void
$this->pendingAction = null;
$this->dispatch('success', 'Domain updated.');
$this->refreshDomains();
if ($urlsToCheck !== []) {
$this->queueUrlsDns($urlsToCheck, $app);
}
$this->checkUrlsDns([$newUrl], (int) $app->id);
} catch (\Throwable $e) {
handleError($e, $this);
}
@ -1346,28 +1243,6 @@ public function removeDomain(int $index): void
}
}
public function removeDomainByKey(string $domainKey): void
{
$index = collect($this->domainRows)->search(
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
&& hash_equals($domainKey, $this->domainRowKey($row))
);
if ($index === false) {
return;
}
$this->removeDomain((int) $index);
}
/**
* @param array{url: string, service_application_id: int|string} $row
*/
private function domainRowKey(array $row): string
{
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
}
public function addSuggestedDomain(int $index): void
{
try {
@ -1465,24 +1340,6 @@ public function generateDomain(): void
}
}
public function regenerateEditingDomain(): void
{
$this->authorize('update', $this->service);
if ($this->editingIndex === null || ! isset($this->domainRows[$this->editingIndex]) || ! $this->service->server) {
return;
}
$host = parse_url(generateUrl(server: $this->service->server, random: new_public_id()), PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return;
}
$this->editingGeneratedHost = $host;
$this->editingDomainParts['host'] = str_starts_with(strtolower((string) $this->editingDomainParts['host']), 'www.') ? 'www.'.$host : $host;
$this->editingDomainPartsChanged = true;
$this->editingDomainWasRegenerated = true;
}
/**
* @param Collection<int, string> $domains
*/
@ -1490,9 +1347,6 @@ protected function saveDomainListForApp(
ServiceApplication $app,
Collection $domains,
bool $checkConflicts = true,
bool $checkPorts = true,
?Collection $noindexDomains = null,
?string $redirect = null,
): bool {
$domainString = $domains->filter()->unique()->implode(',');
$domainString = $domainString === '' ? null : ValidationPatterns::normalizeApplicationDomains($domainString);
@ -1507,12 +1361,6 @@ protected function saveDomainListForApp(
}
$app->fqdn = $domainString;
if ($noindexDomains !== null) {
$app->setNoindexDomains($noindexDomains);
}
if ($redirect !== null) {
$app->redirect = $redirect;
}
if ($checkConflicts && ! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $app);
@ -1525,12 +1373,11 @@ protected function saveDomainListForApp(
}
}
if ($checkPorts && ! $this->forceRemovePort) {
if (! $this->forceRemovePort) {
$requiredPort = $app->getRequiredPort();
if ($requiredPort !== null && $domainString) {
$previousFqdn = $app->getOriginal('fqdn');
foreach ($this->splitDomains($domainString) as $fqdn) {
if ($app->portRequiresConfirmation($fqdn, $requiredPort, is_string($previousFqdn) ? $previousFqdn : null)) {
if (ServiceApplication::extractPortFromUrl($fqdn) === null) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
$app->refresh();
@ -1578,7 +1425,6 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null):
$urlSet = array_fill_keys($urls, true);
$server = $this->service->server;
$skipDns = ! $this->dnsValidationEnabled || ! $server;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
$url = $row['url'] ?? null;
@ -1612,33 +1458,6 @@ protected function checkUrlsDns(array $urls, ?int $serviceApplicationId = null):
$this->persistAllDomainDnsStatuses();
}
/**
* @param array<int, string> $urls
*/
protected function queueUrlsDns(array $urls, ServiceApplication $application): void
{
foreach (array_unique($urls) as $url) {
$checkId = new_public_id();
$this->markUrlsAsChecking([$url], (int) $application->id, $checkId);
$this->persistAllDomainDnsStatuses();
try {
CheckDomainDnsJob::dispatch(
$application,
$url,
$url,
$this->service->server,
$this->serverIp,
$checkId,
);
} catch (\Throwable) {
$this->markUrlsDnsCheckUnavailable([$url], (int) $application->id, $checkId);
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'The DNS check could not be started. Try again from the Domains page.');
}
}
}
protected function shouldValidateDns(): bool
{
return $this->dnsValidationEnabled && $this->service->server !== null;

View file

@ -78,7 +78,7 @@ public function saveEditedCompose()
try {
$this->authorize('update', $this->service);
$this->dispatch('saveCompose', $this->dockerComposeRaw);
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -46,18 +46,18 @@ public function mount()
$this->syncData();
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync to model
$this->application->setEditableUrls($this->fqdn);
$this->application->fqdn = $this->fqdn;
$this->application->save();
} else {
// Sync from model
$this->fqdn = $this->application->url;
$this->fqdn = $this->application->fqdn;
}
}
@ -84,10 +84,6 @@ public function cancelRemovePort()
public function submit()
{
try {
$persistedApplication = $this->application->fresh();
$previousEditableUrls = $persistedApplication->url;
$previousFqdn = $persistedApplication->fqdn;
$previousPortOverrides = $persistedApplication->domain_port_overrides;
$this->authorize('update', $this->application);
$this->validate();
@ -97,7 +93,7 @@ public function submit()
$this->dispatch('warning', __('warning.sslipdomain'));
}
// Sync to model for domain conflict check (without validation)
$this->application->setEditableUrls($this->fqdn);
$this->application->fqdn = $this->fqdn;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@ -117,21 +113,29 @@ public function submit()
$requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
$fqdn = trim((string) $fqdn);
if ($fqdn === '') {
// Check if all FQDNs have a port
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
if ($this->application->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
$this->application->fqdn = $previousFqdn;
$this->application->domain_port_overrides = $previousPortOverrides;
return;
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
// Reset the force flag after using it

View file

@ -120,7 +120,7 @@ public function refreshBackupStatus(): void
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
}
private function syncData(bool $toModel = false): void
public function syncData(bool $toModel = false): void
{
if ($toModel) {
if ($this->fileStorage->is_too_large) {
@ -160,7 +160,7 @@ public function convertToDirectory()
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
}
}
@ -179,7 +179,7 @@ public function loadStorageOnServer()
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
}
}
@ -207,7 +207,7 @@ public function convertToFile()
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
}
}
@ -242,7 +242,7 @@ public function delete($password, $selectedActions = [])
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->dispatch('storageCountsChanged')->to(Storage::class);
$this->dispatch('refreshStorages');
}
return true;
@ -308,10 +308,10 @@ public function render()
{
return view('livewire.project.service.file-storage', [
'directoryDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permanently deleted from the server.'],
['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'],
],
'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted from the server.'],
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
],
'hostFileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],

View file

@ -5,11 +5,8 @@
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Actions\Service\StopServiceApplication;
use App\Enums\ProcessStatus;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@ -172,29 +169,6 @@ public function restart()
}
}
public function removeSelectedResourceContainer(): void
{
$resource = $this->selectedResource();
if (! $resource) {
return;
}
$this->authorize('update', $resource);
StopServiceApplication::run($resource, true, true);
$this->dispatch('success', 'Container removed.');
}
private function selectedResource(): ServiceApplication|ServiceDatabase|null
{
$uuid = data_get($this->parameters, 'stack_service_uuid');
if (! $uuid) {
return null;
}
return $this->service->applications()->whereUuid($uuid)->first()
?? $this->service->databases()->whereUuid($uuid)->first();
}
public function pullAndRestartEvent()
{
try {

View file

@ -1,80 +0,0 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class ImportBackup extends Component
{
use AuthorizesRequests;
public Service $service;
public Collection $databases;
public ?ServiceDatabase $selectedDatabase = null;
public string $selectedDatabaseUuid = '';
public array $parameters;
public function mount(): mixed
{
$this->parameters = get_route_parameters();
$project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('update', $this->service);
$this->databases = $this->service->databases
->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database))
->values();
$databaseUuid = request()->route('stack_service_uuid');
if ($databaseUuid) {
$selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid);
abort_unless($selectedDatabase instanceof ServiceDatabase, 404);
$this->authorize('update', $selectedDatabase);
$this->selectedDatabase = $selectedDatabase;
$this->selectedDatabaseUuid = $selectedDatabase->uuid;
if (request()->routeIs('project.service.database.import')) {
return redirect()->route('project.service.import-backup.database', $this->parameters);
}
} elseif ($this->databases->count() === 1) {
return redirect()->route('project.service.import-backup.database', [
...$this->parameters,
'stack_service_uuid' => $this->databases->first()->uuid,
]);
}
return null;
}
public function updatedSelectedDatabaseUuid(): mixed
{
$database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid);
abort_unless($database instanceof ServiceDatabase, 404);
$this->authorize('update', $database);
return redirect()->route('project.service.import-backup.database', [
...$this->parameters,
'stack_service_uuid' => $database->uuid,
]);
}
public function render(): View
{
return view('livewire.project.service.import-backup');
}
private function supportsImport(ServiceDatabase $database): bool
{
return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']);
}
}

View file

@ -20,7 +20,7 @@ class Index extends Component
public ?Service $service = null;
public ServiceApplication|ServiceDatabase|null $serviceApplication = null;
public ?ServiceApplication $serviceApplication = null;
public ?ServiceDatabase $serviceDatabase = null;
@ -28,8 +28,6 @@ class Index extends Component
public ?string $currentRoute = null;
public bool $embedded = false;
public array $parameters;
public array $query;
@ -61,6 +59,8 @@ class Index extends Component
public bool $isLogDrainEnabled = false;
public bool $isImportSupported = false;
// Application-specific properties
public $docker_cleanup = true;
@ -84,8 +84,6 @@ class Index extends Component
public bool $isStripprefixEnabled = false;
public mixed $maxRestartCount = 10;
protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages'];
protected $rules = [
@ -97,57 +95,16 @@ class Index extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isPublic' => 'required|boolean',
'isLogDrainEnabled' => 'required|boolean',
'maxRestartCount' => 'integer|min:0',
// Application-specific rules
'fqdn' => 'nullable',
'isGzipEnabled' => 'nullable|boolean',
'isStripprefixEnabled' => 'nullable|boolean',
];
public function mount(
ServiceApplication|ServiceDatabase|null $serviceApplication = null,
bool $embedded = false,
) {
public function mount()
{
try {
$this->embedded = $embedded;
$this->services = collect([]);
if ($serviceApplication instanceof ServiceDatabase) {
$this->service = $serviceApplication->service;
$this->authorize('view', $this->service);
$this->parameters = [
'project_uuid' => $this->service->environment->project->uuid,
'environment_uuid' => $this->service->environment->uuid,
'service_uuid' => $this->service->uuid,
'stack_service_uuid' => $serviceApplication->uuid,
];
$this->query = request()->query();
$this->currentRoute = 'project.service.index';
$this->serviceDatabase = $serviceApplication;
$this->serviceApplication = null;
$this->resourceType = 'database';
$this->initializeDatabaseProperties();
$this->s3s = currentTeam()->s3s;
return;
}
if ($serviceApplication) {
$this->service = $serviceApplication->service;
$this->authorize('view', $this->service);
$this->parameters = [
'project_uuid' => $this->service->environment->project->uuid,
'environment_uuid' => $this->service->environment->uuid,
'service_uuid' => $this->service->uuid,
'stack_service_uuid' => $serviceApplication->uuid,
];
$this->query = request()->query();
$this->currentRoute = 'project.service.index';
$this->serviceApplication = $serviceApplication;
$this->resourceType = 'application';
$this->initializeApplicationProperties();
$this->s3s = currentTeam()->s3s;
return;
}
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
@ -162,12 +119,6 @@ public function mount(
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) {
return redirect()->route(
'project.service.configuration',
collect($this->parameters)->except('stack_service_uuid')->all(),
);
}
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
@ -202,6 +153,10 @@ private function initializeDatabaseProperties(): void
$this->refreshFileStorages();
$this->syncDatabaseData(false);
// Check if import is supported for this database type
$dbType = $this->serviceDatabase->databaseType();
$supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo'];
$this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type));
}
private function syncDatabaseData(bool $toModel = false): void
@ -341,48 +296,36 @@ public function disablePublicAccess(): void
public function instantSave()
{
$this->authorize('update', $this->serviceDatabase);
try {
if ($this->isPublic) {
if (! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
$this->authorize('update', $this->serviceDatabase);
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
return;
}
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.');
$this->isPublic = false;
$this->serviceDatabase->is_public = 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 {
@ -413,43 +356,24 @@ private function syncApplicationData(bool $toModel = false): void
if ($toModel) {
$this->serviceApplication->human_name = $this->humanName;
$this->serviceApplication->description = $this->description;
$this->serviceApplication->setEditableUrls($this->fqdn);
$this->serviceApplication->fqdn = $this->fqdn;
$this->serviceApplication->image = $this->image;
$this->serviceApplication->exclude_from_status = $this->excludeFromStatus;
$this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled;
$this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled;
if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) {
$this->serviceApplication->restart_limit_reached = false;
}
$this->serviceApplication->max_restart_count = $this->maxRestartCount;
} else {
$this->humanName = $this->serviceApplication->human_name;
$this->description = $this->serviceApplication->description;
$this->fqdn = $this->serviceApplication->url;
$this->fqdn = $this->serviceApplication->fqdn;
$this->image = $this->serviceApplication->image;
$this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false);
$this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false);
$this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true);
$this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true);
$this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 10;
}
}
public function saveMaxRestartCount(): void
{
$this->authorize('update', $this->serviceApplication);
$validated = $this->validate([
'maxRestartCount' => 'integer|min:0',
]);
$this->serviceApplication->update([
'max_restart_count' => $validated['maxRestartCount'],
'restart_limit_reached' => false,
]);
$this->dispatch('success', 'Max restart count saved.');
}
public function instantSaveApplication()
{
try {
@ -504,7 +428,7 @@ public function deleteApplication($password, $selectedActions = [])
$this->serviceApplication->delete();
$this->dispatch('success', 'Application deleted.');
return redirectRoute($this, 'project.service.configuration', $this->parameters);
return redirect()->route('project.service.configuration', $this->parameters);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -538,7 +462,7 @@ public function convertToDatabase()
$serviceApplication->delete();
});
return redirectRoute($this, 'project.service.configuration', $redirectParams);
return redirect()->route('project.service.configuration', $redirectParams);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -567,10 +491,6 @@ public function cancelRemovePort()
public function submitApplication()
{
try {
$persistedApplication = $this->serviceApplication->fresh();
$previousEditableUrls = $persistedApplication->url;
$previousFqdn = $persistedApplication->fqdn;
$previousPortOverrides = $persistedApplication->domain_port_overrides;
$this->authorize('update', $this->serviceApplication);
$this->validate([
'fqdn' => ValidationPatterns::applicationDomainRules(),
@ -600,21 +520,28 @@ public function submitApplication()
$requiredPort = $this->serviceApplication->getRequiredPort();
if ($requiredPort !== null) {
foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) {
$fqdn = trim((string) $fqdn);
if ($fqdn === '') {
$fqdns = str($this->fqdn)->trim()->explode(',');
$missingPort = false;
foreach ($fqdns as $fqdn) {
$fqdn = trim($fqdn);
if (empty($fqdn)) {
continue;
}
if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
$this->serviceApplication->fqdn = $previousFqdn;
$this->serviceApplication->domain_port_overrides = $previousPortOverrides;
return;
$port = ServiceApplication::extractPortFromUrl($fqdn);
if ($port === null) {
$missingPort = true;
break;
}
}
if ($missingPort) {
$this->requiredPort = $requiredPort;
$this->showPortWarningModal = true;
return;
}
}
} else {
$this->forceRemovePort = false;

View file

@ -10,13 +10,6 @@ class Status extends Component
{
public Service $service;
public ?string $selectedResourceUuid = null;
public function mount(): void
{
$this->selectedResourceUuid = request()->route('stack_service_uuid');
}
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
@ -34,11 +27,6 @@ public function refreshStatus(): void
public function render(): View
{
$selectedResource = $this->selectedResourceUuid
? $this->service->applications->firstWhere('uuid', $this->selectedResourceUuid)
?? $this->service->databases->firstWhere('uuid', $this->selectedResourceUuid)
: null;
return view('livewire.project.service.status', compact('selectedResource'));
return view('livewire.project.service.status');
}
}

View file

@ -2,7 +2,6 @@
namespace App\Livewire\Project\Service;
use App\Livewire\Project\Shared\Storages\All as StorageList;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
@ -52,7 +51,7 @@ public function getListeners()
return [
"echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent',
'storageCountsChanged' => 'refreshStorages',
'refreshStorages',
'addNewVolume',
];
}
@ -88,17 +87,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$hadVolumes = $this->cachedVolumeCount > 0;
// Avoid loading full volume models onto this parent (child All owns that snapshot).
$this->resource->unsetRelation('persistentStorages');
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->loadFileStorageForActiveTab();
if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) {
$this->dispatch('refreshVolumeList')->to(StorageList::class);
}
}
public function setActiveTab(string $tab): void
@ -230,6 +223,7 @@ public function submitPersistentVolume()
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -264,6 +258,7 @@ public function submitFileStorage()
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -298,6 +293,7 @@ public function submitHostFileStorage()
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -336,6 +332,7 @@ public function submitFileStorageDirectory()
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}

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