feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras Bacsai
2026-08-03 23:11:54 +02:00
committed by GitHub
co-authored by ShadowArcanist
parent 0b843bb07c
commit 8ae56587d5
497 changed files with 44045 additions and 22630 deletions
+5 -4
View File
@@ -29,11 +29,12 @@ DB_PORT=5432
# DB_WRITE_PASSWORD=
# DB_STICKY=true
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
# Enable Laravel Debugbar (disabled by default; set true when needed)
DEBUGBAR_ENABLED=false
# Server-Timing headers + on-screen HUD (defaults ON when APP_ENV=local).
# Force on in any environment (including production): SERVER_TIMING_ENABLED=true
# Force off even in local: SERVER_TIMING_ENABLED=false
# SERVER_TIMING_ENABLED=true
# Vite dev server. Defaults to localhost. For phone/LAN/Tailscale access, set to
# the host machine's reachable IP (e.g. VITE_HOST=100.75.155.70), then recreate vite.
-1
View File
@@ -9,7 +9,6 @@ CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
TELESCOPE_ENABLED=false
REDIS_HOST=127.0.0.1
+26 -6
View File
@@ -76,15 +76,35 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
BRANCH: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
TAG_ARGS=(--tag "${IMAGE}:sha-${SHA}")
# Moving tag for the latest production-line SHA image (v4.x only).
if [ "${BRANCH}" = "v4.x" ]; then
TAG_ARGS+=(--tag "${IMAGE}:edge")
fi
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
"${TAG_ARGS[@]}"
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
BRANCH: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
TAG_ARGS=(--tag "${IMAGE}:sha-${SHA}")
# Moving tag for the latest production-line SHA image (v4.x only).
if [ "${BRANCH}" = "v4.x" ]; then
TAG_ARGS+=(--tag "${IMAGE}:edge")
fi
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
"${IMAGE}:sha-${SHA}-amd64" \
"${IMAGE}:sha-${SHA}-aarch64" \
"${TAG_ARGS[@]}"
+1
View File
@@ -40,6 +40,7 @@ CHANGELOG.md
/.workspaces
tests/Browser/Screenshots
tests/v4/Browser/Screenshots
ref
# Local generated Lima configs
.dev/bin/
-1
View File
@@ -167,7 +167,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8
- laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- rector/rector (RECTOR) - v2
+12 -6
View File
@@ -140,13 +140,19 @@ After installing Docker (or Orbstack) and Spin, verify the installation:
|------|-----|------|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
| Mailpit (email catcher) | `http://localhost:8025` | |
| Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default |
> [!NOTE]
> To enable Telescope, add the following to your `.env` file:
> ```env
> TELESCOPE_ENABLED=true
> ```
**Server-Timing + HUD** (headers + bottom-right pill on full HTML pages):
| Setting | Effect |
|---------|--------|
| `APP_ENV=local` and `SERVER_TIMING_ENABLED` unset | **On** (default in dev) |
| `SERVER_TIMING_ENABLED=true` | **On** in any env, including production |
| `SERVER_TIMING_ENABLED=false` | **Off** even when `APP_ENV=local` |
Metrics: `app` / `db` / `php` / `dbslow` (ms), `queries`, `html` (bytes), `mem` (MB).
HUD keeps a request log (click row → AI-ready dump). Production: enable only
temporarily (`SERVER_TIMING_ENABLED=true`); if you use `config:cache`, rebuild
or clear config after changing the env var.
## Development Notes
+2 -1
View File
@@ -20,7 +20,7 @@ Coolify uses two long-lived branches so production fixes can ship without waitin
| Branch | Role | Docker image tags | How it ships |
| --- | --- | --- | --- |
| **`v4.x`** | Production / releasable line | `sha-<commit>` via **Build Coolify (SHA)** | GitHub release promotes the SHA image to a semantic version (and `latest` for stable releases) |
| **`v4.x`** | Production / releasable line | `sha-<commit>` and moving `edge` via **Build Coolify (SHA)** | GitHub release promotes the SHA image to a semantic version (and `latest` for stable releases) |
| **`next`** | Development line for features and larger changes | Branch tag (for example `next`) via **Staging Build** | Becomes production only after merge into `v4.x` |
### Where to merge
@@ -59,6 +59,7 @@ Only commits on **`v4.x`** produce production SHA images and can be tagged for a
- Merge the release commit into `v4.x` through a pull request.
- The `Build Coolify (SHA)` workflow builds AMD64 and ARM64 images and publishes them to Docker Hub and GHCR using immutable architecture tags.
- After both builds complete, the workflow creates the multi-architecture `sha-<commit-sha>` manifest in both registries.
- For pushes to **`v4.x`**, the same multi-architecture manifest is also tagged as `edge`, so `coollabsio/coolify:edge` always points at the latest production-line SHA image. Builds from `main` publish only the immutable `sha-<commit-sha>` tags.
- This workflow does not update a semantic version tag or `latest`.
3. **Wait for the SHA Image**
+640
View File
@@ -0,0 +1,640 @@
# Coolify UI redesign
This branch restyles Coolify without changing its Livewire + Blade + Alpine +
Tailwind v4 architecture. The visual system now covers the global shell,
project and environment pages, application navigation, settings surfaces,
tables, modals, toasts, terminals, and metrics.
Use this file as the source of truth when updating another page. The older
Graphite-only notes are no longer accurate.
Onboarding validation and live server validation checkpoints share
`<x-checkpoint-item>` (idle / pending / running / success / error) inside a
compact divided list, not legacy green check SVGs or fixed-width status rows.
> **Maintainer rules**
>
> - Keep the work frontend-focused unless existing data must be exposed to the
> view.
> - Preserve routes, Livewire bindings, permissions, confirmations, and working
> interactions while changing layout and presentation.
> - Do not write or run tests for this redesign branch.
> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear
> it with `docker exec coolify php artisan view:clear`.
> - Build frontend assets in the Vitee container with
> `docker exec coolify-vite npm run build`.
> - Use existing components before adding another styling abstraction.
---
## 1. Visual direction
The interface is compact and product-focused:
- near-neutral layered surfaces instead of large bordered boxes;
- 1314px UI typography and 32px controls;
- 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;
- the readable Coolify yellow accent in dark mode;
- subtle active-item gradients that fade completely into the surrounding
background at the far edge, with the active pill rounded only on the left
(square on the right so it bleeds into the content edge);
- sentence-case labels and headings;
- never use the em dash (`—`) in UI copy. Prefer a period, colon, comma, or
ASCII hyphen (`-`) for empty cells and separators.
Avoid oversized titles, generic dashboard cards, strong shadows, thick
dividers, native browser selects, and isolated colored buttons that do not
match the current action styles.
---
## 2. Development and cascade notes
PHP runs in the `coolify` container. The development app is normally available
at `http://localhost:8000`, with Vite on port `5173`.
`resources/css/app.css` still contains unlayered global element rules for
headings, labels, and tables. Tailwind utilities are layered, so the
unlayered rules can win unexpectedly.
The settings and dense-surface CSS therefore lives as plain unlayered CSS near
the end of `resources/css/app.css`, beginning at:
```css
/* Coollabs layer-card settings surfaces */
```
Important consequences:
- scope restyled forms with `.application-settings-form` or
`.application-settings-workspace`;
- add shared surface overrides to the unlayered block instead of stacking
`!important` utilities;
- listbox panels require ancestors with `overflow: visible`;
- anchored cards use `scroll-margin-top: 7rem` to clear both fixed navigation
layers;
- modal shells reuse the layer-card classes but keep content-width sizing on
desktop;
- Alpine code inside quoted Blade attributes must not introduce conflicting
quote characters.
---
## 3. Tokens and color behavior
The surface ladder is defined in `resources/css/app.css`.
| Token | Light | Dark | Use |
|---|---|---|---|
| `--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` | 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:
- **Light mode:** Coolify purple (`coollabs`) for active controls, focus,
primary actions, and navigation accents.
- **Dark mode:** Coolify yellow (`warning`) for the same states because the
original purple did not provide sufficient text and ring contrast.
Do not hard-code blue focus rings or leave yellow accent utilities active in
light mode. Primary action patterns should normally follow:
```html
bg-coollabs/10 text-coollabs ring-coollabs/25
dark:bg-warning/15 dark:text-warning dark:ring-warning/25
```
The filled top-level action/tab treatment uses the same palette at a restrained
opacity rather than a fully saturated fill.
---
## 4. Page shells and navigation
### Global shell
- Main sidebar groups are compact, use outline Reicons, and keep a 32px row
height.
- Active sidebar rows have a curved accent rail and a subtle horizontal
gradient. The gradient must fade to the exact sidebar background at the
right edge in both themes.
- Nested items use a thin guide line with a visible active segment, not a thick
box border.
- The update badge sits on the version row and uses a tiny fully rounded
primary-action pill.
### Layer-2 navigation
Application and server pages use the same fixed second navigation layer
directly below the global topbar. Do not keep a large in-flow resource heading
or legacy `.navbar-main` tabs on one resource type while using the compact
layer-2 bar on another. Active tabs are a light brand fill:
- purple tint in light mode;
- yellow tint in dark mode;
- no fully saturated tab background.
Keep route-derived active state in Blade/Livewire. Do not rely only on Alpine
state because it can disappear after polling or a Livewire morph.
The global topbar owns the current resource identity and its compact status
badges. Layer 2 owns route tabs, resource links, and contextual action buttons
only. If a resource is missing from `x-top-breadcrumb`, extend the global
topbar instead of repeating its name or status summary in layer 2. Mobile
resource navigation may repeat this context because the desktop global topbar
is hidden there.
Only add layer-2 tabs when they represent real sibling routes inside one
context. Never repeat main-sidebar destinations such as Dashboard, Projects,
Terminal, Servers, Sources, Destinations, or Storage as a second tab row. A
single collection page does not need a tab just to fill the bar; keep its
primary action in the page header instead. When tabs are useful, their left edge
uses the same compact `pl-2` alignment as application navigation rather than
the content container's wide horizontal padding.
The dashboard is a compact overview, not a metrics wall. Use two full-width
sections that follow the projects-page grid pattern: projects first, then
servers. Keep one `New` action in the page header and let its modal choose the
resource type. Place active deployments above the resource grids as a compact,
live-updating table rather than a metric card. Communicate server health with
the shared status badge.
### Top-level dashboard destinations
Every page opened directly from the main sidebar uses the same compact content
shell:
- 24px page title and a 13px muted summary;
- the primary action at the top right using the restrained brand fill;
- no legacy `coolbox`, `.navbar-main`, or oversized subtitle block;
- four-column compact cards for small browsable collections;
- a dense table instead of cards when the collection is expected to grow;
- `x-empty` anatomy for empty states;
- `x-status-badge` for state and `x-reicon` for all interface icons.
Collection cards are `min-h-28` or `min-h-32`, use a 32px icon tile, and keep
secondary metadata at 11px. They must not grow into dashboard-sized summary
cards. Sources, destinations, S3 storage, private keys, and shared-variable
scopes use this pattern.
Top-level settings families such as Team, Notifications, Keys & Tokens, and
instance Settings use a compact header followed by a small route-derived tab
strip. The active tab uses the same purple-light/yellow-dark tint as resource
tabs. Do not nest `<button>` elements inside tab links.
### Route-family completion gate
A redesign is not complete when only its index or most visible route has been
updated. Treat every route family as one deliverable:
- index, create, detail, settings, logs, metrics, backup, execution, and danger
routes must share the same navigation hierarchy and surface language;
- main-sidebar collection routes use the global shell without duplicating those
destinations in a layer-2 tab row;
- resource detail families use resource identity and status in the global
topbar, route tabs and actions in layer 2, and the grouped settings sidebar
only for the third level;
- create and edit routes stay inside the same layer-2 family instead of
falling back to an isolated legacy page;
- reusable partials, empty states, confirmation flows, and row editors must be
migrated with the page that exposes them;
- audit the whole family for native selects, legacy heading blocks, old Save
buttons, old status chips, and `coolbox`/`navbar-main`/`sub-menu-wrapper`
before marking the family complete.
Do not report a family as redesigned while a sibling route still uses the old
tabs, a large in-flow title, a browser select, or a different modal anatomy.
The New Resource page keeps its filter controls in the top layer card, then
renders Applications, Databases, and Services as separate layer-card sections.
Do not leave category headings and resource grids floating as uncontained
content below the filter card.
### Settings workspace
Application and server configuration pages use the same 210px grouped,
icon-led sidebar and a full-width content column. The workspace is capped at
1180px, the sidebar becomes sticky at `xl`, and the sidebar label and first
content card start on the same visual line. Do not use the legacy
`sub-menu-wrapper`, native mobile page selects, or an in-flow row of top-level
tabs. Only show nested section anchors when a page has at least four useful
sections.
The shared workspace grid is:
```blade
<div
class="application-settings-workspace mt-8 grid min-w-0 gap-8
xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
...
</aside>
<div class="min-w-0 xl:mt-3">
...
</div>
</div>
```
Instance Settings constrains both `x-settings.navbar` and the workspace to the
same `max-w-[1180px]` shell.
**Page titles (global):** family H1s (`x-dashboard.navbar` with
`titleOnDesktop="false"`, the default) hide at **lg+**, the same breakpoint as
the desktop shell (main sidebar + fixed layer-2 tabs). Below `lg` the mobile
topbar is used and the page title stays visible. Collection indexes (Servers,
Projects, …) always keep their H1; stack title above actions on narrow widths
so they never overlap. Resource in-flow names only render below `md` (when the
fixed resource tab bar is hidden). Fixed layer-2 spacers must be `lg:h-12` to
match the bar height. Do not put the H1 beside the settings sidebar.
Standard content stack:
```blade
<div class="application-settings-workspace flex flex-col gap-6">
<x-application.settings-section ... />
<x-application.settings-section ... />
</div>
```
The current cross-page section gap is `gap-6`. Do not introduce extra top
padding on an individual page unless its toolbar is intentionally separated
from the first card.
Use a flex or grid stack with `gap-6`; do not use `space-y-*` between layer
cards. The layer-card root intentionally resets its own margin, so margin-based
spacing utilities can silently collapse.
---
## 5. Layer cards
Use `resources/views/components/application/settings-section.blade.php`.
Older manual shells may use `.application-settings-section-header` and
`.application-settings-section-body`; both must retain the same padded,
action-aligned anatomy as the component. Prefer migrating new work to the
component instead of creating another manual variant.
```blade
<x-application.settings-section
id="public-access-section"
title="Public access"
helper="How this section affects the resource.">
<x-slot:actions>
<x-forms.button>Action</x-forms.button>
</x-slot:actions>
...
</x-application.settings-section>
```
Anatomy:
- 8px shell radius;
- elevated header strip;
- no divider below the header;
- nested base-color body with its own fill ring;
- 16px body padding;
- optional `flush` mode for full-bleed tables;
- card-level actions belong in the header slot.
Header actions use an 8px top/right inset while the title keeps its 16px left
inset. Do not leave a larger empty strip between the final action and the
card's top-right corner.
Do not split one collection into a summary card followed by a table or log
card. Keep its status/action in the header, its view switcher or toolbar at the
top of a flush body, and its data in that same layer card. Repeated file
editors are the opposite case: each file gets its own titled layer card so its
content and actions remain clearly associated.
### Nested radii
Concentric boxes must follow:
```text
outer radius = inner radius + visible inset
```
Examples:
- a 6px tab or listbox option inside 4px padding uses a 10px outer well;
- an 8px button inside the unsaved pill's 8px padding uses a 16px outer pill.
Do not give visibly inset parent and child boxes the same radius. Flush or
edge-to-edge children are exempt because there is no visible inset to add.
Use an empty state when the section has no usable controls:
```blade
<x-empty size="sm" title="Nothing here" description="Explain what enables it.">
<x-slot:icon>
<x-reicon name="layers" class="size-8" />
</x-slot:icon>
</x-empty>
```
---
## 6. Controls
All normal controls are 32px high with an 8px radius.
### Field grids
The grid must match the controls visible in the current state:
- two visible peer controls use two columns, not a three-column grid with an
empty track;
- three visible peer controls may use three columns when their content stays
readable;
- conditional fields remain in the same grid when they are part of that field
group, so a URL or text input does not become wider than its peer column;
- collapse to one column at smaller breakpoints.
Do not pick a column count from the maximum possible state if the normal state
shows fewer controls.
### Inputs
Use `x-forms.input` and `x-forms.textarea`. Fields need visible vertical spacing
between the label and control. Password visibility uses the outline Reicon
`eye`/`eye-off` treatment from the shared input component.
### Dropdowns
Do not use native `<select>` on any redesigned route, including mobile
fallbacks. Use:
```blade
<x-forms.listbox id="property" label="Setting" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" onChange="instantSave" />
```
Boolean checkboxes should normally become descriptive two-option listboxes.
Use `.live` behavior only when the selection needs an immediate server
rerender.
Keep checkboxes for compact permission matrices and multi-select lists. Those
controls must use the shared `x-forms.checkbox` anatomy: an 18px rounded custom
box, purple checked fill in light mode, yellow checked fill in dark mode, and a
high-contrast check mark. Never expose the browser or Tailwind Forms default
checkbox on a redesigned page.
The popup panel uses a 10px radius around 6px options with a 4px inset. Keep
the option content left-aligned and size the panel to its content or trigger;
do not create an unnecessarily wide menu.
Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The
selected option is indicated inside the menu, not repeated on the trigger.
### Buttons
- neutral actions use the shared `.button`;
- primary actions use the theme-aware purple/yellow tint;
- destructive actions use the existing error treatment;
- use outline Reicons where a matching glyph exists;
- avoid raw browser-default buttons and old dark-mode purple fills.
### Unsaved changes
`resources/views/components/unsaved-bar.blade.php` is a compact floating
bottom-center pill. It contains:
- “You have changes that haven't been saved yet.”
- a subtle Reset action;
- a theme-aware Save changes button matching the tab accent.
On small viewports the pill is inset (`inset-x-3`) and stacks: full label on
the first line, Reset / Save on the second (right-aligned). From `sm` up it
returns to the centered single-row nowrap pill.
Do not restore the old full-width footer.
Deferred fields in one Livewire component use one floating unsaved bar and one
submit action. Do not add a separate “Save configuration” button to every
card. Selectors that are safe to persist independently should use the existing
instant-save pattern.
---
## 7. Dense tables
Collections with many rows should use the Cloudflare-inspired table pattern:
- toolbar above the table;
- search on the left;
- filters, sort, view toggles, and Add on the right;
- 40px header row and roughly 48px data rows;
- subtle row hover;
- plain text or the shared status badge rather than large colored chips;
- compact action at the far right;
- no separate layer card for each item.
Do not add a summary card above a table when it only repeats the row count,
current page, or refresh interval. Keep counts and pagination in the footer.
Background polling stays silent unless its state is actionable; do not add a
“Live updates” badge just to explain that a table refreshes. Filters only
render meaningful values; use the shared listbox instead of a number input or
browser-native control.
The footer is always inside the table shell:
- `Showing XY of Z` on the left;
- first, previous, current page, next, and last controls on the right.
Hide the entire pagination footer when there is only one page (`totalPages > 1`).
A lone “12 of 2” bar with disabled controls adds noise and is unnecessary.
Use `x-status-badge` for resource and execution state. It is a small neutral
pill with a semantic dot, not a full colored rectangle.
Relevant classes:
- `.data-table`
- `.data-table-header`
- `.data-table-row`
- `.table-badge`
Create a page-specific grid class when columns differ. Add responsive rules
that hide secondary columns before allowing horizontal overflow.
---
## 8. Modals, confirmations, and toasts
### Modals
`x-modal-input` and confirmation dialogs reuse the layer-card shell:
- compact elevated header;
- nested base-color body;
- content-width desktop sizing;
- shared 32px controls;
- no redundant description below a self-explanatory title;
- custom listboxes instead of native browser selects;
- right-aligned footer actions below a divider;
- compact action buttons, never a submit button stretched by a column layout.
Edit modals should use the same field layout and option set as their matching
create modal.
### Command palette
The global search command palette (`livewire:global-search`) is a compact
top-anchored overlay:
- elevated shell with hairline ring and modal shadow (not a heavy floating card);
- recessed-neutral header strip with outline search glyph and 14px input;
- compact `⌘K` / `/` / `ESC` kbd chips matching the sidebar search trigger;
- nested base-color results body with group labels in sentence case;
- dense result rows as inset 6px-radius pills (listbox anatomy), not full-bleed
bars with global focus rings;
- hover uses neutral fill; keyboard focus uses a soft accent wash plus a 2px
left rail — never the global `ring-2` / ring-offset treatment;
- create rows use a neutral plus tile that only picks up the accent when the
row is focused;
- type pills and quickcommand chips stay recessed; they tint with the accent
only on the focused row;
- neutral thin scrollbar inside the results body (not brand-colored);
- create-resource modals opened from the palette reuse the standard
`application-settings-section` layer-card shell.
Preserve keyboard navigation (arrow keys, Enter via focused links, Escape to
clear then close), `/` and `⌘K` open shortcuts, and the multi-step
server → destination → project → environment create flow.
### Toasts
`resources/views/components/toast.blade.php` provides the global
`window.toast(message, options)` API and Livewire event handling.
Current toast behavior:
- compact layered card, maximum width 26rem;
- Reicon status tile for success, info, warning, danger, or default;
- title plus optional description;
- dismiss and copy-details actions;
- up to four stacked notifications;
- four-second dismissal, paused while hovered;
- support for all six screen positions and sanitized custom HTML.
Do not bring back the old oversized dark rectangle.
---
## 9. Terminals, logs, and metrics
### Terminals
Application and server browser terminals use the same browser-oriented console
shell, theme picker, compact header controls, and outline `browser-terminal`
Reicon. Hide a container switcher when only one container exists.
### Logs
Runtime and deployment logs should feel like a clean terminal surface:
- keep a single log stream inside one layer card instead of adding an
introductory card above it;
- one compact toolbar;
- a recessed monospace log viewport;
- search and line-count controls aligned with icon actions;
- clear live/follow state;
- fullscreen support without changing the control language;
- custom listbox-style menus instead of browser dropdowns.
### Metrics
Metrics pages use separate layer cards for range selection, CPU, and memory.
Charts follow the application metrics implementation:
- 240px area chart;
- smooth 2px stroke and restrained gradient fill;
- dashed neutral grid;
- no ApexCharts toolbar;
- tooltip positioned at the hovered point;
- UTC on both axes and tooltip;
- 20% headroom above observed values;
- downsample long time ranges before rendering.
Only add a metric if Sentinel exposes historical data for it. Current Sentinel
history endpoints store CPU and memory. Root filesystem usage is included in
the periodic push payload for threshold notifications, but it is not stored as
a historical Sentinel metric and has no history endpoint, so it cannot power a
disk-usage graph yet.
---
## 10. Current reference surfaces
Use these as implementation references:
| Surface | Reference |
|---|---|
| Dashboard overview | `resources/views/livewire/dashboard.blade.php` |
| Top-level collection cards | `resources/views/livewire/project/index.blade.php`, `resources/views/source/all.blade.php` |
| Top-level family tabs | `resources/views/components/team/navbar.blade.php`, `resources/views/components/notification/navbar.blade.php` |
| General settings and form anatomy | `resources/views/livewire/project/application/general.blade.php` |
| Advanced settings | `resources/views/livewire/project/application/advanced.blade.php` |
| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` |
| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` |
| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` |
| Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` |
| Layer card | `resources/views/components/application/settings-section.blade.php` |
| Custom dropdown | `resources/views/components/forms/listbox.blade.php` |
| Empty state | `resources/views/components/empty.blade.php` |
| Status pill | `resources/views/components/status-badge.blade.php` |
| Floating save pill | `resources/views/components/unsaved-bar.blade.php` |
| Global toast | `resources/views/components/toast.blade.php` |
| Command palette / global search | `resources/views/livewire/global-search.blade.php` |
| Outline icons | `resources/views/components/reicon.blade.php` |
| Shared styling | `resources/css/app.css`, `resources/css/utilities.css` |
| HTTP error pages | `resources/views/components/error-page.blade.php`, `resources/views/errors/*` |
Already restyled application configuration surfaces include General, Advanced,
Environment Variables, Persistent Storage, Servers, Scheduled Tasks, Webhooks,
Preview Deployments, Healthcheck, Rollback, Resource Limits, Resource
Operations, Metrics, Tags, and Danger Zone.
HTTP error pages (400, 401, 402, 403, 404, 419, 429, 500, 503) use the shared
`<x-error-page>` component on the public auth-style canvas: theme-aware status
code, compact title and muted description, neutral `.button` actions, and an
`auth-text-link`-style Contact support link. Keep copy sentence-case and avoid
oversized 200px status numbers.
---
## 11. Restyling checklist
1. Inventory every route and reusable partial in the family before editing.
2. Read the current Blade and Livewire class before changing presentation.
3. Preserve every existing action, authorization check, loading state, and
confirmation.
4. Add the correct dual navigation and scoped workspace/form class.
5. Convert meaningful groups to layer cards and use `gap-6`.
6. Make the responsive column count match the controls visible in every state.
7. Replace native selects and checkbox-style configuration with listboxes.
8. Use one save model per component: instant-save or one floating dirty bar.
9. Check nested radii using `outer = inner + inset`.
10. Keep modal descriptions purposeful and footer actions compact/right-aligned.
11. Use tables for dense collections and cards for forms or summaries.
12. Use `x-status-badge`, `x-empty`, and `x-reicon`.
13. Confirm light and dark accent behavior.
14. Check fixed-nav anchor offsets and responsive stacking.
15. Sweep every sibling route for legacy controls and shells.
16. Run `git diff --check`.
17. Compile Blade views in the `coolify` container.
18. Build assets in `coolify-vite`.
19. Hard-refresh and inspect the family routes in both themes.
+4 -1
View File
@@ -24,6 +24,9 @@ class CleanupDocker
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
$buildxMetadataVolume = isDev() && $server->isLocalhost()
? 'coolify-buildx'
: '$HOME/.docker/buildx';
$cleanupLog = [];
@@ -51,7 +54,7 @@ class CleanupDocker
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
"docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
+5 -4
View File
@@ -18,7 +18,6 @@ use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
class Init extends Command
@@ -161,10 +160,12 @@ class Init extends Command
private function pullTemplatesFromCDN()
{
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
store_service_templates_bundle($response->body());
}
}
@@ -362,6 +362,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -554,6 +555,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -746,6 +748,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -1332,9 +1335,10 @@ class ApplicationsController extends Controller
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1343,7 +1347,7 @@ class ApplicationsController extends Controller
}
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
@@ -1435,7 +1439,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -1552,13 +1561,14 @@ class ApplicationsController extends Controller
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
if ($validator->fails()) {
@@ -1688,7 +1698,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -1804,14 +1819,15 @@ class ApplicationsController extends Controller
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
@@ -1913,7 +1929,12 @@ class ApplicationsController extends Controller
}
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
$dockerComposeDomainsJson->put(data_get($domain, 'name'), ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = data_get($domain, 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put(data_get($domain, 'name'), $entry);
});
$request->offsetUnset('docker_compose_domains');
}
@@ -2647,6 +2668,7 @@ class ApplicationsController extends Controller
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']],
],
),
],
@@ -2773,9 +2795,10 @@ class ApplicationsController extends Controller
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*' => 'array:name,domain,redirect',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
@@ -2785,7 +2808,7 @@ class ApplicationsController extends Controller
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only a name and domain field are supported.',
'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
@@ -2993,10 +3016,18 @@ class ApplicationsController extends Controller
$yaml = Yaml::parse($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson) {
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
$name = data_get($domain, 'name');
if ($name && is_array($services) && isset($services[$name])) {
$dockerComposeDomainsJson->put($name, ['domain' => data_get($domain, 'domain')]);
$entry = ['domain' => data_get($domain, 'domain')];
$redirect = array_key_exists('redirect', $domain)
? data_get($domain, 'redirect')
: data_get($existingDockerComposeDomains[$name] ?? [], 'redirect');
if (in_array($redirect, ['www', 'non-www', 'both'], true)) {
$entry['redirect'] = $redirect;
}
$dockerComposeDomainsJson->put($name, $entry);
}
});
$request->offsetUnset('docker_compose_domains');
@@ -858,7 +858,7 @@ class ServersController extends Controller
if ($server->definedResources()->count() > 0 && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
if ($server->is_coolify_host) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
+3
View File
@@ -2,6 +2,7 @@
namespace App\Http;
use App\Http\Middleware\AddServerTimingHeaders;
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\ApiSensitiveData;
use App\Http\Middleware\Authenticate;
@@ -51,6 +52,8 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// Outermost so Server-Timing includes the full middleware + app cost.
AddServerTimingHeaders::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
@@ -0,0 +1,152 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Symfony\Component\HttpFoundation\Response;
/**
* Adds W3C Server-Timing headers in local/dev so Chrome DevTools can show
* app + database cost per response (Network Timing Server Timing).
*
* Also injects a small on-screen HUD into full HTML documents so metrics are
* visible without opening DevTools. Livewire/fetch responses only get headers;
* the HUD updates from Server-Timing on those requests via a fetch patch.
*
* Client-side metrics (paint, LCP, layout, JS/CSS download) cannot be measured
* here use the Performance panel. Compare app dur vs wall-clock TTFB to see
* network/proxy overhead outside PHP.
*/
class AddServerTimingHeaders
{
public function handle(Request $request, Closure $next): Response
{
if (! $this->shouldAddHeaders()) {
return $next($request);
}
$startedAt = hrtime(true);
$queryCount = 0;
$queryTimeMs = 0.0;
$slowestQueryMs = 0.0;
$active = true;
Event::listen(QueryExecuted::class, function (QueryExecuted $query) use (&$queryCount, &$queryTimeMs, &$slowestQueryMs, &$active): void {
if (! $active) {
return;
}
$queryCount++;
$queryTimeMs += $query->time;
if ($query->time > $slowestQueryMs) {
$slowestQueryMs = $query->time;
}
});
$response = $next($request);
$active = false;
return $this->withServerTiming($response, $request, $startedAt, $queryCount, $queryTimeMs, $slowestQueryMs);
}
protected function shouldAddHeaders(): bool
{
return (bool) config('app.server_timing', false);
}
protected function withServerTiming(
Response $response,
Request $request,
int $startedAt,
int $queryCount,
float $queryTimeMs,
float $slowestQueryMs,
): Response {
$totalMs = (hrtime(true) - $startedAt) / 1_000_000;
$memoryMb = round(memory_get_peak_usage(true) / 1024 / 1024, 2);
$content = $response->getContent();
$htmlBytes = is_string($content) ? strlen($content) : 0;
$metrics = [
'app' => round($totalMs, 2),
'db' => round($queryTimeMs, 2),
'php' => round(max(0, $totalMs - $queryTimeMs), 2),
'dbslow' => round($slowestQueryMs, 2),
'queries' => $queryCount,
'html' => $htmlBytes,
'mem' => $memoryMb,
];
// Non-time metrics use dur so Chrome DevTools lists the value in Server Timing.
// queries = count, html = response body bytes (not milliseconds).
$headerMetrics = [
sprintf('app;desc="Total";dur=%.2f', $metrics['app']),
sprintf('db;desc="Database (%d queries)";dur=%.2f', $queryCount, $metrics['db']),
sprintf('php;desc="PHP (excl. DB)";dur=%.2f', $metrics['php']),
sprintf('dbslow;desc="Slowest query";dur=%.2f', $metrics['dbslow']),
sprintf('queries;desc="Query count";dur=%d', $queryCount),
sprintf('html;desc="Response bytes";dur=%d', $htmlBytes),
sprintf('mem;desc="Peak memory (MB)";dur=%.2f', $memoryMb),
];
$response->headers->set('Server-Timing', implode(', ', $headerMetrics));
// Convenience mirrors for curl / non-DevTools clients.
$response->headers->set('X-Debug-Memory-MB', (string) $memoryMb);
$response->headers->set('X-Debug-Query-Count', (string) $queryCount);
$response->headers->set('X-Debug-Html-Bytes', (string) $htmlBytes);
return $this->injectHud($response, $request, $metrics);
}
/**
* Inject a floating HUD into full HTML documents only (not Livewire partials/JSON).
*/
protected function injectHud(Response $response, Request $request, array $metrics): Response
{
$content = $response->getContent();
if (! is_string($content) || $content === '') {
return $response;
}
if (! $this->isFullHtmlDocument($response, $content)) {
return $response;
}
// Avoid double-inject (e.g. nested error pages).
if (str_contains($content, 'id="server-timing-hud"') || str_contains($content, "id='server-timing-hud'")) {
return $response;
}
$hud = view('components.server-timing-hud', [
'metrics' => $metrics,
'path' => '/'.ltrim($request->path(), '/'),
])->render();
$replaced = preg_replace('/<\/body>/i', $hud.'</body>', $content, 1, $count);
if ($count === 0 || ! is_string($replaced)) {
return $response;
}
$response->setContent($replaced);
$response->headers->remove('Content-Length');
// Keep html metric as pre-HUD page size (more useful for profiling the app).
return $response;
}
protected function isFullHtmlDocument(Response $response, string $content): bool
{
$contentType = (string) $response->headers->get('Content-Type', '');
if ($contentType !== '' && ! str_contains(strtolower($contentType), 'text/html')) {
return false;
}
// Full documents only — skip Livewire component HTML fragments.
return str_contains(strtolower($content), '</body>')
&& (str_contains(strtolower($content), '<html') || str_contains(strtolower($content), '<!doctype'));
}
}
+3 -1
View File
@@ -2160,7 +2160,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets();
$buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
$buildxMetadataVolume = isDev() && $this->server->isLocalhost()
? '-v coolify-buildx:/root/.docker/buildx'
: "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
+15 -5
View File
@@ -8,14 +8,14 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 10;
public $timeout = 60;
public function __construct()
{
@@ -28,14 +28,24 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
if (isDev()) {
return;
}
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->successful()) {
$services = $response->json();
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
// Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled.
store_service_templates_bundle($response->body());
} else {
Log::error('PullTemplatesFromCDN failed', [
'status' => $response->status(),
'body' => str($response->body())->limit(500)->toString(),
]);
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
}
} catch (\Throwable $e) {
Log::error('PullTemplatesFromCDN exception', [
'message' => $e->getMessage(),
]);
send_internal_notification('PullTemplatesAndVersions failed with: '.$e->getMessage());
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Livewire\Concerns;
use App\Support\DnsRecordHints;
use App\Support\DomainConnect\CloudflareDomainConnect;
use Illuminate\Support\Js;
trait InteractsWithCloudflareDomainConnect
{
public bool $showCloudflareAutoconfigureModal = false;
public bool $showDnsRecordsModal = false;
public function domainConnectAvailable(): bool
{
return app(CloudflareDomainConnect::class)->isAvailable();
}
public function openCloudflareAutoconfigureModal(): void
{
$this->authorizeUpdateForDomainConnect();
if (! $this->domainConnectAvailable()) {
$this->dispatch('error', 'Automated DNS configuration is only available on Coolify Cloud when Domain Connect is configured.');
return;
}
if ($this->allDomainHostnames() === []) {
$this->dispatch('error', 'Add at least one domain before configuring DNS on Cloudflare.');
return;
}
$this->showCloudflareAutoconfigureModal = true;
}
public function closeCloudflareAutoconfigureModal(): void
{
$this->showCloudflareAutoconfigureModal = false;
}
public function applyCloudflareAutoconfigure(): void
{
$this->authorizeUpdateForDomainConnect();
$domainConnect = app(CloudflareDomainConnect::class);
if (! $domainConnect->isAvailable()) {
$this->dispatch(
'error',
'Automated DNS configuration is only available on Coolify Cloud when a Domain Connect private key is configured.'
);
return;
}
$ip = $this->serverIpForDomainConnect();
if (blank($ip) || filter_var($ip, FILTER_VALIDATE_IP) === false) {
$this->dispatch(
'error',
'A resolvable server IP is required before autoconfiguring DNS. Set a public IP on the server (or instance settings for localhost).'
);
return;
}
$hostnames = $this->allDomainHostnames();
if ($hostnames === []) {
$this->dispatch('error', 'Add at least one domain before configuring DNS on Cloudflare.');
return;
}
$urls = [];
try {
foreach ($hostnames as $hostname) {
$parts = CloudflareDomainConnect::splitHostname($hostname);
$urls[] = $domainConnect->buildHostingApplyUrl(
domain: $parts['domain'],
ip: $ip,
host: $parts['host'] !== '' ? $parts['host'] : null,
);
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$urls = array_values(array_unique($urls));
$this->showCloudflareAutoconfigureModal = false;
$openScript = collect($urls)
->map(fn (string $url) => 'window.open('.Js::from($url).', "_blank", "noopener,noreferrer");')
->implode("\n");
$this->js($openScript);
}
public function openDnsRecordsModal(): void
{
$this->authorizeUpdateForDomainConnect();
$this->showDnsRecordsModal = true;
}
public function closeDnsRecordsModal(): void
{
$this->showDnsRecordsModal = false;
}
public function recheckDnsRecordsInModal(): void
{
$this->authorizeUpdateForDomainConnect();
$this->checkAllDns();
}
/**
* DNS records for hosts that still need attention (not already resolving correctly).
*
* @return array<int, array{type: string, name: string, value: string}>
*/
public function dnsRecordHints(): array
{
[$ipv4, $ipv6] = $this->serverIpsForDnsHints();
return DnsRecordHints::forHostnames($this->allDomainHostnames(onlyNeedingDns: true), $ipv4, $ipv6);
}
public function dnsRecordsCopyText(): string
{
return DnsRecordHints::toCopyText($this->dnsRecordHints());
}
/**
* Unique hostnames from domain rows (and optional form inputs).
*
* @param bool $onlyNeedingDns When true, skip hosts whose DNS status is already ok.
* @return array<int, string>
*/
protected function allDomainHostnames(bool $onlyNeedingDns = false): array
{
$hosts = [];
$workingHosts = [];
foreach ($this->domainRows ?? [] as $row) {
$url = $row['url'] ?? null;
if (! is_string($url) || $url === '') {
continue;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
continue;
}
$host = strtolower($host);
// Working configured domains: DNS already points at this server.
if (($row['dns_status'] ?? null) === 'ok' && ! ($row['is_suggested'] ?? false)) {
$workingHosts[$host] = true;
}
$hosts[] = $host;
}
if (filled($this->newDomain ?? null)) {
$candidate = $this->normalizeHostnameInput((string) $this->newDomain);
if ($candidate !== '') {
$hosts[] = strtolower($candidate);
}
}
if (filled($this->editingDomain ?? null) && ($this->showEditDomainModal ?? false)) {
$candidate = $this->normalizeHostnameInput((string) $this->editingDomain);
if ($candidate !== '') {
$hosts[] = strtolower($candidate);
}
}
$hosts = array_values(array_unique(array_filter($hosts)));
if ($onlyNeedingDns) {
$hosts = array_values(array_filter(
$hosts,
fn (string $host) => ! isset($workingHosts[$host])
));
}
sort($hosts);
return $hosts;
}
/**
* @return array{0: ?string, 1: ?string}
*/
protected function serverIpsForDnsHints(): array
{
$ipv4 = null;
$ipv6 = null;
$ip = $this->serverIpForDomainConnect();
if (filled($ip)) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$ipv4 = $ip;
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$ipv6 = $ip;
}
}
// 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];
}
protected function defaultDnsHostname(): string
{
$hosts = $this->allDomainHostnames();
return $hosts[0] ?? '';
}
protected function normalizeHostnameInput(string $hostname): string
{
$hostname = trim($hostname);
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
return rtrim($hostname, '.');
}
protected function serverIpForDomainConnect(): ?string
{
if (filled($this->serverIp) && filter_var($this->serverIp, FILTER_VALIDATE_IP) !== false) {
return $this->serverIp;
}
return null;
}
abstract protected function authorizeUpdateForDomainConnect(): void;
}
+15 -1
View File
@@ -20,7 +20,21 @@ class Dashboard extends Component
{
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeam()->with('environments')->get();
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->get();
}
public function render()
@@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Dashboard;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Component;
class ActiveDeployments extends Component
{
public Collection $activeDeployments;
public Collection $recentDeployments;
public function mount(): void
{
$this->refreshDeployments();
}
public function refreshDeployments(): void
{
$serverIds = Server::ownedByCurrentTeamCached()->pluck('id');
$columns = [
'id',
'application_id',
'application_name',
'deployment_url',
'deployment_uuid',
'pull_request_id',
'server_name',
'server_id',
'status',
'created_at',
'finished_at',
];
$baseQuery = ApplicationDeploymentQueue::query()
->with(['application.environment.project'])
->whereIn('server_id', $serverIds);
$this->activeDeployments = (clone $baseQuery)
->whereIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->orderBy('status')
->orderBy('id')
->limit(5)
->get($columns);
$this->recentDeployments = (clone $baseQuery)
->whereNotIn('status', [
ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
])
->orderByDesc('id')
->limit(5)
->get($columns);
}
public function render()
{
return view('livewire.dashboard.active-deployments');
}
}
+32
View File
@@ -11,6 +11,22 @@ class DeploymentsIndicator extends Component
{
public bool $expanded = false;
/**
* Persisted across polls. Livewire update requests are not the page route,
* so this must not be re-derived from request()->routeIs() on every render.
*/
public bool $shouldShow = true;
public function mount(): void
{
$this->shouldShow = $this->shouldShowForCurrentRequest();
}
public function updateShouldShowFromPath(string $path): void
{
$this->shouldShow = ! $this->isDashboardPath($path);
}
#[Computed]
public function deployments()
{
@@ -53,4 +69,20 @@ class DeploymentsIndicator extends Component
{
return view('livewire.deployments-indicator');
}
private function shouldShowForCurrentRequest(): bool
{
if (request()->routeIs('dashboard')) {
return false;
}
return ! $this->isDashboardPath(request()->path());
}
private function isDashboardPath(string $path): bool
{
$normalized = trim($path, '/');
return $normalized === '' || $normalized === '/';
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Notifications\Concerns;
trait TogglesNotificationEvents
{
private const NOTIFICATION_EVENT_KEYS = [
'deploymentSuccess',
'deploymentFailure',
'statusChange',
'backupSuccess',
'backupFailure',
'scheduledTaskSuccess',
'scheduledTaskFailure',
'dockerCleanupSuccess',
'dockerCleanupFailure',
'serverDiskUsage',
'serverReachable',
'serverUnreachable',
'serverPatch',
'traefikOutdated',
];
public function toggleEvent(string $property): void
{
$channel = class_basename(static::class);
$allowedProperties = array_map(
static fn (string $event): string => $event.$channel.'Notifications',
self::NOTIFICATION_EVENT_KEYS,
);
abort_unless(in_array($property, $allowedProperties, true), 404);
$this->{$property} = ! $this->{$property};
$this->saveModel();
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\DiscordNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Discord extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
public Team $team;
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -13,7 +14,7 @@ use Livewire\Component;
class Email extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\PushoverNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Pushover extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\SlackNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@@ -13,7 +14,7 @@ use Livewire\Component;
class Slack extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Telegram extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
protected $listeners = ['refresh' => '$refresh'];
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
@@ -12,7 +13,7 @@ use Livewire\Component;
class Webhook extends Component
{
use AuthorizesRequests;
use AuthorizesRequests, TogglesNotificationEvents;
public Team $team;
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Application\Deployment;
use App\Enums\ApplicationDeploymentStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -28,6 +30,18 @@ class Index extends Component
public ?string $pull_request_id = null;
public array $pullRequestOptions = [];
public string $search = '';
public string $deploymentFilter = 'all';
public string $deploymentSort = 'newest';
public array $statusFilterOptions = [];
public array $sourceFilterOptions = [];
protected $queryString = ['pull_request_id'];
public function getListeners()
@@ -64,8 +78,16 @@ class Index extends Component
}
}
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
$this->application = $application;
$this->loadPullRequestOptions();
$this->loadDeploymentFilterOptions();
['deployments' => $deployments, 'count' => $count] = $application->deployments(
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->current_url = url()->current();
@@ -114,14 +136,75 @@ class Index extends Component
$this->loadDeployments();
}
public function goToPage(int $page): void
{
$lastPage = max(1, (int) ceil($this->deployments_count / $this->defaultTake));
$page = max(1, min($page, $lastPage));
$this->skip = ($page - 1) * $this->defaultTake;
$this->showPrev = $page > 1;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function loadDeployments()
{
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
['deployments' => $deployments, 'count' => $count] = $this->application->deployments(
skip: $this->skip,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
search: $this->search,
filter: $this->deploymentFilter,
sort: $this->deploymentSort,
);
$this->deployments = $deployments;
$this->deployments_count = $count;
$this->showMore();
}
public function updatedSearch(): void
{
$this->resetPaginationAndLoad();
}
public function setDeploymentFilter(string $filter): void
{
$validFilters = collect($this->statusFilterOptions)
->concat($this->sourceFilterOptions)
->pluck('value')
->push('all');
if (! $validFilters->contains($filter)) {
return;
}
$this->deploymentFilter = $filter;
$this->pull_request_id = null;
$this->resetPaginationAndLoad();
}
public function setPullRequestFilter(string $pullRequestId): void
{
$validPullRequestIds = collect($this->pullRequestOptions)->pluck('value');
if (! $validPullRequestIds->contains($pullRequestId)) {
return;
}
$this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId;
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function setDeploymentSort(string $sort): void
{
if (! in_array($sort, ['newest', 'oldest'], true)) {
return;
}
$this->deploymentSort = $sort;
$this->resetPaginationAndLoad();
}
public function updatedPullRequestId($value)
{
// Sanitize and validate the pull request ID
@@ -139,20 +222,15 @@ class Index extends Component
$this->pull_request_id = null;
}
// Reset pagination when filter changes
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
$this->deploymentFilter = 'all';
$this->resetPaginationAndLoad();
}
private function updateCurrentPage()
@@ -160,6 +238,95 @@ class Index extends Component
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
}
private function loadPullRequestOptions(): void
{
$pullRequestIds = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->where('pull_request_id', '>', 0)
->distinct()
->orderByDesc('pull_request_id')
->pluck('pull_request_id')
->map(fn ($pullRequestId) => (string) $pullRequestId)
->values();
if ($this->pull_request_id && ! $pullRequestIds->contains($this->pull_request_id)) {
$this->pull_request_id = null;
}
$this->pullRequestOptions = collect([
['value' => '', 'label' => 'All deployments'],
])
->concat($pullRequestIds->map(fn ($pullRequestId) => [
'value' => $pullRequestId,
'label' => "Pull request #{$pullRequestId}",
]))
->all();
}
private function loadDeploymentFilterOptions(): void
{
$statuses = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->distinct()
->pluck('status');
$statusLabels = [
ApplicationDeploymentStatus::FINISHED->value => 'Success',
ApplicationDeploymentStatus::FAILED->value => 'Failed',
ApplicationDeploymentStatus::IN_PROGRESS->value => 'In progress',
ApplicationDeploymentStatus::QUEUED->value => 'Queued',
ApplicationDeploymentStatus::CANCELLED_BY_USER->value => 'Cancelled',
];
$this->statusFilterOptions = collect($statusLabels)
->filter(fn (string $label, string $status) => $statuses->contains($status))
->map(fn (string $label, string $status) => [
'value' => "status:{$status}",
'label' => $label,
])
->values()
->all();
$sourceKeys = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->selectRaw("
CASE
WHEN pull_request_id > 0 THEN 'pull-request'
WHEN is_webhook THEN 'webhook'
WHEN rollback THEN 'rollback'
WHEN is_api THEN 'api'
ELSE 'manual'
END AS source_key
")
->distinct()
->pluck('source_key');
$sourceLabels = [
'manual' => 'Manual',
'pull-request' => 'Pull requests',
'webhook' => 'Webhooks',
'api' => 'API',
'rollback' => 'Rollbacks',
];
$this->sourceFilterOptions = collect($sourceLabels)
->filter(fn (string $label, string $source) => $sourceKeys->contains($source))
->map(fn (string $label, string $source) => [
'value' => "source:{$source}",
'label' => $label,
])
->values()
->all();
}
private function resetPaginationAndLoad(): void
{
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function render()
{
return view('livewire.project.application.deployment.index');
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -94,6 +94,9 @@ class General extends Component
public bool $isSpa = false;
/** UI-only aggregate of isStatic/isSpa: dynamic | static | spa */
public string $siteType = 'dynamic';
public bool $isBuildServerEnabled = false;
public bool $isPreserveRepositoryEnabled = false;
@@ -437,6 +440,7 @@ class General extends Component
// Application settings properties
$this->isStatic = $this->application->settings->is_static;
$this->isSpa = $this->application->settings->is_spa;
$this->siteType = $this->isStatic ? ($this->isSpa ? 'spa' : 'static') : 'dynamic';
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
@@ -444,6 +448,13 @@ class General extends Component
}
}
public function setSiteType(): void
{
$this->isStatic = $this->siteType !== 'dynamic';
$this->isSpa = $this->siteType === 'spa';
$this->instantSave();
}
public function instantSave()
{
try {
@@ -680,7 +691,10 @@ class General extends Component
if ($this->application->additional_servers->count() === 0) {
foreach ($domains as $domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<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.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $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.");
}
}
}
@@ -722,7 +736,10 @@ class General extends Component
$this->application->redirect = $this->redirect;
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
if ($has_www === 0 && $this->application->redirect === 'www') {
$this->dispatch('error', 'You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list and as an A DNS record (if applicable).');
$server = $this->application->destination?->server;
$target = $server ? (serverDnsTargetIp($server) ?? $server->ip) : null;
$dnsHint = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "You want to redirect to www, but you do not have a www domain set.<br><br>Please add www to your domain list ({$dnsHint}).");
return;
}
@@ -847,7 +864,10 @@ class General extends Component
$domain = data_get($service, 'domain');
if ($domain) {
if (! validateDNSEntry($domain, $this->application->destination->server)) {
$showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$domain->{$this->application->destination->server->ip}<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.");
$server = $this->application->destination->server;
$target = serverDnsTargetIp($server) ?? $server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$showToaster && $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.");
}
}
}
@@ -20,6 +20,8 @@ class Heading extends Component
public array $parameters;
public string $activeRouteName = '';
protected string $deploymentUuid;
public bool $docker_cleanup = true;
@@ -38,6 +40,7 @@ class Heading extends Component
public function mount()
{
$this->activeRouteName = request()->route()?->getName() ?? '';
$this->parameters = [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
@@ -129,7 +129,10 @@ class Previews extends Component
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
$this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.<br><br>$fqdn->{$this->application->destination->server->ip}<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.");
$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;
}
+10 -2
View File
@@ -252,9 +252,17 @@ class BackupEdit extends Component
]);
}
// Instance databases (e.g. coolify-db) have no project/environment.
// Stay on the current page (settings.backup) instead of redirecting.
$project = $database->project();
$environment = $database->environment;
if (! $project || ! $environment) {
return null;
}
return redirect()->route('project.database.backup.executions', [
'project_uuid' => $database->project()->uuid,
'environment_uuid' => $database->environment->uuid,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
'backup_uuid' => $this->backup->uuid,
]);
+55 -12
View File
@@ -2,28 +2,71 @@
namespace App\Livewire\Project;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Index extends Component
{
public $projects;
public $servers;
public $private_keys;
public function mount()
public function mount(): void
{
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
$this->projects = Project::ownedByCurrentTeamCached();
$this->servers = Server::ownedByCurrentTeamCached();
// Only load what the page renders. Servers/private keys were previously
// hydrated into public Livewire state but never used by the view.
$this->projects = Project::ownedByCurrentTeam()
->with(['environments:id,uuid,name,project_id'])
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->get();
}
public function render()
public function render(): View
{
return view('livewire.project.index');
return view('livewire.project.index', [
'projectsJs' => $this->projects->map(function (Project $project): array {
$firstEnvironment = $project->environments->first();
$resourceCount = collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
])->sum();
return [
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
'addResourceHref' => $firstEnvironment && auth()->user()->can('createAnyResource')
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}
+19 -6
View File
@@ -161,13 +161,15 @@ class Select extends Component
[
'id' => 'public',
'name' => 'Public Repository',
'description' => 'You can deploy any kind of public repositories from the supported git providers.',
'description' => 'Connect any public Git repository and let Coolify build and deploy it from source.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd',
'logo' => asset('svgs/git.svg'),
],
[
'id' => 'private-gh-app',
'name' => 'Private Repository (with GitHub App)',
'description' => 'You can deploy public & private repositories through your GitHub Apps.',
'description' => 'Deploy a private GitHub repository with automatic webhooks and pull request support.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/setup-app',
'logo' => asset('svgs/github.svg'),
],
[
@@ -179,7 +181,8 @@ class Select extends Component
[
'id' => 'private-deploy-key',
'name' => 'Private Repository (with Deploy Key)',
'description' => 'You can deploy private repositories with a deploy key.',
'description' => 'Connect a private Git repository over SSH using a repository-scoped deploy key.',
'documentation' => 'https://coolify.io/docs/applications/ci-cd/github/deploy-key',
'logo' => asset('svgs/git.svg'),
],
];
@@ -187,19 +190,22 @@ class Select extends Component
[
'id' => 'dockerfile',
'name' => 'Dockerfile',
'description' => 'You can deploy a simple Dockerfile, without Git.',
'description' => 'Build and deploy an application from a Dockerfile without connecting a Git repository.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/dockerfile',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-compose-empty',
'name' => 'Docker Compose Empty',
'description' => 'You can deploy complex application easily with Docker Compose, without Git.',
'description' => 'Create a multi-container application and provide the Docker Compose definition manually.',
'documentation' => 'https://coolify.io/docs/applications/build-packs/docker-compose',
'logo' => asset('svgs/docker.svg'),
],
[
'id' => 'docker-image',
'name' => 'Docker Image',
'description' => 'You can deploy an existing Docker Image from any Registry, without Git.',
'description' => 'Run a prebuilt image from Docker Hub or another compatible container registry.',
'documentation' => 'https://coolify.io/docs/applications',
'logo' => asset('svgs/docker.svg'),
],
];
@@ -286,6 +292,13 @@ class Select extends Component
private function serviceTemplatesLastUpdated(): ?string
{
$fetchedAt = get_service_templates_fetched_at();
if ($fetchedAt instanceof CarbonImmutable) {
return $fetchedAt
->timezone(config('app.timezone'))
->format('M j, Y H:i');
}
return $this->formatLastModified($this->serviceTemplatesPath());
}
+13 -11
View File
@@ -188,24 +188,26 @@ class Index extends Component
'dragonflies' => $this->dragonflies,
'clickhouses' => $this->clickhouses,
'services' => $this->services,
'applicationsJs' => $this->toSearchableArray($this->applications),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
'redisJs' => $this->toSearchableArray($this->redis),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
'keydbsJs' => $this->toSearchableArray($this->keydbs),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
'servicesJs' => $this->toSearchableArray($this->services),
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'),
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'),
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'),
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'),
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'),
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'),
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'),
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'),
'servicesJs' => $this->toSearchableArray($this->services, 'service', 'Service'),
]);
}
private function toSearchableArray(Collection $items): array
private function toSearchableArray(Collection $items, string $type, string $typeLabel): array
{
return $items->map(fn ($item) => [
'uuid' => $item->uuid,
'name' => $item->name,
'type' => $type,
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
'fqdn' => $item->fqdn ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'status' => $item->status ?? '',
File diff suppressed because it is too large Load Diff
+12
View File
@@ -282,6 +282,18 @@ class Index extends Component
}
}
public function enablePublicAccess(): void
{
$this->isPublic = true;
$this->instantSave();
}
public function disablePublicAccess(): void
{
$this->isPublic = false;
$this->instantSave();
}
public function instantSave()
{
try {
@@ -6,6 +6,7 @@ use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -29,10 +30,22 @@ class All extends Component
public string $search = '';
public string $environmentFilter = 'all';
public int $page = 1;
public int $perPage = 10;
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
/**
* Environment variable rows are loaded after first paint via wire:init
* so the surrounding configuration page can render immediately.
*/
public bool $readyToLoad = false;
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -41,6 +54,7 @@ class All extends Component
public function updatedSearch(): void
{
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
@@ -51,6 +65,11 @@ class All extends Component
unset($this->hardcodedEnvironmentVariables);
unset($this->hardcodedEnvironmentVariablesPreview);
unset($this->hasEnvironmentVariables);
unset($this->environmentVariableRows);
unset($this->environmentVariablePageRows);
unset($this->environmentVariableRowCount);
unset($this->environmentVariableLastPage);
unset($this->currentEnvironmentVariablePage);
}
public function mount()
@@ -63,7 +82,25 @@ class All extends Component
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
}
$this->getDevView();
// Intentionally skip loading env vars / developer-view bulk text here.
// loadEnvironmentVariables() is triggered from the frontend via wire:init.
}
/**
* Frontend-initiated load of environment variables after the page shell paints.
*/
public function loadEnvironmentVariables(): void
{
if ($this->readyToLoad) {
return;
}
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
if ($this->view === 'dev') {
$this->getDevView();
}
}
public function instantSave()
@@ -71,10 +108,14 @@ class All extends Component
try {
$this->authorize('manageEnvironment', $this->resource);
$this->page = 1;
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
$this->resource->settings->use_build_secrets = $this->use_build_secrets;
$this->resource->settings->save();
$this->getDevView();
$this->clearEnvironmentVariableCaches();
if ($this->readyToLoad && $this->view === 'dev') {
$this->getDevView();
}
$this->dispatch('success', 'Environment variable settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
@@ -84,20 +125,30 @@ class All extends Component
public function getEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(false);
}
public function getEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getEnvironmentVariables(true);
}
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true, bool $withValue = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
// Full-value loads (dev view / tests) still use relationship queries.
// List/table pagination uses fetchManagedEnvironmentVariables() without values.
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -116,7 +167,15 @@ class All extends Component
$query->orderBy('order');
}
return $this->nullLockedValues($query->get());
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
$variables = $query->get()->each(fn (EnvironmentVariable $environmentVariable) => $environmentVariable->setAppends([]));
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function searchTerm(): string
@@ -131,14 +190,11 @@ class All extends Component
public function getHasEnvironmentVariablesProperty(): bool
{
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
if (! $this->readyToLoad) {
return false;
}
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables;
return $this->environmentVariableRowCount > 0;
}
private function nullLockedValues($envs)
@@ -162,14 +218,341 @@ class All extends Component
public function getHardcodedEnvironmentVariablesProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(false);
}
public function getHardcodedEnvironmentVariablesPreviewProperty()
{
if (! $this->readyToLoad) {
return collect();
}
return $this->getHardcodedVariables(true);
}
/**
* Full in-memory row list. Prefer page/count helpers for UI rendering so large
* variable sets stay cheap; this remains for tests and non-paginated callers.
*/
public function getEnvironmentVariableRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
if ($segment['kind'] === 'managed') {
foreach ($this->fetchManagedEnvironmentVariables($segment['is_preview'], null, null) as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
continue;
}
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$index,
));
}
}
return $rows->values();
}
public function getEnvironmentVariablePageRowsProperty(): Collection
{
if (! $this->readyToLoad) {
return collect();
}
$page = $this->currentEnvironmentVariablePage;
$offset = max(0, ($page - 1) * $this->perPage);
$remaining = $this->perPage;
$rows = collect();
foreach ($this->environmentVariableSegments() as $segment) {
$segmentCount = $segment['count'];
if ($segmentCount === 0) {
continue;
}
if ($offset >= $segmentCount) {
$offset -= $segmentCount;
continue;
}
$take = min($remaining, $segmentCount - $offset);
if ($segment['kind'] === 'managed') {
$variables = $this->fetchManagedEnvironmentVariables($segment['is_preview'], $offset, $take);
foreach ($variables as $environmentVariable) {
$rows->push($this->managedEnvironmentVariableRow($environmentVariable));
}
} else {
$hardcoded = $segment['is_preview']
? $this->hardcodedEnvironmentVariablesPreview
: $this->hardcodedEnvironmentVariables;
foreach ($hardcoded->slice($offset, $take)->values() as $index => $environmentVariable) {
$rows->push($this->hardcodedEnvironmentVariableRow(
$environmentVariable,
$segment['is_preview'],
$offset + $index,
));
}
}
$remaining -= $take;
$offset = 0;
if ($remaining <= 0) {
break;
}
}
return $rows->values();
}
public function getEnvironmentVariableRowCountProperty(): int
{
if (! $this->readyToLoad) {
return 0;
}
return collect($this->environmentVariableSegments())->sum('count');
}
public function getEnvironmentVariableLastPageProperty(): int
{
return max(1, (int) ceil($this->environmentVariableRowCount / $this->perPage));
}
public function getCurrentEnvironmentVariablePageProperty(): int
{
return min($this->page, $this->environmentVariableLastPage);
}
public function setEnvironmentFilter(string $filter): void
{
if (! in_array($filter, ['all', 'production', 'preview'], true)) {
return;
}
$this->environmentFilter = $filter;
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public function setEnvironmentVariablePage(int $page): void
{
$this->page = max(1, min($page, $this->environmentVariableLastPage));
$this->clearEnvironmentVariableCaches();
}
public function previousEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage - 1);
}
public function nextEnvironmentVariablePage(): void
{
$this->setEnvironmentVariablePage($this->currentEnvironmentVariablePage + 1);
}
/**
* Ordered segments used for pagination: production managed production hardcoded
* preview managed preview hardcoded (matching the historical table order).
*
* @return list<array{kind: string, is_preview: bool, count: int}>
*/
private function environmentVariableSegments(): array
{
$includeProduction = $this->environmentFilter === 'all' || $this->environmentFilter === 'production';
$includePreview = $this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview');
$segments = [];
if ($includeProduction) {
$segments[] = [
'kind' => 'managed',
'is_preview' => false,
'count' => $this->countManagedEnvironmentVariables(false),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => false,
'count' => $this->hardcodedEnvironmentVariables->count(),
];
}
}
if ($includePreview) {
$segments[] = [
'kind' => 'managed',
'is_preview' => true,
'count' => $this->countManagedEnvironmentVariables(true),
];
if ($this->showsHardcodedEnvironmentVariables()) {
$segments[] = [
'kind' => 'hardcoded',
'is_preview' => true,
'count' => $this->hardcodedEnvironmentVariablesPreview->count(),
];
}
}
return $segments;
}
private function managedEnvironmentVariablesQuery(bool $isPreview): Builder
{
$query = EnvironmentVariable::query()
->where('resourceable_type', $this->resource->getMorphClass())
->where('resourceable_id', $this->resource->id)
->where('is_preview', $isPreview);
$query->orderByRaw("CASE WHEN is_required = true AND (value IS NULL OR value = '') THEN 0 ELSE 1 END");
if ($this->searchTerm() !== '') {
$escapedSearch = addcslashes(Str::lower($this->searchTerm()), '%_\\');
$query->whereRaw("LOWER(key) LIKE ? ESCAPE '\\'", ['%'.$escapedSearch.'%']);
}
if ($this->is_env_sorting_enabled) {
$query->orderBy('key');
} else {
$query->orderBy('order')->orderBy('id');
}
return $query;
}
private function countManagedEnvironmentVariables(bool $isPreview): int
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return 0;
}
return $this->managedEnvironmentVariablesQuery($isPreview)->count();
}
/**
* Columns needed to render the table row / nested Show component without decrypting secrets.
* The encrypted `value` column is intentionally omitted until edit/dev view loads it.
*
* @var list<string>
*/
private const LIST_COLUMNS = [
'id',
'uuid',
'key',
'comment',
'order',
'is_preview',
'is_multiline',
'is_literal',
'is_runtime',
'is_buildtime',
'is_required',
'is_shown_once',
'is_shared',
'resourceable_type',
'resourceable_id',
'created_at',
'updated_at',
'version',
];
private function fetchManagedEnvironmentVariables(?bool $isPreview, ?int $offset, ?int $limit, bool $withValue = false): Collection
{
if ($isPreview === true && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
if ($isPreview === null) {
$variables = collect();
if ($this->environmentFilter === 'all' || $this->environmentFilter === 'production') {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(false, null, null, $withValue));
}
if ($this->supportsPreviewEnvironmentVariables()
&& ($this->environmentFilter === 'all' || $this->environmentFilter === 'preview')) {
$variables = $variables->concat($this->fetchManagedEnvironmentVariables(true, null, null, $withValue));
}
return $variables->values();
}
$query = $this->managedEnvironmentVariablesQuery($isPreview);
if (! $withValue) {
$query->select(self::LIST_COLUMNS);
}
if ($offset !== null) {
$query->skip($offset);
}
if ($limit !== null) {
$query->take($limit);
}
$variables = $query->get()->each(function (EnvironmentVariable $environmentVariable) {
// Prevent accidental real_value / is_shared accessor work during Livewire hydration.
$environmentVariable->setAppends([]);
});
return $withValue
? $this->nullLockedValues($variables)
: $variables;
}
private function managedEnvironmentVariableRow(EnvironmentVariable $environmentVariable): array
{
return [
'id' => 'environment-'.$environmentVariable->id,
'kind' => 'managed',
'scope' => $environmentVariable->is_preview ? 'preview' : 'production',
'environmentVariable' => $environmentVariable,
];
}
private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array
{
$scope = $isPreview ? 'preview' : 'production';
return [
'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index,
'kind' => 'hardcoded',
'scope' => $scope,
'environmentVariable' => $environmentVariable,
];
}
private function showsHardcodedEnvironmentVariables(): bool
{
return $this->resource->type() === 'service' || $this->resource?->build_pack === 'dockercompose';
}
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
@@ -256,13 +639,17 @@ class All extends Component
public function switch()
{
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
if ($this->view === 'dev') {
$this->ensureEnvironmentVariablesLoaded();
$this->getDevView();
}
}
public function submit($data = null)
{
try {
$this->authorize('manageEnvironment', $this->resource);
$this->ensureEnvironmentVariablesLoaded();
if ($data === null) {
$this->handleBulkSubmit();
} else {
@@ -270,7 +657,9 @@ class All extends Component
}
$this->updateOrder();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@@ -278,6 +667,13 @@ class All extends Component
}
}
private function ensureEnvironmentVariablesLoaded(): void
{
if (! $this->readyToLoad) {
$this->loadEnvironmentVariables();
}
}
private function updateOrder()
{
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
@@ -495,7 +891,10 @@ class All extends Component
public function refreshEnvs()
{
$this->resource->refresh();
$this->readyToLoad = true;
$this->clearEnvironmentVariableCaches();
$this->getDevView();
if ($this->view === 'dev') {
$this->getDevView();
}
}
}
@@ -35,6 +35,10 @@ class Show extends Component
public string $type;
public int $tableAlphabeticalOrder = 0;
public int $tableCreationOrder = 0;
public string $key;
public ?string $value = null;
@@ -63,6 +67,18 @@ class Show extends Component
public bool $isValueHidden = false;
/**
* Decrypted value / real_value are only needed in the edit modal (or after save).
* Keeping them unloaded for table rows avoids decrypting every visible env on each page change.
*/
public bool $valuesLoaded = false;
/**
* Entangled with the edit modal open state so the modal stays open across the
* async loadValues() re-render (open immediately, decrypt after).
*/
public bool $editorOpen = false;
public array $problematicVariables = [];
protected $listeners = [
@@ -116,10 +132,33 @@ class Show extends Component
if (! $this->env->exists || ! $this->env->fresh()) {
return;
}
$this->valuesLoaded = false;
$this->syncData();
$this->checkEnvs();
}
/**
* Decrypt and resolve values only when the edit modal is opened.
*/
public function loadValues(): void
{
if ($this->valuesLoaded) {
return;
}
// List queries omit the encrypted value column; refresh so edit has a full model.
if ($this->env->exists) {
$fresh = $this->env->fresh();
if ($fresh) {
$fresh->setAppends([]);
$this->env = $fresh;
}
}
$this->hydrateValueFields();
$this->valuesLoaded = true;
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
@@ -149,29 +188,55 @@ class Show extends Component
$this->env->is_literal = $this->is_literal;
$this->env->is_shown_once = $this->is_shown_once;
$this->env->save();
$this->valuesLoaded = true;
} else {
// Table metadata only — never decrypt here. Values load via loadValues().
$this->env->setAppends([]);
$this->key = $this->env->key;
$this->value = $this->env->value;
$this->comment = $this->env->comment;
$this->is_multiline = $this->env->is_multiline;
$this->is_literal = $this->env->is_literal;
$this->is_shown_once = $this->env->is_shown_once;
$this->is_runtime = $this->env->is_runtime ?? true;
$this->is_buildtime = $this->env->is_buildtime ?? true;
$this->is_required = $this->env->is_required ?? false;
$this->is_really_required = $this->env->is_really_required ?? false;
$this->is_shared = $this->env->is_shared ?? false;
$this->real_value = $this->env->real_value;
$this->is_multiline = (bool) $this->env->is_multiline;
$this->is_literal = (bool) $this->env->is_literal;
$this->is_shown_once = (bool) $this->env->is_shown_once;
$this->is_runtime = (bool) ($this->env->is_runtime ?? true);
$this->is_buildtime = (bool) ($this->env->is_buildtime ?? true);
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->valuesLoaded) {
$this->hydrateValueFields();
} else {
$this->value = null;
$this->real_value = null;
// Required badge: without decrypting, show when flagged required.
// Exact empty-value state is refined when the edit modal opens.
$this->is_really_required = $this->is_required;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
}
private function hydrateValueFields(): void
{
$this->value = $this->env->value;
$this->is_shared = (bool) ($this->env->is_shared ?? false);
if ($this->is_shared) {
$this->real_value = $this->env->real_value;
$this->is_really_required = $this->is_required && blank($this->real_value);
} else {
$this->real_value = null;
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
}
public function checkEnvs()
{
$this->isDisabled = false;
@@ -215,6 +280,7 @@ class Show extends Component
{
try {
$this->authorize('update', $this->env);
$this->loadValues();
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
$oldValue = $this->env->getOriginal('value');
@@ -238,7 +304,23 @@ class Show extends Component
#[Computed]
public function availableSharedVariables(): array
{
// Shared across all Show row components in the same request (edit modals).
static $requestCache = [];
$team = currentTeam();
$cacheKey = implode('|', [
$team?->id ?? 'none',
data_get($this->parameters, 'project_uuid', ''),
data_get($this->parameters, 'environment_uuid', ''),
data_get($this->parameters, 'server_uuid', ''),
data_get($this->parameters, 'application_uuid', ''),
data_get($this->parameters, 'service_uuid', ''),
]);
if (array_key_exists($cacheKey, $requestCache)) {
return $requestCache[$cacheKey];
}
$result = [
'team' => [],
'project' => [],
@@ -248,7 +330,7 @@ class Show extends Component
// Early return if no team
if (! $team) {
return $result;
return $requestCache[$cacheKey] = $result;
}
// Check if user can view team variables
@@ -359,7 +441,7 @@ class Show extends Component
}
}
return $result;
return $requestCache[$cacheKey] = $result;
}
public function delete()
@@ -16,6 +16,8 @@ class ShowHardcoded extends Component
public ?string $serviceName = null;
public bool $isPreview = false;
public function mount()
{
$this->key = $this->env['key'];
+24 -7
View File
@@ -80,14 +80,31 @@ class Logs extends Component
]);
return $containers->toArray();
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
}
// Docker labels differ by resource type:
// applications → coolify.applicationId, services → coolify.serviceId, databases → coolify.databaseId
$containers = match (true) {
$this->resource instanceof Application => getCurrentApplicationContainerStatus(
$server,
$this->resource->id,
includePullrequests: true
),
$this->resource instanceof Service => getCurrentServiceContainerStatus(
$server,
$this->resource->id
),
default => getCurrentDatabaseContainerStatus(
$server,
$this->resource->id
),
};
if ($containers && $containers->count() > 0) {
return $containers->sort()->toArray();
}
return [];
} catch (\Exception $e) {
// Log error but don't fail the entire operation
+2
View File
@@ -17,6 +17,8 @@ class Terminal extends Component
public bool $isTerminalConnected = false;
public string $variant = 'default';
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
+1 -1
View File
@@ -62,7 +62,7 @@ class Webhooks extends Component
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
]);
$this->dispatch('success', 'Secret Saved.');
$this->dispatch('success', 'Webhook secrets saved.');
} catch (\Exception $e) {
return handleError($e, $this);
}
+63 -3
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Project;
use App\Models\Environment;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -34,7 +35,25 @@ class Show extends Component
public function mount(string $project_uuid)
{
try {
$this->project = Project::where('team_id', currentTeam()->id)->where('uuid', $project_uuid)->firstOrFail();
$this->project = Project::where('team_id', currentTeam()->id)
->where('uuid', $project_uuid)
->with([
'environments' => fn ($query) => $query
->withCount([
'applications',
'services',
'postgresqls',
'redis',
'keydbs',
'dragonflies',
'clickhouses',
'mongodbs',
'mysqls',
'mariadbs',
])
->orderBy('created_at'),
])
->firstOrFail();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -68,8 +87,49 @@ class Show extends Component
]);
}
public function render()
public function render(): View
{
return view('livewire.project.show');
$canUpdateProject = auth()->user()->can('update', $this->project);
$canCreateResource = auth()->user()->can('createAnyResource');
return view('livewire.project.show', [
'environmentsJs' => $this->project->environments->map(function (Environment $environment) use ($canCreateResource, $canUpdateProject): array {
$resourceCount = collect([
$environment->applications_count,
$environment->services_count,
$environment->postgresqls_count,
$environment->redis_count,
$environment->keydbs_count,
$environment->dragonflies_count,
$environment->clickhouses_count,
$environment->mongodbs_count,
$environment->mysqls_count,
$environment->mariadbs_count,
])->sum();
return [
'uuid' => $environment->uuid,
'name' => $environment->name,
'description' => $environment->description,
'resourceCount' => $resourceCount,
'href' => route('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
]),
'settingsHref' => $canUpdateProject
? route('project.environment.edit', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
'addResourceHref' => $canCreateResource
? route('project.resource.create', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $environment->uuid,
])
: null,
];
})->values()->toArray(),
]);
}
}
+5
View File
@@ -45,6 +45,11 @@ class Delete extends Component
}
try {
$this->authorize('delete', $this->server);
if ($this->server->is_coolify_host) {
$this->dispatch('error', 'The Coolify host server cannot be deleted.');
return;
}
if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
+2 -2
View File
@@ -177,11 +177,11 @@ class LogDrains extends Component
}
}
public function submit(string $type)
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->syncData(true, $type);
$this->syncData(true);
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
+25
View File
@@ -32,6 +32,8 @@ class Navbar extends Component
public bool $restartInitiated = false;
public array $serverSwitcherOptions = [];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -49,6 +51,29 @@ class Navbar extends Component
$this->currentRoute = request()->route()->getName();
$this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip;
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$routeParameters = request()->route()?->parameters() ?? [];
$routeName = request()->route()?->getName();
$this->serverSwitcherOptions = auth()->user()->currentTeam()->servers()
->orderBy('name')
->get()
->map(function (Server $server) use ($routeName, $routeParameters): array {
$parameters = [...$routeParameters, 'server_uuid' => $server->uuid];
try {
$href = $routeName ? route($routeName, $parameters) : route('server.show', $server->uuid);
} catch (\Throwable) {
$href = route('server.show', $server->uuid);
}
return [
'uuid' => $server->uuid,
'name' => $server->name,
'href' => $href,
'functional' => $server->isFunctional(),
];
})
->values()
->all();
$this->loadProxyConfiguration();
}
+36 -27
View File
@@ -48,6 +48,8 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $webhook_allow_localhost;
public ?string $domain_connect_private_key = null;
public function rules()
{
return [
@@ -63,6 +65,7 @@ class Advanced extends Component
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
'domain_connect_private_key' => 'nullable|string',
];
}
@@ -84,6 +87,8 @@ class Advanced extends Component
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
// Do not prefill the secret into the form; only update when the admin pastes a new value.
$this->domain_connect_private_key = null;
}
public function submit()
@@ -155,6 +160,11 @@ class Advanced extends Component
return;
}
if (isCloud() && filled($this->domain_connect_private_key)) {
$this->settings->domain_connect_private_key = $this->normalizeDomainConnectPrivateKey($this->domain_connect_private_key);
$this->domain_connect_private_key = null;
}
$this->instantSave($webhookAllowedInternalHosts);
} catch (\Exception $e) {
return handleError($e, $this);
@@ -187,6 +197,32 @@ class Advanced extends Component
}
}
public function clearDomainConnectPrivateKey(): void
{
try {
if (! isCloud()) {
return;
}
$this->authorize('update', $this->settings);
$this->settings->domain_connect_private_key = null;
$this->settings->save();
$this->domain_connect_private_key = null;
$this->dispatch('success', 'Domain Connect private key removed.');
} catch (\Exception $e) {
handleError($e, $this);
}
}
private function normalizeDomainConnectPrivateKey(string $key): string
{
$key = str_replace(["\r\n", "\r"], "\n", trim($key));
if (! str_contains($key, "\n") && str_contains($key, '\\n')) {
$key = str_replace('\\n', "\n", $key);
}
return $key;
}
/**
* @return array<int, string>|false
*/
@@ -230,33 +266,6 @@ class Advanced extends Component
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
public function toggleRegistration($password): bool
{
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
$this->settings->is_registration_enabled = $this->is_registration_enabled = true;
$this->settings->save();
$this->dispatch('success', 'Registration has been enabled.');
return true;
}
public function toggleTwoStepConfirmation($password): bool
{
$this->authorize('update', $this->settings);
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true;
$this->settings->save();
$this->dispatch('success', 'Two step confirmation has been disabled.');
return true;
}
public function render()
{
return view('livewire.settings.advanced');
+3 -1
View File
@@ -141,7 +141,9 @@ class Index extends Component
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
if (! validateDNSEntry($this->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<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.");
$target = serverDnsTargetIp($this->server) ?? $this->server->ip;
$guidance = dnsMismatchGuidanceMessage($target, $target);
$this->dispatch('error', "Validating DNS failed.<br><br>{$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.");
$error_show = true;
}
}
+34 -2
View File
@@ -28,6 +28,10 @@ class ScheduledJobs extends Component
public string $filterDate = 'last_24h';
public string $search = '';
public string $sortOrder = 'newest';
public int $skipPage = 0;
public int $skipDefaultTake = 20;
@@ -76,6 +80,16 @@ class ScheduledJobs extends Component
$this->loadData();
}
public function updatedSearch(): void
{
$this->loadData();
}
public function updatedSortOrder(): void
{
$this->loadData();
}
public function skipNextPage(): void
{
$this->skipPage += $this->skipDefaultTake;
@@ -241,8 +255,26 @@ class ScheduledJobs extends Component
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
}
return $backups->concat($tasks)->concat($cleanups)
->sortByDesc('created_at')
$executions = $backups->concat($tasks)->concat($cleanups);
if (filled($this->search)) {
$search = str($this->search)->lower()->trim()->toString();
$executions = $executions->filter(function (array $execution) use ($search): bool {
return collect([
$execution['type'],
$execution['resource_name'],
$execution['resource_type'],
$execution['server_name'],
$execution['message'],
])->filter()->contains(
fn ($value): bool => str((string) $value)->lower()->contains($search)
);
});
}
return ($this->sortOrder === 'oldest'
? $executions->sortBy('created_at')
: $executions->sortByDesc('created_at'))
->values()
->take(100);
}
+10
View File
@@ -143,6 +143,16 @@ class SettingsEmail extends Component
}
}
public function instantSaveSmtp(): void
{
$this->instantSave('SMTP');
}
public function instantSaveResend(): void
{
$this->instantSave('Resend');
}
public function submitSmtp()
{
try {
+2
View File
@@ -353,6 +353,8 @@ class Change extends Component
$this->activeTab = 'permissions';
} elseif ($routeName === 'source.github.resources') {
$this->activeTab = 'resources';
} elseif ($routeName === 'source.github.danger') {
$this->activeTab = 'danger';
} else {
$this->activeTab = 'general';
}
@@ -73,4 +73,9 @@ class PricingPlans extends Component
return redirect($session->url, 303);
}
public function render()
{
return view('livewire.subscription.pricing-plans');
}
}
+34 -2
View File
@@ -40,14 +40,26 @@ class Show extends Component
public function mount()
{
try {
$this->tags = Tag::ownedByCurrentTeam()->get()->unique('name')->sortBy('name');
$this->tags = Tag::ownedByCurrentTeam()
->withCount(['applications', 'services'])
->get()
->unique('name')
->sortBy('name')
->values();
if (str($this->tagName)->isNotEmpty()) {
$tag = $this->tags->where('name', $this->tagName)->first();
if (! $tag) {
return redirect()->route('tags.show');
}
$this->webhook = generateTagDeployWebhook($tag->name);
$this->applications = $tag->applications()->get();
$this->services = $tag->services()->get();
$this->tag = $tag;
$this->getDeployments();
} else {
$this->deploymentsPerTagPerServer = [];
}
} catch (\Exception $e) {
return handleError($e, $this);
@@ -57,6 +69,12 @@ class Show extends Component
public function getDeployments()
{
try {
if (! $this->applications) {
$this->deploymentsPerTagPerServer = [];
return;
}
$resource_ids = $this->applications->pluck('id');
$this->deploymentsPerTagPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('application_id', $resource_ids)->get([
'id',
@@ -99,6 +117,20 @@ class Show extends Component
public function render()
{
return view('livewire.tags.show');
return view('livewire.tags.show', [
'tagsJs' => ($this->tags ?? collect())->map(function (Tag $tag): array {
$applicationsCount = (int) data_get($tag, 'applications_count', 0);
$servicesCount = (int) data_get($tag, 'services_count', 0);
return [
'id' => $tag->id,
'name' => $tag->name,
'href' => route('tags.show', ['tagName' => $tag->name]),
'applicationsCount' => $applicationsCount,
'servicesCount' => $servicesCount,
'resourceCount' => $applicationsCount + $servicesCount,
];
})->values()->toArray(),
]);
}
}
+48 -27
View File
@@ -4,55 +4,51 @@ namespace App\Livewire\Team;
use App\Models\User;
use Livewire\Component;
use Livewire\WithPagination;
class AdminView extends Component
{
public $users;
use WithPagination;
public ?string $search = '';
public string $search = '';
public bool $lots_of_users = false;
public string $teamFilter = 'all';
private $number_of_users_to_show = 20;
public string $sort = 'name_asc';
public function mount()
{
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->getUsers();
}
public function submitSearch()
public function updatedSearch(): void
{
if (! isInstanceAdmin()) {
return;
}
if ($this->search !== '') {
$this->users = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
})->get()->filter(function ($user) {
return $user->id !== auth()->id();
});
} else {
$this->getUsers();
}
$this->resetPage();
}
public function getUsers()
public function updatedTeamFilter(): void
{
$this->resetPage();
}
public function updatedSort(): void
{
$this->resetPage();
}
public function submitSearch(): void
{
if (! isInstanceAdmin()) {
return;
}
$users = User::where('id', '!=', auth()->id())->get();
if ($users->count() > $this->number_of_users_to_show) {
$this->lots_of_users = true;
$this->users = $users->take($this->number_of_users_to_show);
} else {
$this->lots_of_users = false;
$this->users = $users;
}
$this->resetPage();
}
public function delete($id, $password, $selectedActions = [])
@@ -76,7 +72,7 @@ class AdminView extends Component
try {
$user->delete();
$this->getUsers();
$this->resetPage();
return true;
} catch (\Exception $e) {
@@ -86,6 +82,31 @@ class AdminView extends Component
public function render()
{
return view('livewire.team.admin-view');
$search = trim($this->search);
$teamId = currentTeam()->id;
$users = User::query()
->where('id', '!=', auth()->id())
->when($search !== '', function ($query) use ($search): void {
$query->where(function ($query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->when($this->teamFilter === 'current', function ($query) use ($teamId): void {
$query->whereHas('teams', fn ($teamQuery) => $teamQuery->where('teams.id', $teamId));
})
->when($this->teamFilter === 'outside', function ($query) use ($teamId): void {
$query->whereDoesntHave('teams', fn ($teamQuery) => $teamQuery->where('teams.id', $teamId));
})
->when($this->sort === 'name_desc', fn ($query) => $query->orderByDesc('name'))
->when($this->sort === 'email_asc', fn ($query) => $query->orderBy('email'))
->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email'))
->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name'))
->orderBy('id')
->paginate(10);
return view('livewire.team.admin-view', [
'users' => $users,
]);
}
}
+1
View File
@@ -33,6 +33,7 @@ class Create extends Component
'name' => $this->name,
'description' => $this->description,
'personal_team' => false,
'is_mcp_server_enabled' => true,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession($team);
+3 -1
View File
@@ -66,7 +66,9 @@ class Index extends Component
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
$this->description = $this->team->description;
$this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled;
// Null can appear after Team::create() when the DB default is not
// hydrated onto the in-memory model stored in session.
$this->is_mcp_server_enabled = (bool) ($this->team->is_mcp_server_enabled ?? true);
}
}
+93 -5
View File
@@ -181,6 +181,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'docker_compose_domains',
'domain_dns_statuses',
'docker_compose_custom_start_command',
'docker_compose_custom_build_command',
'swarm_replicas',
@@ -233,6 +234,7 @@ class Application extends BaseModel
'docker_compose',
'docker_compose_raw',
'custom_labels',
'domain_dns_statuses',
];
protected function casts(): array
@@ -243,6 +245,7 @@ class Application extends BaseModel
'manual_webhook_secret_gitlab' => 'encrypted',
'manual_webhook_secret_bitbucket' => 'encrypted',
'manual_webhook_secret_gitea' => 'encrypted',
'domain_dns_statuses' => 'array',
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -1097,16 +1100,101 @@ class Application extends BaseModel
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get();
}
public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null)
{
$deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc');
/**
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
*/
public function deployments(
int $skip = 0,
int $take = 10,
?string $pullRequestId = null,
?string $search = null,
string $filter = 'all',
string $sort = 'newest',
): array {
$deployments = ApplicationDeploymentQueue::query()
->where('application_id', $this->id);
if ($pullRequestId) {
$deployments = $deployments->where('pull_request_id', $pullRequestId);
$deployments->where('pull_request_id', $pullRequestId);
}
$search = trim((string) $search);
if ($search !== '') {
$normalizedSearch = Str::lower($search);
$statusAliases = [
'success' => ApplicationDeploymentStatus::FINISHED->value,
'in progress' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'cancelled' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
];
$deployments->where(function ($query) use ($search, $normalizedSearch, $statusAliases) {
$query
->whereLike('deployment_uuid', "%{$search}%")
->orWhereLike('commit', "%{$search}%")
->orWhereLike('commit_message', "%{$search}%")
->orWhereLike('server_name', "%{$search}%")
->orWhereLike('status', "%{$search}%");
if (isset($statusAliases[$normalizedSearch])) {
$query->orWhere('status', $statusAliases[$normalizedSearch]);
}
if (is_numeric($search) && (int) $search > 0) {
$query->orWhere('pull_request_id', (int) $search);
}
match ($normalizedSearch) {
'pull request', 'pull requests', 'pr' => $query->orWhere('pull_request_id', '>', 0),
'webhook', 'webhooks' => $query->orWhere('is_webhook', true),
'rollback', 'rollbacks' => $query->orWhere('rollback', true),
'api' => $query->orWhere('is_api', true),
'manual' => $query->orWhere(function ($sourceQuery) {
$sourceQuery
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false);
}),
default => null,
};
});
}
if (Str::startsWith($filter, 'status:')) {
$deployments->where('status', Str::after($filter, 'status:'));
}
if (Str::startsWith($filter, 'source:')) {
match (Str::after($filter, 'source:')) {
'pull-request' => $deployments->where('pull_request_id', '>', 0),
'webhook' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', true),
'rollback' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', true),
'api' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', true),
'manual' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false),
default => null,
};
}
$count = $deployments->count();
$deployments = $deployments->skip($skip)->take($take)->get();
$deployments = $deployments
->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc')
->orderBy('id', $sort === 'oldest' ? 'asc' : 'desc')
->skip($skip)
->take($take)
->get();
return [
'count' => $count,
+3
View File
@@ -33,6 +33,7 @@ class InstanceSettings extends Model
'resend_api_key',
'is_dns_validation_enabled',
'custom_dns_servers',
'domain_connect_private_key',
'instance_name',
'is_api_enabled',
'allowed_ips',
@@ -58,6 +59,7 @@ class InstanceSettings extends Model
'smtp_username',
'smtp_password',
'resend_api_key',
'domain_connect_private_key',
'sentinel_token',
];
@@ -74,6 +76,7 @@ class InstanceSettings extends Model
'resend_enabled' => 'boolean',
'resend_api_key' => 'encrypted',
'domain_connect_private_key' => 'encrypted',
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
+18
View File
@@ -17,6 +17,8 @@ class ServiceApplication extends BaseModel
'human_name',
'description',
'fqdn',
'redirect',
'domain_dns_statuses',
'ports',
'exposes',
'status',
@@ -31,6 +33,22 @@ class ServiceApplication extends BaseModel
'is_migrated',
];
/**
* Internal DNS check cache not part of the public API surface.
*
* @var list<string>
*/
protected $hidden = [
'domain_dns_statuses',
];
protected function casts(): array
{
return [
'domain_dns_statuses' => 'array',
];
}
protected static function booted()
{
static::deleting(function ($service) {
+5 -1
View File
@@ -8,8 +8,8 @@ use App\Jobs\V5TeardownTeamJob;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Support\V5\V5Feature;
use App\Notifications\Channels\SendsSlack;
use App\Support\V5\V5Feature;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -52,6 +52,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'is_mcp_server_enabled',
];
protected $attributes = [
'is_mcp_server_enabled' => true,
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
-5
View File
@@ -13,17 +13,12 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
use Laravel\Telescope\TelescopeServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
if (App::isLocal()) {
$this->app->register(TelescopeServiceProvider::class);
}
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
}
@@ -1,67 +0,0 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Telescope::night();
$this->hideSensitiveRequestDetails();
$isLocal = $this->app->environment('local');
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
return $isLocal ||
$entry->isReportableException() ||
$entry->isFailedRequest() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->hasMonitoredTag();
});
}
/**
* Prevent sensitive request details from being logged by Telescope.
*/
protected function hideSensitiveRequestDetails(): void
{
if ($this->app->environment('local')) {
return;
}
Telescope::hideRequestParameters(['_token']);
Telescope::hideRequestHeaders([
'cookie',
'x-csrf-token',
'x-xsrf-token',
]);
}
/**
* Register the Telescope gate.
*
* This gate determines who can access Telescope in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewTelescope', function ($user) {
$root_user = User::find(0);
return in_array($user->email, [
$root_user->email,
]);
});
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Support;
/**
* Provider-agnostic DNS records to point hostnames at a Coolify server.
*/
class DnsRecordHints
{
/**
* Build A/AAAA entries for every hostname (deduped).
*
* @param array<int, string|null> $hostnames
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forHostnames(array $hostnames, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$seen = [];
foreach ($hostnames as $hostname) {
if (! is_string($hostname) || trim($hostname) === '') {
continue;
}
foreach (self::forTarget($hostname, $ipv4, $ipv6) as $record) {
$key = strtolower($record['type'].'|'.$record['name'].'|'.$record['value']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$records[] = $record;
}
}
usort($records, function (array $a, array $b): int {
return [$a['name'], $a['type'], $a['value']] <=> [$b['name'], $b['type'], $b['value']];
});
return $records;
}
/**
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forTarget(?string $hostname, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return [];
}
if (filled($ipv4) && filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$records[] = [
'type' => 'A',
'name' => $fqdn,
'value' => $ipv4,
];
}
if (filled($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$records[] = [
'type' => 'AAAA',
'name' => $fqdn,
'value' => $ipv6,
];
}
return $records;
}
public static function normalizeHostname(?string $hostname): ?string
{
if (blank($hostname)) {
return null;
}
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
if ($hostname === '' || $hostname === '@' || ! str_contains($hostname, '.')) {
return null;
}
// Strip path-like noise; host only.
if (filter_var($hostname, FILTER_VALIDATE_IP)) {
return null;
}
return $hostname;
}
/**
* Relative name for a zone (e.g. app for app.example.com, @ for example.com).
*/
public static function relativeName(?string $hostname): string
{
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return '@';
}
$labels = array_values(array_filter(explode('.', $fqdn), fn (string $p) => $p !== ''));
if (count($labels) <= 2) {
return '@';
}
return implode('.', array_slice($labels, 0, -2));
}
/**
* Plain-text block suitable for clipboard (type / name / value).
*
* @param array<int, array{type: string, name: string, value: string}> $records
*/
public static function toCopyText(array $records): string
{
if ($records === []) {
return '';
}
$lines = ["Type\tName\tValue"];
foreach ($records as $record) {
$lines[] = "{$record['type']}\t{$record['name']}\t{$record['value']}";
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,338 @@
<?php
namespace App\Support\DomainConnect;
use InvalidArgumentException;
use RuntimeException;
/**
* Builds Cloudflare Domain Connect synchronous "apply template" URLs.
*
* @see https://developers.cloudflare.com/dns/reference/domain-connect/
* @see https://github.com/Domain-Connect/spec/blob/master/Domain%20Connect%20Spec%20Draft.adoc
*/
class CloudflareDomainConnect
{
public const CLOUDFLARE_SYNC_UX = 'https://dash.cloudflare.com/domainconnect';
public function isConfigured(): bool
{
return filled($this->privateKeyPem());
}
/**
* Domain Connect is a Coolify Cloud feature and requires a signing key
* (instance setting or env) to be present at runtime.
*/
public function isAvailable(): bool
{
return isCloud() && $this->isConfigured();
}
public function providerId(): string
{
return (string) config('services.domain_connect.provider_id', 'coolify.io');
}
public function serviceId(): string
{
return (string) config('services.domain_connect.service_id', 'hosting');
}
public function keyId(): string
{
return (string) config('services.domain_connect.key_id', '_dcpubkeyv1');
}
/**
* @param array<string, string|null> $variables Template variables (e.g. ip). Null values become empty strings.
*/
public function buildApplyUrl(
string $domain,
array $variables = [],
?string $host = null,
?string $redirectUri = null,
): string {
if (! $this->isAvailable()) {
throw new RuntimeException(
'Domain Connect is only available on Coolify Cloud when a Domain Connect private key is configured.'
);
}
$domain = strtolower(trim($domain));
$domain = preg_replace('#^https?://#', '', $domain) ?? $domain;
$domain = rtrim(explode('/', $domain)[0] ?? $domain, '.');
if ($domain === '' || ! str_contains($domain, '.')) {
throw new InvalidArgumentException('A valid domain is required for Cloudflare Domain Connect.');
}
$host = $host === null ? '' : strtolower(trim($host));
$host = rtrim($host, '.');
if ($host === '@') {
$host = '';
}
// Signature covers the query string excluding `key` and `sig` (Domain Connect spec).
$params = [
'domain' => $domain,
'host' => $host,
];
foreach ($variables as $name => $value) {
if ($name === 'domain' || $name === 'host' || $name === 'key' || $name === 'sig') {
continue;
}
$params[$name] = $value === null ? '' : (string) $value;
}
if (filled($redirectUri)) {
$params['redirect_uri'] = $redirectUri;
}
$queryToSign = $this->buildQueryString($params);
$signature = $this->sign($queryToSign);
// Cloudflare requires `sig` as the last query parameter; `key` is also required.
$finalQuery = $queryToSign
.'&key='.rawurlencode($this->keyId())
.'&sig='.rawurlencode($signature);
return self::CLOUDFLARE_SYNC_UX
.'/v2/domainTemplates/providers/'.rawurlencode($this->providerId())
.'/services/'.rawurlencode($this->serviceId())
.'/apply?'.$finalQuery;
}
/**
* Point a zone (and optional host) A record at the Coolify server IP.
*/
public function buildHostingApplyUrl(
string $domain,
string $ip,
?string $host = null,
?string $redirectUri = null,
): string {
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
throw new InvalidArgumentException('A valid IPv4 or IPv6 address is required for DNS autoconfigure.');
}
return $this->buildApplyUrl(
domain: $domain,
variables: ['ip' => $ip],
host: $host,
redirectUri: $redirectUri,
);
}
/**
* Split a hostname into its registrable domain and relative host.
*
* Uses a small multi-part public-suffix set for common cases (e.g. co.uk)
* instead of shipping the full Mozilla Public Suffix List (~300KB).
* Unknown multi-part TLDs fall back to last-two-labels; Cloudflare will
* still match the user's zone when the guess is close enough.
*
* @return array{domain: string, host: string}
*/
public static function splitHostname(string $hostname): array
{
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
$labels = array_values(array_filter(explode('.', $hostname), fn (string $p) => $p !== ''));
if (count($labels) < 2) {
throw new InvalidArgumentException('Hostname must include a domain (e.g. example.com or app.example.com).');
}
$suffixLabelCount = self::publicSuffixLabelCount($labels);
$registrableLabelCount = $suffixLabelCount + 1;
if (count($labels) < $registrableLabelCount) {
throw new InvalidArgumentException('Hostname must include a registrable domain.');
}
$domain = implode('.', array_slice($labels, -$registrableLabelCount));
$hostLabels = array_slice($labels, 0, -$registrableLabelCount);
$host = implode('.', $hostLabels);
return ['domain' => $domain, 'host' => $host];
}
/**
* How many trailing labels form the public suffix (effective TLD).
*
* @param list<string> $labels
*/
protected static function publicSuffixLabelCount(array $labels): int
{
$count = count($labels);
// Longest multi-part match first (up to 3 labels: e.g. com.au, co.uk).
for ($length = min(3, $count - 1); $length >= 2; $length--) {
$candidate = implode('.', array_slice($labels, -$length));
if (isset(self::MULTI_PART_PUBLIC_SUFFIXES[$candidate])) {
return $length;
}
}
return 1;
}
/**
* Common multi-label public suffixes. Keys only; values unused.
* Not exhaustive covers frequent Domain Connect / Cloudflare zones.
*
* @var array<string, true>
*/
private const MULTI_PART_PUBLIC_SUFFIXES = [
// United Kingdom / related
'ac.uk' => true,
'co.uk' => true,
'gov.uk' => true,
'ltd.uk' => true,
'me.uk' => true,
'net.uk' => true,
'org.uk' => true,
'plc.uk' => true,
'sch.uk' => true,
// Australia
'com.au' => true,
'net.au' => true,
'org.au' => true,
'edu.au' => true,
'gov.au' => true,
'asn.au' => true,
'id.au' => true,
// New Zealand
'co.nz' => true,
'net.nz' => true,
'org.nz' => true,
'govt.nz' => true,
'ac.nz' => true,
// Japan
'co.jp' => true,
'or.jp' => true,
'ne.jp' => true,
'ac.jp' => true,
'go.jp' => true,
// Brazil
'com.br' => true,
'net.br' => true,
'org.br' => true,
'gov.br' => true,
// India
'co.in' => true,
'net.in' => true,
'org.in' => true,
'gen.in' => true,
'firm.in' => true,
'ind.in' => true,
// South Africa
'co.za' => true,
'org.za' => true,
'web.za' => true,
'net.za' => true,
// Mexico / LatAm
'com.mx' => true,
'org.mx' => true,
'gob.mx' => true,
'com.ar' => true,
'com.co' => true,
'com.pe' => true,
'com.cl' => true,
// Asia / others
'com.cn' => true,
'net.cn' => true,
'org.cn' => true,
'com.hk' => true,
'com.sg' => true,
'com.tw' => true,
'com.my' => true,
'com.ph' => true,
'com.tr' => true,
'com.ua' => true,
'com.pl' => true,
'com.ru' => true,
'co.kr' => true,
'co.il' => true,
'com.sa' => true,
'com.eg' => true,
'com.ng' => true,
// EU-style
'co.at' => true,
'or.at' => true,
'co.nl' => true,
'com.de' => true,
// Platforms sometimes used as zones
'github.io' => true,
'pages.dev' => true,
];
/**
* @param array<string, string> $params
*/
protected function buildQueryString(array $params): string
{
$parts = [];
foreach ($params as $key => $value) {
// Always include empty values (Resend sends host= for apex).
$parts[] = rawurlencode((string) $key).'='.rawurlencode($value);
}
return implode('&', $parts);
}
protected function sign(string $queryString): string
{
$privateKey = openssl_pkey_get_private($this->privateKeyPem());
if ($privateKey === false) {
throw new RuntimeException('Invalid Domain Connect private key.');
}
$signature = '';
$ok = openssl_sign($queryString, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if (! $ok) {
throw new RuntimeException('Failed to sign Domain Connect apply URL.');
}
return base64_encode($signature);
}
protected function privateKeyPem(): ?string
{
$key = null;
try {
$settingsKey = data_get(instanceSettings(), 'domain_connect_private_key');
if (is_string($settingsKey) && trim($settingsKey) !== '') {
$key = $settingsKey;
}
} catch (\Throwable) {
// Instance settings may be unavailable during early boot/tests.
}
if ($key === null) {
$envKey = config('services.domain_connect.private_key');
if (is_string($envKey) && trim($envKey) !== '') {
$key = $envKey;
}
}
if ($key === null) {
return null;
}
$key = str_replace(["\r\n", "\r"], "\n", $key);
// Allow single-line env values with literal \n
if (! str_contains($key, "\n") && str_contains($key, '\\n')) {
$key = str_replace('\\n', "\n", $key);
}
return $key;
}
}
+1 -5
View File
@@ -27,7 +27,7 @@ class Checkbox extends Component
public string|bool $instantSave = false,
public bool $live = false,
public bool $disabled = false,
public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base',
public string $defaultClass = 'peer absolute inset-0 z-10 m-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed',
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
@@ -41,10 +41,6 @@ class Checkbox extends Component
$this->instantSave = false; // Disable instant save for unauthorized users
}
}
if ($this->disabled) {
$this->defaultClass .= ' opacity-40';
}
}
/**
+4 -2
View File
@@ -73,8 +73,10 @@ class EnvVarInput extends Component
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
$this->scopeUrls = [
+4 -2
View File
@@ -68,8 +68,10 @@ class Input extends Component
if (is_null($this->name)) {
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
// $this->label = Str::title($this->label);
+15
View File
@@ -5,6 +5,21 @@ use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Support\Collection;
/**
* Count domains in a comma-separated FQDN string (e.g. "https://a.com,https://b.com").
*/
function countDomains(?string $fqdn): int
{
if (! filled($fqdn)) {
return 0;
}
return collect(explode(',', $fqdn))
->map(fn ($d) => trim($d))
->filter()
->count();
}
function isValidDomainUrl(string $url): bool
{
$components = parse_url($url);
+23 -8
View File
@@ -1340,6 +1340,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($isPullRequest) {
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
$composeRedirect = data_get($domains, "$changedServiceName.redirect");
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -1351,7 +1355,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image
image: $image,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -1365,7 +1370,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort
predefinedPort: $predefinedPort,
redirect_direction: $redirectDirection
));
break;
}
@@ -1378,7 +1384,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image
image: $image,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $labelNetwork,
@@ -1390,7 +1397,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort
predefinedPort: $predefinedPort,
redirect_direction: $redirectDirection
));
}
}
@@ -2599,6 +2607,9 @@ function serviceParser(Service $resource): Collection
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($originalResource, 'redirect')
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2610,7 +2621,8 @@ function serviceParser(Service $resource): Collection
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image
image: $image,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -2624,7 +2636,8 @@ function serviceParser(Service $resource): Collection
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort
predefinedPort: $predefinedPort,
redirect_direction: $redirectDirection
));
break;
}
@@ -2637,7 +2650,8 @@ function serviceParser(Service $resource): Collection
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image
image: $image,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
@@ -2649,7 +2663,8 @@ function serviceParser(Service $resource): Collection
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
predefinedPort: $predefinedPort
predefinedPort: $predefinedPort,
redirect_direction: $redirectDirection
));
}
}
+325 -22
View File
@@ -1316,23 +1316,92 @@ function sslip(Server $server)
return "http://{$server->ip}.sslip.io";
}
function service_templates_cache_key(): string
{
return (string) config('constants.services.cache_key', 'coolify:service-templates-bundle');
}
function service_templates_path(): string
{
return base_path('templates/'.config('constants.services.file_name'));
}
/**
* Persist the CDN service-templates bundle to local disk and shared cache.
*
* The shared cache entry is what multi-node Cloud relies on: Horizon (or any
* single node) pulls once; every HTTP node reads the same Redis payload.
*/
function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool
{
$fetchedAt ??= now()->toIso8601String();
$path = service_templates_path();
$written = File::put($path, $json) !== false;
Cache::forever(service_templates_cache_key(), [
'fetched_at' => $fetchedAt,
'json' => $json,
]);
return $written;
}
function get_service_templates_fetched_at(): ?CarbonImmutable
{
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && filled(data_get($bundle, 'fetched_at'))) {
try {
return CarbonImmutable::parse((string) data_get($bundle, 'fetched_at'));
} catch (Throwable) {
// fall through to local file mtime
}
}
$path = service_templates_path();
if (File::exists($path)) {
$mtime = filemtime($path);
if ($mtime !== false) {
return CarbonImmutable::createFromTimestamp($mtime);
}
}
return null;
}
function get_service_templates(bool $force = false): Collection
{
if ($force) {
try {
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->failed()) {
return collect([]);
}
$services = $response->json();
store_service_templates_bundle($response->body());
return collect($services);
return collect(json_decode($response->body()))->sortKeys();
} catch (Throwable) {
return get_service_templates();
}
}
$path = base_path('templates/'.config('constants.services.file_name'));
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && is_string(data_get($bundle, 'json')) && data_get($bundle, 'json') !== '') {
$fetchedAt = (string) data_get($bundle, 'fetched_at', '0');
return Cache::remember("service-templates:shared:{$fetchedAt}", now()->addDay(), function () use ($bundle) {
return collect(json_decode((string) data_get($bundle, 'json')))->sortKeys();
});
}
$path = service_templates_path();
if (! File::exists($path)) {
return collect([]);
}
$mtime = filemtime($path) ?: 0;
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
@@ -1799,11 +1868,188 @@ function getRealtime()
}
}
/**
* Resolve a server IP or hostname to an IP address for DNS comparison/display.
*
* When a server uses a hostname (e.g. coolify-testing-host) instead of a literal
* IP, A-record validation and UI copy need the actual IP the user should point DNS to.
*
* Successful hostname resolutions are cached briefly so Livewire mounts/domain state
* loads do not re-query DNS on every request.
*
* @return array{ip: ?string, configured: ?string, resolved_from_hostname: bool}
*/
function resolveServerIpAddress(?string $ipOrHost): array
{
$configured = filled($ipOrHost) ? trim((string) $ipOrHost) : null;
if ($configured === null || $configured === '') {
return [
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
];
}
// Strip IPv6 brackets if present: [2001:db8::1]
$candidate = str($configured)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $candidate,
'configured' => $configured,
'resolved_from_hostname' => false,
];
}
$cacheKey = 'server-ip-resolve:'.mb_strtolower($candidate);
$cachedIp = Cache::get($cacheKey);
if (is_string($cachedIp) && filter_var($cachedIp, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $cachedIp,
'configured' => $configured,
'resolved_from_hostname' => true,
];
}
$resolvedIp = null;
try {
$aRecords = @dns_get_record($candidate, DNS_A);
if (is_array($aRecords)) {
foreach ($aRecords as $record) {
$ip = $record['ip'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
if ($resolvedIp === null) {
try {
$aaaaRecords = @dns_get_record($candidate, DNS_AAAA);
if (is_array($aaaaRecords)) {
foreach ($aaaaRecords as $record) {
$ip = $record['ipv6'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
}
if ($resolvedIp === null) {
$resolved = @gethostbyname($candidate);
if (is_string($resolved) && $resolved !== $candidate && filter_var($resolved, FILTER_VALIDATE_IP) !== false) {
$resolvedIp = $resolved;
}
}
if ($resolvedIp !== null) {
Cache::put($cacheKey, $resolvedIp, now()->addSeconds(60));
}
return [
'ip' => $resolvedIp,
'configured' => $configured,
'resolved_from_hostname' => $resolvedIp !== null,
];
}
/**
* Preferred server address for DNS validation: public instance IPs for localhost,
* otherwise the server IP/hostname resolved to a real IP when possible.
*/
function serverDnsTargetIp(Server $server): ?string
{
$settings = instanceSettings();
if ($server->id === 0) {
$configured = data_get($settings, 'public_ipv4')
?: data_get($settings, 'public_ipv6')
?: $server->ip;
} else {
$configured = $server->ip;
}
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
// Prefer resolved IP; fall back to configured value so existing hostname-based
// comparisons still have something to show if DNS resolution fails.
return $resolved['ip'] ?? $resolved['configured'];
}
/**
* DNS record type users should create for a server address: A (IPv4) or AAAA (IPv6).
*/
function dnsRecordTypeForIp(?string $ipOrHost): string
{
if (! is_string($ipOrHost) || trim($ipOrHost) === '') {
return 'A';
}
// Accept labels like "2001:db8::1 (hostname)" or bracketed IPv6.
$candidate = trim($ipOrHost);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
return 'AAAA';
}
return 'A';
}
/**
* Bare address for DNS guidance (first token of a label, brackets stripped).
*/
function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string
{
if (! is_string($ipOrLabel) || trim($ipOrLabel) === '') {
return null;
}
$candidate = trim($ipOrLabel);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
return $candidate !== '' ? $candidate : null;
}
/**
* User-facing guidance when a hostname does not resolve to the server.
* Format: "A record → 1.2.3.4" or "AAAA record → 2001:db8::1".
*
* @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback.
* @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel).
*/
function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordType = null): string
{
$address = dnsGuidanceTargetAddress($ipForRecordType)
?? dnsGuidanceTargetAddress($targetLabel);
if ($address === null) {
return 'DNS validation failed. Check your DNS records.';
}
$recordType = dnsRecordTypeForIp($address);
return "{$recordType} record → {$address}";
}
function validateDNSEntry(string $fqdn, Server $server)
{
// https://www.cloudflare.com/ips-v4/#
$cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
$url = Url::fromString($fqdn);
$host = $url->getHost();
if (str($host)->contains('sslip.io')) {
@@ -1816,13 +2062,9 @@ function validateDNSEntry(string $fqdn, Server $server)
}
$dns_servers = data_get($settings, 'custom_dns_servers');
$dns_servers = str($dns_servers)->explode(',');
if ($server->id === 0) {
$ip = data_get($settings, 'public_ipv4', data_get($settings, 'public_ipv6', $server->ip));
} else {
$ip = $server->ip;
}
$ip = serverDnsTargetIp($server);
$found_matching_ip = false;
$type = DNSTypes::NAME_A;
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
foreach ($dns_servers as $dns_server) {
try {
$query = new DNSQuery($dns_server);
@@ -1831,11 +2073,11 @@ function validateDNSEntry(string $fqdn, Server $server)
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
if (isCloudflareIp($result->getData())) {
$found_matching_ip = true;
break;
}
if ($result->getData() === $ip) {
if ($ip && $result->getData() === $ip) {
$found_matching_ip = true;
break;
}
@@ -1849,11 +2091,48 @@ function validateDNSEntry(string $fqdn, Server $server)
return $found_matching_ip;
}
function isCloudflareIp(string $ip): bool
{
// https://www.cloudflare.com/ips/
$cloudflareIps = [
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32',
'2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29',
'2c0f:f248::/32',
];
return ipMatch($ip, $cloudflareIps);
}
function ipMatch($ip, $cidrs, &$match = null)
{
foreach ((array) $cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) {
[$subnet, $prefixLength] = explode('/', $cidr);
$packedIp = inet_pton($ip);
$packedSubnet = inet_pton($subnet);
if ($packedIp === false || $packedSubnet === false || strlen($packedIp) !== strlen($packedSubnet)) {
continue;
}
$addressBits = strlen($packedIp) * 8;
$prefixLength = (int) $prefixLength;
if ($prefixLength < 0 || $prefixLength > $addressBits) {
continue;
}
$fullBytes = intdiv($prefixLength, 8);
$remainingBits = $prefixLength % 8;
$matches = substr($packedIp, 0, $fullBytes) === substr($packedSubnet, 0, $fullBytes);
if ($matches && $remainingBits > 0) {
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
$matches = (ord($packedIp[$fullBytes]) & $mask) === (ord($packedSubnet[$fullBytes]) & $mask);
}
if ($matches) {
$match = $cidr;
return true;
@@ -2758,6 +3037,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if (! $isDatabase && $fqdns->count() > 0) {
if ($fqdns) {
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($savedService, 'redirect')
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($resource->server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2769,7 +3051,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image')
image: data_get($service, 'image'),
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -2782,7 +3065,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image')
image: data_get($service, 'image'),
redirect_direction: $redirectDirection
));
break;
}
@@ -2795,7 +3079,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image')
image: data_get($service, 'image'),
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $resource->destination->network,
@@ -2806,7 +3091,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image')
image: data_get($service, 'image'),
redirect_direction: $redirectDirection
));
}
}
@@ -3533,6 +3819,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
}
$shouldGenerateLabelsExactly = $server->settings->generate_exact_labels;
$composeRedirect = data_get($domains, "$serviceName.redirect");
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -3546,6 +3836,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_force_https_enabled: $resource->isForceHttpsEnabled(),
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
redirect_direction: $redirectDirection,
)
);
break;
@@ -3560,6 +3851,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_force_https_enabled: $resource->isForceHttpsEnabled(),
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
redirect_direction: $redirectDirection,
)
);
break;
@@ -3575,6 +3867,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_force_https_enabled: $resource->isForceHttpsEnabled(),
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
redirect_direction: $redirectDirection,
)
);
$serviceLabels = $serviceLabels->merge(
@@ -3587,6 +3880,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_force_https_enabled: $resource->isForceHttpsEnabled(),
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
redirect_direction: $redirectDirection,
)
);
}
@@ -3857,8 +4151,17 @@ function loggy($message = null, array $context = [])
return app('log')->debug($message, $context);
}
function sslipDomainWarning(string $domains)
/**
* Warn when any domain uses HTTPS with an sslip hostname.
*
* Empty/null domain lists are valid (domains removed) and produce no warning.
*/
function sslipDomainWarning(?string $domains): bool
{
if (blank($domains)) {
return false;
}
$domains = str($domains)->trim()->explode(',');
$showSslipHttpsWarning = false;
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
+7 -2
View File
@@ -1,6 +1,8 @@
<?php
use App\Models\Team;
use Stripe\BillingPortal\Session;
use Stripe\Customer;
use Stripe\Stripe;
function isSubscriptionActive()
@@ -65,7 +67,7 @@ function getStripeCustomerPortalSession(Team $team)
return null;
}
return \Stripe\BillingPortal\Session::create([
return Session::create([
'customer' => $stripe_customer_id,
'return_url' => $return_url,
]);
@@ -80,6 +82,9 @@ function allowedPathsForUnsubscribedAccounts()
'two-factor-challenge',
'livewire/update',
'admin',
// Account basics stay available without a paid plan.
'profile',
'profile/appearance',
];
}
function allowedPathsForBoardingAccounts()
@@ -114,7 +119,7 @@ function updateStripeCustomerEmail(Team $team, string $newEmail): void
Stripe::setApiKey(config('subscription.stripe_api_key'));
\Stripe\Customer::update(
Customer::update(
$stripe_customer_id,
['email' => $newEmail]
);
+1082
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -61,13 +61,11 @@
"zircote/swagger-php": "^5.8.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.16.5",
"driftingly/rector-laravel": "^2.1.9",
"fakerphp/faker": "^1.24.1",
"laravel/boost": "^2.1",
"laravel/dusk": "^8.3.4",
"laravel/pint": "^1.27",
"laravel/telescope": "^5.16.1",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.8.3",
"pestphp/pest": "^4.3.2",
@@ -104,13 +102,6 @@
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": [
"laravel/telescope"
]
}
},
"scripts": {
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
Generated
+2 -230
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3bc1d41688bd98785b556172ae2410ed",
"content-hash": "69407ef96f7081245b664c24115fd382",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -13075,91 +13075,6 @@
],
"time": "2025-08-24T17:25:34+00:00"
},
{
"name": "barryvdh/laravel-debugbar",
"version": "v3.16.5",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/laravel-debugbar.git",
"reference": "e85c0a8464da67e5b4a53a42796d46a43fc06c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/e85c0a8464da67e5b4a53a42796d46a43fc06c9a",
"reference": "e85c0a8464da67e5b4a53a42796d46a43fc06c9a",
"shasum": ""
},
"require": {
"illuminate/routing": "^10|^11|^12",
"illuminate/session": "^10|^11|^12",
"illuminate/support": "^10|^11|^12",
"php": "^8.1",
"php-debugbar/php-debugbar": "^2.2.4",
"symfony/finder": "^6|^7|^8"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"orchestra/testbench-dusk": "^7|^8|^9|^10",
"phpunit/phpunit": "^9.5.10|^10|^11",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar"
},
"providers": [
"Barryvdh\\Debugbar\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.16-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Barryvdh\\Debugbar\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "PHP Debugbar integration for Laravel",
"keywords": [
"debug",
"debugbar",
"dev",
"laravel",
"profiler",
"webprofiler"
],
"support": {
"issues": "https://github.com/fruitcake/laravel-debugbar/issues",
"source": "https://github.com/fruitcake/laravel-debugbar/tree/v3.16.5"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-01-23T15:03:22+00:00"
},
{
"name": "brianium/paratest",
"version": "v7.20.0",
@@ -14051,75 +13966,6 @@
},
"time": "2026-03-05T07:58:43+00:00"
},
{
"name": "laravel/telescope",
"version": "v5.20.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
"reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"php": "^8.0",
"symfony/console": "^5.3|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"laravel/octane": "^1.4|^2.0",
"orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Telescope\\TelescopeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Telescope\\": "src/",
"Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mohamed Said",
"email": "mohamed@laravel.com"
}
],
"description": "An elegant debug assistant for the Laravel framework.",
"keywords": [
"debugging",
"laravel",
"monitoring"
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
"source": "https://github.com/laravel/telescope/tree/v5.20.0"
},
"time": "2026-04-06T12:52:26+00:00"
},
{
"name": "league/uri-components",
"version": "7.8.1",
@@ -15035,80 +14881,6 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "php-debugbar/php-debugbar",
"version": "v2.2.6",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/php-debugbar.git",
"reference": "abb9fa3c5c8dbe7efe03ddba56782917481de3e8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/abb9fa3c5c8dbe7efe03ddba56782917481de3e8",
"reference": "abb9fa3c5c8dbe7efe03ddba56782917481de3e8",
"shasum": ""
},
"require": {
"php": "^8.1",
"psr/log": "^1|^2|^3",
"symfony/var-dumper": "^5.4|^6.4|^7.3|^8.0"
},
"replace": {
"maximebf/debugbar": "self.version"
},
"require-dev": {
"dbrekelmans/bdi": "^1",
"phpunit/phpunit": "^10",
"symfony/browser-kit": "^6.0|7.0",
"symfony/panther": "^1|^2.1",
"twig/twig": "^3.11.2"
},
"suggest": {
"kriswallsmith/assetic": "The best way to manage assets",
"monolog/monolog": "Log using Monolog",
"predis/predis": "Redis storage"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.2-dev"
}
},
"autoload": {
"psr-4": {
"DebugBar\\": "src/DebugBar/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Bouroumeau-Fuseau",
"email": "maxime.bouroumeau@gmail.com",
"homepage": "http://maximebf.com"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Debug bar in the browser for php application",
"homepage": "https://github.com/php-debugbar/php-debugbar",
"keywords": [
"debug",
"debug bar",
"debugbar",
"dev"
],
"support": {
"issues": "https://github.com/php-debugbar/php-debugbar/issues",
"source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.6"
},
"time": "2025-12-22T13:21:32+00:00"
},
{
"name": "php-webdriver/webdriver",
"version": "1.16.0",
@@ -17489,5 +17261,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
+81 -29
View File
@@ -1,6 +1,35 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\ConfigurationServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\AuthServiceProvider;
use Illuminate\Auth\Passwords\PasswordResetServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
use Illuminate\Bus\BusServiceProvider;
use Illuminate\Cache\CacheServiceProvider;
use Illuminate\Cookie\CookieServiceProvider;
use Illuminate\Database\DatabaseServiceProvider;
use Illuminate\Encryption\EncryptionServiceProvider;
use Illuminate\Filesystem\FilesystemServiceProvider;
use Illuminate\Foundation\Providers\ConsoleSupportServiceProvider;
use Illuminate\Foundation\Providers\FoundationServiceProvider;
use Illuminate\Hashing\HashServiceProvider;
use Illuminate\Mail\MailServiceProvider;
use Illuminate\Notifications\NotificationServiceProvider;
use Illuminate\Pagination\PaginationServiceProvider;
use Illuminate\Pipeline\PipelineServiceProvider;
use Illuminate\Queue\QueueServiceProvider;
use Illuminate\Redis\RedisServiceProvider;
use Illuminate\Session\SessionServiceProvider;
use Illuminate\Support\Facades\Facade;
use Illuminate\Translation\TranslationServiceProvider;
use Illuminate\Validation\ValidationServiceProvider;
use Illuminate\View\ViewServiceProvider;
use SocialiteProviders\Manager\ServiceProvider;
return [
@@ -45,6 +74,29 @@ return [
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Server-Timing Headers + on-screen HUD
|--------------------------------------------------------------------------
|
| When true: W3C Server-Timing + X-Debug-* headers, and an on-screen HUD
| on full HTML responses (request log, AI-ready copy dump).
|
| Default: on only when APP_ENV=local (SERVER_TIMING_ENABLED unset).
| Override anywhere (including production) with:
| SERVER_TIMING_ENABLED=true # force on
| SERVER_TIMING_ENABLED=false # force off (even in local)
|
| Production note: this exposes timing, query counts, memory, HTML size,
| and a visible debug HUD to clients. Enable only temporarily for debugging,
| then set false or remove the variable and reload config if cached.
|
*/
'server_timing' => env('SERVER_TIMING_ENABLED') !== null
? filter_var(env('SERVER_TIMING_ENABLED'), FILTER_VALIDATE_BOOLEAN)
: env('APP_ENV', 'production') === 'local',
/*
|--------------------------------------------------------------------------
| Application URL
@@ -161,45 +213,45 @@ return [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
AuthServiceProvider::class,
BroadcastServiceProvider::class,
BusServiceProvider::class,
CacheServiceProvider::class,
ConsoleSupportServiceProvider::class,
CookieServiceProvider::class,
DatabaseServiceProvider::class,
EncryptionServiceProvider::class,
FilesystemServiceProvider::class,
FoundationServiceProvider::class,
HashServiceProvider::class,
MailServiceProvider::class,
NotificationServiceProvider::class,
PaginationServiceProvider::class,
PipelineServiceProvider::class,
QueueServiceProvider::class,
RedisServiceProvider::class,
PasswordResetServiceProvider::class,
SessionServiceProvider::class,
TranslationServiceProvider::class,
ValidationServiceProvider::class,
ViewServiceProvider::class,
/*
* Package Service Providers...
*/
\SocialiteProviders\Manager\ServiceProvider::class,
ServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
AppServiceProvider::class,
FortifyServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\ConfigurationServiceProvider::class,
EventServiceProvider::class,
HorizonServiceProvider::class,
RouteServiceProvider::class,
ConfigurationServiceProvider::class,
],
/*
+2
View File
@@ -27,6 +27,8 @@ return [
'services' => [
'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json',
'file_name' => 'service-templates-latest.json',
// Shared across HTTP/Horizon nodes when CACHE_DRIVER is redis (default).
'cache_key' => 'coolify:service-templates-bundle',
],
'terminal' => [
-326
View File
@@ -1,326 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Debugbar Settings
|--------------------------------------------------------------------------
|
| Debugbar is enabled by default, when debug is set to true in app.php.
| You can override the value by setting enable to true or false instead of null.
|
| You can provide an array of URI's that must be ignored (eg. 'api/*')
|
*/
'enabled' => env('DEBUGBAR_ENABLED', null),
'except' => [
'telescope*',
'horizon*',
'api*',
],
/*
|--------------------------------------------------------------------------
| Storage settings
|--------------------------------------------------------------------------
|
| DebugBar stores data for session/ajax requests.
| You can disable this, so the debugbar stores data in headers/session,
| but this can cause problems with large data collectors.
| By default, file storage (in the storage folder) is used. Redis and PDO
| can also be used. For PDO, run the package migrations first.
|
| Warning: Enabling storage.open will allow everyone to access previous
| request, do not enable open storage in publicly available environments!
| Specify a callback if you want to limit based on IP or authentication.
| Leaving it to null will allow localhost only.
*/
'storage' => [
'enabled' => true,
'open' => env('DEBUGBAR_OPEN_STORAGE'), // bool/callback.
'driver' => 'file', // redis, file, pdo, socket, custom
'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)
'provider' => '', // Instance of StorageInterface for custom driver
'hostname' => '127.0.0.1', // Hostname to use with the "socket" driver
'port' => 2304, // Port to use with the "socket" driver
],
/*
|--------------------------------------------------------------------------
| Editor
|--------------------------------------------------------------------------
|
| Choose your preferred editor to use when clicking file name.
|
| Supported: "phpstorm", "vscode", "vscode-insiders", "vscode-remote",
| "vscode-insiders-remote", "vscodium", "textmate", "emacs",
| "sublime", "atom", "nova", "macvim", "idea", "netbeans",
| "xdebug", "espresso"
|
*/
'editor' => env('DEBUGBAR_EDITOR') ?: env('IGNITION_EDITOR', 'phpstorm'),
/*
|--------------------------------------------------------------------------
| Remote Path Mapping
|--------------------------------------------------------------------------
|
| If you are using a remote dev server, like Laravel Homestead, Docker, or
| even a remote VPS, it will be necessary to specify your path mapping.
|
| Leaving one, or both of these, empty or null will not trigger the remote
| URL changes and Debugbar will treat your editor links as local files.
|
| "remote_sites_path" is an absolute base path for your sites or projects
| in Homestead, Vagrant, Docker, or another remote development server.
|
| Example value: "/home/vagrant/Code"
|
| "local_sites_path" is an absolute base path for your sites or projects
| on your local computer where your IDE or code editor is running on.
|
| Example values: "/Users/<name>/Code", "C:\Users\<name>\Documents\Code"
|
*/
'remote_sites_path' => env('DEBUGBAR_REMOTE_SITES_PATH'),
'local_sites_path' => env('DEBUGBAR_LOCAL_SITES_PATH', env('IGNITION_LOCAL_SITES_PATH')),
/*
|--------------------------------------------------------------------------
| Vendors
|--------------------------------------------------------------------------
|
| Vendor files are included by default, but can be set to false.
| This can also be set to 'js' or 'css', to only include javascript or css vendor files.
| Vendor files are for css: font-awesome (including fonts) and highlight.js (css files)
| and for js: jquery and highlight.js
| So if you want syntax highlighting, set it to true.
| jQuery is set to not conflict with existing jQuery scripts.
|
*/
'include_vendors' => true,
/*
|--------------------------------------------------------------------------
| Capture Ajax Requests
|--------------------------------------------------------------------------
|
| The Debugbar can capture Ajax requests and display them. If you don't want this (ie. because of errors),
| you can use this option to disable sending the data through the headers.
|
| Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools.
|
| Note for your request to be identified as ajax requests they must either send the header
| X-Requested-With with the value XMLHttpRequest (most JS libraries send this), or have application/json as a Accept header.
|
| By default `ajax_handler_auto_show` is set to true allowing ajax requests to be shown automatically in the Debugbar.
| Changing `ajax_handler_auto_show` to false will prevent the Debugbar from reloading.
*/
'capture_ajax' => true,
'add_ajax_timing' => false,
'ajax_handler_auto_show' => true,
'ajax_handler_enable_tab' => true,
/*
|--------------------------------------------------------------------------
| Custom Error Handler for Deprecated warnings
|--------------------------------------------------------------------------
|
| When enabled, the Debugbar shows deprecated warnings for Symfony components
| in the Messages tab.
|
*/
'error_handler' => false,
/*
|--------------------------------------------------------------------------
| Clockwork integration
|--------------------------------------------------------------------------
|
| The Debugbar can emulate the Clockwork headers, so you can use the Chrome
| Extension, without the server-side code. It uses Debugbar collectors instead.
|
*/
'clockwork' => false,
/*
|--------------------------------------------------------------------------
| DataCollectors
|--------------------------------------------------------------------------
|
| Enable/disable DataCollectors
|
*/
'collectors' => [
'phpinfo' => true, // Php version
'messages' => true, // Messages
'time' => true, // Time Datalogger
'memory' => true, // Memory usage
'exceptions' => true, // Exception displayer
'log' => true, // Logs from Monolog (merged in messages if enabled)
'db' => true, // Show database (PDO) queries and bindings
'views' => true, // Views with their data
'route' => true, // Current route information
'auth' => false, // Display Laravel authentication status
'gate' => true, // Display Laravel Gate checks
'session' => true, // Display session data
'symfony_request' => true, // Only one can be enabled..
'mail' => true, // Catch mail messages
'laravel' => false, // Laravel version and environment
'events' => false, // All events fired
'default_request' => false, // Regular or special Symfony request logger
'logs' => false, // Add the latest log messages
'files' => false, // Show the included files
'config' => false, // Display config settings
'cache' => false, // Display cache events
'models' => true, // Display models
'livewire' => true, // Display Livewire (when available)
'jobs' => false, // Display dispatched jobs
],
/*
|--------------------------------------------------------------------------
| Extra options
|--------------------------------------------------------------------------
|
| Configure some DataCollectors
|
*/
'options' => [
'time' => [
'memory_usage' => false, // Calculated by subtracting memory start and end, it may be inaccurate
],
'messages' => [
'trace' => true, // Trace the origin of the debug message
],
'memory' => [
'reset_peak' => false, // run memory_reset_peak_usage before collecting
'with_baseline' => false, // Set boot memory usage as memory peak baseline
'precision' => 0, // Memory rounding precision
],
'auth' => [
'show_name' => true, // Also show the users name/email in the debugbar
'show_guards' => true, // Show the guards that are used
],
'db' => [
'with_params' => true, // Render SQL with the parameters substituted
'backtrace' => true, // Use a backtrace to find the origin of the query in your files.
'backtrace_exclude_paths' => [], // Paths to exclude from backtrace. (in addition to defaults)
'timeline' => false, // Add the queries to the timeline
'duration_background' => true, // Show shaded background on each query relative to how long it took to execute.
'explain' => [ // Show EXPLAIN output on queries
'enabled' => false,
'types' => ['SELECT'], // Deprecated setting, is always only SELECT
],
'hints' => false, // Show hints for common mistakes
'show_copy' => false, // Show copy button next to the query,
'slow_threshold' => false, // Only track queries that last longer than this time in ms
'memory_usage' => false, // Show queries memory usage
'soft_limit' => 100, // After the soft limit, no parameters/backtrace are captured
'hard_limit' => 500, // After the hard limit, queries are ignored
],
'mail' => [
'timeline' => false, // Add mails to the timeline
'show_body' => true,
],
'views' => [
'timeline' => false, // Add the views to the timeline (Experimental)
'data' => false, // true for all data, 'keys' for only names, false for no parameters.
'group' => 50, // Group duplicate views. Pass value to auto-group, or true/false to force
'exclude_paths' => [ // Add the paths which you don't want to appear in the views
'vendor/filament', // Exclude Filament components by default
],
],
'route' => [
'label' => true, // show complete route on bar
],
'session' => [
'hiddens' => [], // hides sensitive values using array paths
],
'symfony_request' => [
'hiddens' => [], // hides sensitive values using array paths, example: request_request.password
],
'events' => [
'data' => false, // collect events data, listeners
],
'logs' => [
'file' => null,
],
'cache' => [
'values' => true, // collect cache values
],
],
/*
|--------------------------------------------------------------------------
| Inject Debugbar in Response
|--------------------------------------------------------------------------
|
| Usually, the debugbar is added just before </body>, by listening to the
| Response after the App is done. If you disable this, you have to add them
| in your template yourself. See http://phpdebugbar.com/docs/rendering.html
|
*/
'inject' => true,
/*
|--------------------------------------------------------------------------
| DebugBar route prefix
|--------------------------------------------------------------------------
|
| Sometimes you want to set route prefix to be used by DebugBar to load
| its resources from. Usually the need comes from misconfigured web server or
| from trying to overcome bugs like this: http://trac.nginx.org/nginx/ticket/97
|
*/
'route_prefix' => '_debugbar',
/*
|--------------------------------------------------------------------------
| DebugBar route middleware
|--------------------------------------------------------------------------
|
| Additional middleware to run on the Debugbar routes
*/
'route_middleware' => [],
/*
|--------------------------------------------------------------------------
| DebugBar route domain
|--------------------------------------------------------------------------
|
| By default DebugBar route served from the same domain that request served.
| To override default domain, specify it as a non-empty value.
*/
'route_domain' => null,
/*
|--------------------------------------------------------------------------
| DebugBar theme
|--------------------------------------------------------------------------
|
| Switches between light and dark theme. If set to auto it will respect system preferences
| Possible values: auto, light, dark
*/
'theme' => env('DEBUGBAR_THEME', 'auto'),
/*
|--------------------------------------------------------------------------
| Backtrace stack limit
|--------------------------------------------------------------------------
|
| By default, the DebugBar limits the number of frames returned by the 'debug_backtrace()' function.
| If you need larger stacktraces, you can increase this number. Setting it to 0 will result in no limit.
*/
'debug_backtrace_limit' => 50,
];
+13
View File
@@ -67,4 +67,17 @@ return [
'base_url' => env('ZITADEL_BASE_URL'),
],
/*
| Domain Connect (Cloudflare automatic DNS).
| Template: resources/domain-connect/coolify.io.hosting.json
| Publish public key TXT at {key_id}.{syncPubKeyDomain} and onboard with Cloudflare.
| @see https://developers.cloudflare.com/dns/reference/domain-connect/
*/
'domain_connect' => [
'provider_id' => env('DOMAIN_CONNECT_PROVIDER_ID', 'coolify.io'),
'service_id' => env('DOMAIN_CONNECT_SERVICE_ID', 'hosting'),
'key_id' => env('DOMAIN_CONNECT_KEY_ID', '_dcpubkeyv1'),
'private_key' => env('DOMAIN_CONNECT_PRIVATE_KEY'),
],
];
-205
View File
@@ -1,205 +0,0 @@
<?php
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/*
|--------------------------------------------------------------------------
| Telescope Master Switch
|--------------------------------------------------------------------------
|
| This option may be used to disable all Telescope watchers regardless
| of their individual configuration, which simply provides a single
| and convenient way to enable or disable Telescope data storage.
|
*/
'enabled' => env('TELESCOPE_ENABLED', false),
/*
|--------------------------------------------------------------------------
| Telescope Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Telescope will be accessible from. If the
| setting is null, Telescope will reside under the same domain as the
| application. Otherwise, this value will be used as the subdomain.
|
*/
'domain' => env('TELESCOPE_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Telescope Path
|--------------------------------------------------------------------------
|
| This is the URI path where Telescope will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('TELESCOPE_PATH', 'telescope'),
/*
|--------------------------------------------------------------------------
| Telescope Storage Driver
|--------------------------------------------------------------------------
|
| This configuration options determines the storage driver that will
| be used to store Telescope's data. In addition, you may set any
| custom options as needed by the particular driver you choose.
|
*/
'driver' => env('TELESCOPE_DRIVER', 'database'),
'storage' => [
'database' => [
'connection' => env('DB_CONNECTION', 'pgsql'),
'chunk' => 1000,
],
],
/*
|--------------------------------------------------------------------------
| Telescope Queue
|--------------------------------------------------------------------------
|
| This configuration options determines the queue connection and queue
| which will be used to process ProcessPendingUpdate jobs. This can
| be changed if you would prefer to use a non-default connection.
|
*/
'queue' => [
'connection' => env('TELESCOPE_QUEUE_CONNECTION', 'redis'),
'queue' => env('TELESCOPE_QUEUE', 'default'),
],
/*
|--------------------------------------------------------------------------
| Telescope Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will be assigned to every Telescope route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => [
'web',
Authorize::class,
],
/*
|--------------------------------------------------------------------------
| Allowed / Ignored Paths & Commands
|--------------------------------------------------------------------------
|
| The following array lists the URI paths and Artisan commands that will
| not be watched by Telescope. In addition to this list, some Laravel
| commands, like migrations and queue commands, are always ignored.
|
*/
'only_paths' => [
// 'api/*'
],
'ignore_paths' => [
'livewire*',
'nova-api*',
'pulse*',
],
'ignore_commands' => [
//
],
/*
|--------------------------------------------------------------------------
| Telescope Watchers
|--------------------------------------------------------------------------
|
| The following array lists the "watchers" that will be registered with
| Telescope. The watchers gather the application's profile data when
| a request or task is executed. Feel free to customize this list.
|
*/
'watchers' => [
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
Watchers\CacheWatcher::class => [
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
'hidden' => [],
],
Watchers\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
Watchers\CommandWatcher::class => [
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
'ignore' => [],
],
Watchers\DumpWatcher::class => [
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
],
Watchers\EventWatcher::class => [
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
'ignore' => [],
],
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
Watchers\GateWatcher::class => [
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
'ignore_abilities' => [],
'ignore_packages' => true,
'ignore_paths' => [],
],
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
Watchers\LogWatcher::class => [
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
'level' => 'error',
],
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
Watchers\ModelWatcher::class => [
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
'events' => ['eloquent.*'],
'hydrations' => true,
],
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'ignore_packages' => true,
'ignore_paths' => [],
'slow' => 100,
],
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
'ignore_http_methods' => [],
'ignore_status_codes' => [],
],
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
],
];
+2 -1
View File
@@ -6,7 +6,7 @@ use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Team>
* @extends Factory<Team>
*/
class TeamFactory extends Factory
{
@@ -24,6 +24,7 @@ class TeamFactory extends Factory
'description' => $this->faker->sentence(),
'personal_team' => false,
'show_boarding' => false,
'is_mcp_server_enabled' => true,
];
}
@@ -1,70 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
$schema = Schema::connection($this->getConnection());
$schema->create('telescope_entries', function (Blueprint $table) {
$table->bigIncrements('sequence');
$table->uuid('uuid');
$table->uuid('batch_id');
$table->string('family_hash')->nullable();
$table->boolean('should_display_on_index')->default(true);
$table->string('type', 20);
$table->longText('content');
$table->dateTime('created_at')->nullable();
$table->unique('uuid');
$table->index('batch_id');
$table->index('family_hash');
$table->index('created_at');
$table->index(['type', 'should_display_on_index']);
});
$schema->create('telescope_entries_tags', function (Blueprint $table) {
$table->uuid('entry_uuid');
$table->string('tag');
$table->primary(['entry_uuid', 'tag']);
$table->index('tag');
$table->foreign('entry_uuid')
->references('uuid')
->on('telescope_entries')
->onDelete('cascade');
});
$schema->create('telescope_monitoring', function (Blueprint $table) {
$table->string('tag')->primary();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$schema = Schema::connection($this->getConnection());
$schema->dropIfExists('telescope_entries_tags');
$schema->dropIfExists('telescope_entries');
$schema->dropIfExists('telescope_monitoring');
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->json('domain_dns_statuses')->nullable()->after('docker_compose_domains');
});
}
public function down(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('domain_dns_statuses');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->json('domain_dns_statuses')->nullable()->after('fqdn');
});
}
public function down(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->dropColumn('domain_dns_statuses');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->enum('redirect', ['www', 'non-www', 'both'])->default('both')->after('fqdn');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->dropColumn('redirect');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->text('domain_connect_private_key')->nullable()->after('custom_dns_servers');
});
}
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('domain_connect_private_key');
});
}
};
-24
View File
@@ -1280,25 +1280,8 @@ CREATE TABLE IF NOT EXISTS "telegram_notification_settings" (
"traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL
);
CREATE TABLE IF NOT EXISTS "telescope_entries" (
"sequence" INTEGER NOT NULL,
"uuid" TEXT NOT NULL,
"batch_id" TEXT NOT NULL,
"family_hash" TEXT,
"should_display_on_index" INTEGER DEFAULT true NOT NULL,
"type" TEXT NOT NULL,
"content" TEXT NOT NULL,
"created_at" TEXT
);
CREATE TABLE IF NOT EXISTS "telescope_entries_tags" (
"entry_uuid" TEXT NOT NULL,
"tag" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "telescope_monitoring" (
"tag" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "user_changelog_reads" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
@@ -1572,12 +1555,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_team_id_email_unique" ON "te
CREATE UNIQUE INDEX IF NOT EXISTS "team_invitations_uuid_unique" ON "team_invitations" (uuid);
CREATE UNIQUE INDEX IF NOT EXISTS "team_user_team_id_user_id_unique" ON "team_user" (team_id, user_id);
CREATE UNIQUE INDEX IF NOT EXISTS "telegram_notification_settings_team_id_unique" ON "telegram_notification_settings" (team_id);
CREATE INDEX IF NOT EXISTS "telescope_entries_batch_id_index" ON "telescope_entries" (batch_id);
CREATE INDEX IF NOT EXISTS "telescope_entries_created_at_index" ON "telescope_entries" (created_at);
CREATE INDEX IF NOT EXISTS "telescope_entries_family_hash_index" ON "telescope_entries" (family_hash);
CREATE INDEX IF NOT EXISTS "telescope_entries_type_should_display_on_index_index" ON "telescope_entries" (type, should_display_on_index);
CREATE UNIQUE INDEX IF NOT EXISTS "telescope_entries_uuid_unique" ON "telescope_entries" (uuid);
CREATE INDEX IF NOT EXISTS "telescope_entries_tags_tag_index" ON "telescope_entries_tags" (tag);
CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_changelog_reads" (release_tag);
CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag);
@@ -1593,7 +1570,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique"
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (1, '2014_10_12_000000_create_users_table', 1);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (2, '2014_10_12_100000_create_password_reset_tokens_table', 2);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (3, '2014_10_12_200000_add_two_factor_columns_to_users_table', 3);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (4, '2018_08_08_100000_create_telescope_entries_table', 4);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (5, '2019_12_14_000001_create_personal_access_tokens_table', 5);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (6, '2023_03_20_112410_create_activity_log_table', 6);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (7, '2023_03_20_112411_add_event_column_to_activity_log_table', 7);
+4 -3
View File
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.2.0"
"version": "4.1.2"
},
"nightly": {
"version": "4.2.0"
@@ -17,13 +17,14 @@
}
},
"traefik": {
"v3.6": "3.6.11",
"v3.7": "3.7.8",
"v3.6": "3.6.23",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.40"
"v2.11": "2.11.52"
}
}
-1
View File
@@ -24,7 +24,6 @@
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- The v5 bootstrap endpoint refuses to queue without a Flux URL; tests
that exercise the unconfigured path blank it via Config::set. -->
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-5
View File
@@ -1,5 +0,0 @@
{
"/app.js": "/app.js?id=6a9d3c0fef12c9aadd063e3a357cf7cf",
"/app-dark.css": "/app-dark.css?id=1ea407db56c5163ae29311f1f38eb7b9",
"/app.css": "/app.css?id=de4c978567bfd90b38d186937dee5ccf"
}
+2994 -10
View File
File diff suppressed because it is too large Load Diff
+59 -23
View File
@@ -59,11 +59,11 @@
/* input, select before */
@utility input-select {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 dark:bg-coolgray-100 dark:text-white disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40;
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
@apply block h-9 px-3 py-1.5 w-full text-sm text-black rounded-md border border-neutral-200 bg-white dark:bg-surface dark:text-fg dark:border-white/[0.08] transition-colors disabled:bg-neutral-100 disabled:text-neutral-400 dark:disabled:bg-white/[0.03] dark:disabled:text-fg-faint;
box-shadow: none;
&:where(.dark, .dark *) {
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
box-shadow: none;
}
&:disabled {
@@ -82,11 +82,13 @@
@apply focus-visible:outline-none;
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:read-only {
@@ -113,20 +115,37 @@
}
&:focus-visible {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
&:where(.dark, .dark *):focus-visible {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
}
@utility button {
@apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-neutral-200 dark:disabled:border-coolgray-300 disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base;
@apply inline-flex gap-1.5 justify-center items-center px-2.5 h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
}
/* Compact icon-only control (gear, chevrons, etc.) */
@utility icon-button {
@apply inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-400 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-35 dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg;
}
/* Hide static icons while a Livewire loading spinner is shown on the button. */
.button.is-loading > svg:not(.animate-spin) {
display: none;
}
/* Compact resource tab pill */
@utility app-tab {
@apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors;
}
@utility auth-tooltip {
@apply fixed z-[99] px-2.5 py-1.5 text-xs rounded-sm pointer-events-none whitespace-nowrap text-neutral-700 bg-neutral-200 dark:text-neutral-300 dark:bg-coolgray-400;
@apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
}
@utility alert-success {
@@ -145,6 +164,10 @@
@apply flex items-center px-2 text-xs cursor-pointer dark:text-neutral-500/20 text-neutral-500 group-hover:text-neutral-700 dark:group-hover:text-white dark:hover:bg-coolgray-300 hover:bg-neutral-200;
}
@utility user-menu-item {
@apply flex items-center gap-2.5 px-3 h-8 w-full text-[13px] font-medium text-neutral-600 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.06] hover:text-black dark:hover:text-fg transition-colors cursor-pointer;
}
@utility dropdown-item {
@apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
}
@@ -182,10 +205,10 @@
}
@utility menu-item {
@apply flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0;
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
}
@utility menu-item-icon {
@apply shrink-0 size-4 dark:hover:text-white;
@apply shrink-0 size-[18px] opacity-90;
}
@utility menu-item-label {
@@ -193,15 +216,28 @@
}
@utility menu-item-active {
@apply text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden;
@apply overflow-visible rounded-none bg-transparent text-black hover:bg-transparent dark:bg-transparent dark:text-fg dark:hover:bg-transparent;
}
/* Subtle Title-Case section header */
@utility nav-section {
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-neutral-400 dark:text-fg-faint/80 select-none;
}
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
}
@utility menu-subitem-active {
@apply rounded-none bg-transparent text-black hover:bg-transparent dark:bg-transparent dark:text-fg dark:hover:bg-transparent;
}
@utility sub-menu-wrapper {
@apply flex flex-col items-start gap-2 min-w-40 sm:min-w-48 w-auto max-w-full sm:flex-shrink;
@apply flex flex-col gap-0.5 w-full md:w-52 shrink-0;
}
@utility sub-menu-item {
@apply flex gap-2 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0;
@apply relative flex gap-2 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg;
}
@utility sub-menu-item-icon {
@@ -217,7 +253,7 @@
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin;
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-warning dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
@@ -229,7 +265,7 @@
}
@utility navbar-main {
@apply flex flex-col gap-4 justify-items-start pb-2 border-b-2 border-solid h-fit md:flex-row sm:justify-between dark:border-coolgray-200 border-neutral-200 md:items-center text-neutral-700 dark:text-neutral-400;
@apply flex flex-col gap-4 justify-items-start pb-3 border-b border-solid h-fit md:flex-row sm:justify-between dark:border-white/[0.06] border-neutral-200 md:items-center text-neutral-700 dark:text-fg-dim;
}
@utility loading {
@@ -241,7 +277,7 @@
}
@utility box {
@apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm;
@apply relative flex lg:flex-row flex-col p-3 transition-colors cursor-pointer min-h-[4rem] bg-white dark:bg-surface border text-black dark:text-fg hover:text-black border-neutral-200 dark:border-white/[0.06] hover:bg-neutral-50 dark:hover:bg-raised dark:hover:border-white/[0.1] dark:hover:text-fg hover:no-underline rounded-md;
}
@utility box-boarding {
@@ -257,7 +293,7 @@
}
@utility coolbox {
@apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem];
@apply relative flex items-center transition-all duration-150 bg-white dark:bg-surface p-4 rounded-2xl border border-neutral-200 dark:border-white/[0.06] hover:border-neutral-300 dark:hover:border-white/[0.12] dark:hover:bg-raised hover:shadow-[0_4px_16px_rgba(0,0,0,0.35)] cursor-pointer min-h-[4.5rem];
}
@utility on-box {
@@ -265,11 +301,11 @@
}
@utility box-title {
@apply font-bold text-black dark:text-white dark:group-hover:text-white;
@apply font-semibold text-black dark:text-white dark:group-hover:text-white;
}
@utility box-description {
@apply text-xs font-bold text-neutral-500 dark:group-hover:text-white group-hover:text-black;
@apply text-xs font-medium text-neutral-500 dark:text-fg-faint dark:group-hover:text-fg-dim group-hover:text-black;
}
@utility description {
@@ -281,15 +317,15 @@
}
@utility text-helper {
@apply inline-block font-bold text-coollabs dark:text-warning;
@apply inline-block font-semibold text-coollabs dark:text-warning;
}
@utility info-helper {
@apply cursor-pointer text-coollabs dark:text-warning;
@apply cursor-pointer text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim;
}
@utility info-helper-popup {
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
@apply rounded-lg border border-neutral-200 bg-white text-neutral-600 shadow-modal whitespace-normal break-words dark:border-white/10 dark:bg-raised dark:text-fg-dim;
}
@utility buyme {
@@ -0,0 +1,20 @@
{
"providerId": "coolify.io",
"providerName": "Coolify",
"serviceId": "hosting",
"serviceName": "Coolify Hosting",
"version": 1,
"logoUrl": "https://coolify.io/coolify-transparent.png",
"description": "Point your domain to a Coolify-managed server via an A record.",
"variableDescription": "ip is the Coolify server IP. host is the subdomain relative to the zone (empty for apex).",
"syncPubKeyDomain": "domainconnect.coolify.io",
"syncBlock": false,
"records": [
{
"type": "A",
"host": "%host%",
"pointsTo": "%ip%",
"ttl": 3600
}
]
}
+4 -8
View File
@@ -8,11 +8,7 @@ document.addEventListener('livewire:navigated', () => {
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
});
['livewire:navigated', 'alpine:init'].forEach((event) => {
document.addEventListener(event, () => {
// tree-shaking
if (document.getElementById('terminal-container')) {
initializeTerminalComponent()
}
});
});
// Register the terminal data provider before Alpine initializes the page.
// Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
+479 -58
View File
@@ -10,6 +10,96 @@ import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
const baseApplicationTerminalTheme = {
black: '#675f70',
red: '#ef7272',
green: '#7bd88f',
yellow: '#e7bd68',
blue: '#85aacb',
magenta: '#c792ea',
cyan: '#72d5d0',
white: '#d8d2df',
brightBlack: '#8a8292',
brightRed: '#ff9b9b',
brightGreen: '#a5e7b2',
brightYellow: '#f2d596',
brightBlue: '#b0c8df',
brightMagenta: '#ddb3f4',
brightCyan: '#a7e8e4',
brightWhite: '#ffffff',
foreground: '#eee9f2',
background: '#00000000',
overviewRulerBorder: '#00000000',
};
function createApplicationTerminalTheme(accent, colors = {}) {
return {
...baseApplicationTerminalTheme,
cursor: accent,
cursorAccent: '#101012',
selectionBackground: `${accent}66`,
...colors,
};
}
const applicationTerminalThemes = {
'shadows-midnight': createApplicationTerminalTheme('#6d7a7c', {
blue: '#7392ad',
cyan: '#7fa3a6',
brightBlue: '#9bb4c9',
brightCyan: '#a8c4c6',
}),
'shadows-golden-hour': createApplicationTerminalTheme('#bf8c3c', {
yellow: '#d9a759',
red: '#df7756',
brightYellow: '#edc987',
brightRed: '#efa086',
}),
'shadows-cosmic-purple': createApplicationTerminalTheme('#A76DBE', {
blue: '#8f86d9',
magenta: '#c58ad8',
brightBlue: '#b1a9ed',
brightMagenta: '#ddb0e9',
}),
'shadows-neon-glow': createApplicationTerminalTheme('#DB425A', {
red: '#ed5d72',
magenta: '#f35fc2',
brightRed: '#ff8c9d',
brightMagenta: '#ff93d7',
}),
'shadows-icy-mist': createApplicationTerminalTheme('#93b7c4', {
blue: '#8fb7d0',
cyan: '#9acbd0',
brightBlue: '#b9d5e5',
brightCyan: '#c0e3e5',
}),
'shadows-tropical-storm': createApplicationTerminalTheme('#1fa771', {
green: '#45c98b',
cyan: '#4ec7ad',
brightGreen: '#7de0ad',
brightCyan: '#80dfcc',
}),
'shadows-golden-nebula': createApplicationTerminalTheme('#d4a20e', {
yellow: '#e5bb35',
red: '#ee755f',
blue: '#718fc1',
brightYellow: '#f4d375',
}),
'shadows-cosmic-lagoon': createApplicationTerminalTheme('#00b5b8', {
blue: '#668de0',
magenta: '#ba6ad0',
cyan: '#38c6c8',
brightCyan: '#76e0e2',
}),
'shadows-neon-nebula': createApplicationTerminalTheme('#ff55aa', {
blue: '#6f91dd',
magenta: '#ff72c1',
cyan: '#51d5d5',
brightMagenta: '#ffa1d4',
}),
'shadows-transparent': createApplicationTerminalTheme('#8C8E9C'),
};
function logTerminal(level, message, ...context) {
if (!terminalDebugEnabled) {
return;
@@ -59,11 +149,23 @@ export function initializeTerminalComponent() {
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
mobileToolbarCollapsed: false,
// Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting).
fullscreenAncestorPatches: null,
pageScrollLocked: false,
scrollLockY: 0,
scrollLockStyles: null,
preventPageScrollHandler: null,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
? localStorage.getItem('coolify-console-theme')
: 'shadows-cosmic-purple',
init() {
// Recover if a previous portal build left the terminal on <body>.
this.$nextTick(() => this.salvageStrayFullscreenNodes());
this.setupTerminal();
// Add a small delay for initial connection to ensure everything is ready
@@ -103,7 +205,9 @@ export function initializeTerminalComponent() {
this.resizeObserver.observe(this.$refs.terminalWrapper);
}
} else {
this.$refs.terminalWrapper.style.display = 'none';
const terminalElement = document.getElementById('terminal');
this.$refs.terminalWrapper.style.display =
terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none';
// Stop observing when terminal is inactive
if (this.resizeObserver) {
@@ -146,6 +250,8 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.pendingCommand = null;
this.resetTerminalSessionCountdown();
this.exitFullscreen();
this.unlockPageScroll();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@@ -232,6 +338,32 @@ export function initializeTerminalComponent() {
return 'text-neutral-300 bg-black/70 border-white/10';
},
setTerminalTheme(themeName) {
if (!applicationTerminalThemes[themeName]) {
return;
}
this.selectedTheme = themeName;
localStorage.setItem('coolify-console-theme', themeName);
if (this.term) {
const cursorBlink = this.term.options.cursorBlink;
this.term.options.cursorBlink = false;
this.term.options.theme = { ...applicationTerminalThemes[themeName] };
this.term.refresh(0, Math.max(0, this.term.rows - 1));
requestAnimationFrame(() => {
if (!this.term) {
return;
}
this.term.options.cursorBlink = cursorBlink;
this.term.refresh(0, Math.max(0, this.term.rows - 1));
this.term.focus();
});
}
},
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
@@ -265,14 +397,24 @@ export function initializeTerminalComponent() {
setupTerminal() {
const terminalElement = document.getElementById('terminal');
if (terminalElement) {
const isApplicationConsole = terminalElement.dataset.terminalStyle === 'application';
this.term = new Terminal({
cols: 80,
rows: 30,
fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
fontSize: isApplicationConsole ? 13 : 14,
fontWeight: isApplicationConsole ? 550 : 'normal',
fontWeightBold: 700,
lineHeight: isApplicationConsole ? 1.15 : 1,
cursorBlink: true,
cursorStyle: 'block',
rendererType: 'canvas',
convertEol: true,
disableStdin: false
disableStdin: false,
scrollback: 5000,
theme: isApplicationConsole
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes['shadows-cosmic-purple']
: undefined
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
@@ -527,7 +669,7 @@ export function initializeTerminalComponent() {
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.fullscreen = false;
this.exitFullscreen();
this.mobileToolbarCollapsed = false;
this.terminalActive = false;
this.resetTerminalSessionCountdown();
@@ -735,73 +877,352 @@ export function initializeTerminalComponent() {
},
makeFullscreen() {
this.fullscreen = !this.fullscreen;
this.$nextTick(() => {
// Force a layout reflow to ensure DOM changes are applied
this.$refs.terminalWrapper.offsetHeight;
if (this.fullscreen) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
},
// Add a small delay to ensure CSS transitions complete
setTimeout(() => {
/**
* Keep the terminal in-place (no document.body reparent). Livewire morphs
* recreate missing children when nodes leave the component tree, which left
* an empty console shell and dumped the real xterm below the page.
*
* Instead, neutralize ancestor isolation/transform/filter/overflow so
* position:fixed + z-index can cover the viewport above sidebar/top bar.
*/
enterFullscreen() {
const wrapper = this.$refs.terminalWrapper;
if (!wrapper || this.fullscreen) {
return;
}
this.salvageStrayFullscreenNodes();
this.patchAncestorsForFullscreen(wrapper);
this.lockPageScroll();
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
this.fullscreen = true;
document.documentElement.classList.add('terminal-is-fullscreen');
document.body.classList.add('terminal-is-fullscreen');
this.scheduleTerminalResize();
},
exitFullscreen() {
const wrapper = this.$refs.terminalWrapper;
this.restoreAncestorsAfterFullscreen();
this.unlockPageScroll();
this.fullscreen = false;
document.documentElement.classList.remove('terminal-is-fullscreen');
document.body.classList.remove('terminal-is-fullscreen');
if (wrapper) {
wrapper.style.removeProperty('display');
wrapper.style.removeProperty('height');
wrapper.style.removeProperty('min-height');
}
// Recover from older portal builds that left the terminal on <body>.
this.salvageStrayFullscreenNodes();
this.scheduleTerminalResize();
},
/**
* Freeze document scroll while fullscreen. Only xterm's own viewport may scroll.
* Uses position:fixed scroll-lock so nested overflow:visible ancestors cannot
* re-enable page scrolling under the overlay.
*/
lockPageScroll() {
if (this.pageScrollLocked) {
return;
}
this.pageScrollLocked = true;
this.scrollLockY = window.scrollY || document.documentElement.scrollTop || 0;
this.scrollLockStyles = {
htmlOverflow: document.documentElement.style.getPropertyValue('overflow'),
htmlOverscroll: document.documentElement.style.getPropertyValue('overscroll-behavior'),
bodyOverflow: document.body.style.getPropertyValue('overflow'),
bodyPosition: document.body.style.getPropertyValue('position'),
bodyTop: document.body.style.getPropertyValue('top'),
bodyLeft: document.body.style.getPropertyValue('left'),
bodyRight: document.body.style.getPropertyValue('right'),
bodyWidth: document.body.style.getPropertyValue('width'),
bodyPaddingRight: document.body.style.getPropertyValue('padding-right'),
bodyOverscroll: document.body.style.getPropertyValue('overscroll-behavior'),
};
const scrollbarGap = Math.max(0, window.innerWidth - document.documentElement.clientWidth);
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.documentElement.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overscroll-behavior', 'none', 'important');
document.body.style.setProperty('position', 'fixed', 'important');
document.body.style.setProperty('top', `-${this.scrollLockY}px`, 'important');
document.body.style.setProperty('left', '0', 'important');
document.body.style.setProperty('right', '0', 'important');
document.body.style.setProperty('width', '100%', 'important');
if (scrollbarGap > 0) {
document.body.style.setProperty('padding-right', `${scrollbarGap}px`, 'important');
}
this.preventPageScrollHandler = (event) => {
const target = event.target;
if (!(target instanceof Element)) {
event.preventDefault();
return;
}
// Allow terminal scrollback and mobile toolbar touches only.
if (
target.closest('.xterm-viewport') ||
target.closest('[data-terminal-mobile-toolbar]') ||
target.closest('.terminal-fullscreen-btn')
) {
return;
}
event.preventDefault();
};
window.addEventListener('wheel', this.preventPageScrollHandler, { passive: false });
window.addEventListener('touchmove', this.preventPageScrollHandler, { passive: false });
},
unlockPageScroll() {
if (!this.pageScrollLocked) {
return;
}
this.pageScrollLocked = false;
if (this.preventPageScrollHandler) {
window.removeEventListener('wheel', this.preventPageScrollHandler);
window.removeEventListener('touchmove', this.preventPageScrollHandler);
this.preventPageScrollHandler = null;
}
const restore = (el, prop, value) => {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
};
const styles = this.scrollLockStyles ?? {};
restore(document.documentElement, 'overflow', styles.htmlOverflow);
restore(document.documentElement, 'overscroll-behavior', styles.htmlOverscroll);
restore(document.body, 'overflow', styles.bodyOverflow);
restore(document.body, 'position', styles.bodyPosition);
restore(document.body, 'top', styles.bodyTop);
restore(document.body, 'left', styles.bodyLeft);
restore(document.body, 'right', styles.bodyRight);
restore(document.body, 'width', styles.bodyWidth);
restore(document.body, 'padding-right', styles.bodyPaddingRight);
restore(document.body, 'overscroll-behavior', styles.bodyOverscroll);
this.scrollLockStyles = null;
window.scrollTo(0, this.scrollLockY || 0);
this.scrollLockY = 0;
},
patchAncestorsForFullscreen(fromEl) {
this.restoreAncestorsAfterFullscreen();
this.fullscreenAncestorPatches = [];
let node = fromEl.parentElement;
while (node && node !== document.documentElement) {
this.fullscreenAncestorPatches.push({
el: node,
isolation: node.style.getPropertyValue('isolation'),
transform: node.style.getPropertyValue('transform'),
filter: node.style.getPropertyValue('filter'),
backdropFilter: node.style.getPropertyValue('backdrop-filter'),
contain: node.style.getPropertyValue('contain'),
overflow: node.style.getPropertyValue('overflow'),
overflowX: node.style.getPropertyValue('overflow-x'),
overflowY: node.style.getPropertyValue('overflow-y'),
willChange: node.style.getPropertyValue('will-change'),
perspective: node.style.getPropertyValue('perspective'),
zIndex: node.style.getPropertyValue('z-index'),
position: node.style.getPropertyValue('position'),
});
// Drop fixed-position containing blocks + nested stacking contexts.
// Critical: CSS classes like .application-console-block { z-index: 1 }
// trap position:fixed descendants under the sidebar (z-40) / top bar (z-50)
// unless z-index is forced back to auto on the whole ancestor chain.
node.style.setProperty('isolation', 'auto', 'important');
node.style.setProperty('transform', 'none', 'important');
node.style.setProperty('filter', 'none', 'important');
node.style.setProperty('backdrop-filter', 'none', 'important');
node.style.setProperty('contain', 'none', 'important');
node.style.setProperty('perspective', 'none', 'important');
node.style.setProperty('will-change', 'auto', 'important');
node.style.setProperty('overflow', 'visible', 'important');
node.style.setProperty('overflow-x', 'visible', 'important');
node.style.setProperty('overflow-y', 'visible', 'important');
node.style.setProperty('z-index', 'auto', 'important');
node = node.parentElement;
}
// Sidebar/top bar are layout siblings of <main>, not ancestors. Elevate
// main so the fullscreen stacking context paints above z-50 chrome.
const main = fromEl.closest('main');
if (main) {
const existing = this.fullscreenAncestorPatches.find((patch) => patch.el === main);
if (!existing) {
this.fullscreenAncestorPatches.push({
el: main,
isolation: main.style.getPropertyValue('isolation'),
transform: main.style.getPropertyValue('transform'),
filter: main.style.getPropertyValue('filter'),
backdropFilter: main.style.getPropertyValue('backdrop-filter'),
contain: main.style.getPropertyValue('contain'),
overflow: main.style.getPropertyValue('overflow'),
overflowX: main.style.getPropertyValue('overflow-x'),
overflowY: main.style.getPropertyValue('overflow-y'),
willChange: main.style.getPropertyValue('will-change'),
perspective: main.style.getPropertyValue('perspective'),
zIndex: main.style.getPropertyValue('z-index'),
position: main.style.getPropertyValue('position'),
});
}
if (getComputedStyle(main).position === 'static') {
main.style.setProperty('position', 'relative', 'important');
}
main.style.setProperty('z-index', '100001', 'important');
}
},
restoreAncestorsAfterFullscreen() {
if (!this.fullscreenAncestorPatches?.length) {
this.fullscreenAncestorPatches = null;
return;
}
for (const patch of this.fullscreenAncestorPatches) {
const el = patch.el;
if (!el?.style) {
continue;
}
const entries = [
['isolation', patch.isolation],
['transform', patch.transform],
['filter', patch.filter],
['backdrop-filter', patch.backdropFilter],
['contain', patch.contain],
['overflow', patch.overflow],
['overflow-x', patch.overflowX],
['overflow-y', patch.overflowY],
['will-change', patch.willChange],
['perspective', patch.perspective],
['z-index', patch.zIndex],
['position', patch.position],
];
for (const [prop, value] of entries) {
if (value) {
el.style.setProperty(prop, value);
} else {
el.style.removeProperty(prop);
}
}
}
this.fullscreenAncestorPatches = null;
},
/**
* Older fullscreen code reparented the wrapper to document.body. Livewire then
* recreated an empty shell in-place. Pull any stray terminal hosts back home.
*/
salvageStrayFullscreenNodes() {
const host = document.getElementById('terminal-container');
if (!host) {
return;
}
const wrapper = this.$refs.terminalWrapper;
if (wrapper && wrapper.parentElement === document.body) {
host.appendChild(wrapper);
}
document.querySelectorAll('body > .terminal-fullscreen-shell').forEach((node) => {
if (node === wrapper) {
host.appendChild(node);
return;
}
if (node.querySelector('#terminal') || node.id === 'terminal') {
host.appendChild(node);
} else {
node.remove();
}
});
},
scheduleTerminalResize() {
this.$nextTick(() => {
// Multi-pass fit: Alpine class swaps need a couple frames before the
// host has a stable clientHeight for FitAddon.
this.resizeTerminal();
requestAnimationFrame(() => {
this.resizeTerminal();
}, 100);
setTimeout(() => this.resizeTerminal(), 50);
setTimeout(() => this.resizeTerminal(), 150);
});
});
},
resizeTerminal() {
if (!this.terminalActive || !this.term || !this.fitAddon) return;
if (!this.terminalActive || !this.term || !this.fitAddon) {
return;
}
try {
// Force a refresh of the fit addon dimensions
const terminalElement = document.getElementById('terminal');
if (!terminalElement || !this.term.element) {
return;
}
// Host must already be laid out; otherwise FitAddon under-reads and
// later the canvas keeps a wrong pixel height (page stretches on exit).
if (terminalElement.clientHeight < 24 || terminalElement.clientWidth < 24) {
setTimeout(() => this.resizeTerminal(), 50);
return;
}
const previousCols = this.term.cols;
const previousRows = this.term.rows;
this.fitAddon.fit();
// Get fresh dimensions from the terminal element itself. The mobile
// toolbar can live beside the terminal in normal flow, so wrapper dimensions
// would include controls that should not be counted as terminal rows.
const terminalElement = document.getElementById('terminal');
const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
// Account for terminal container padding. In fullscreen mobile mode,
// the fixed toolbar sits over the terminal container, so reserve its height
// when calculating rows to keep the prompt above the controls.
const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
const height = terminalHeight - verticalPadding;
const width = terminalWidth - horizontalPadding;
// Check if dimensions are valid
if (height <= 0 || width <= 0) {
logTerminal('warn', '[Terminal] Invalid wrapper dimensions, retrying...', { height, width });
setTimeout(() => this.resizeTerminal(), 100);
return;
// Keep the xterm chrome inside the host so overflow becomes scrollback,
// not document growth after leaving fullscreen.
if (this.term.element) {
this.term.element.style.width = '100%';
this.term.element.style.height = '100%';
this.term.element.style.maxHeight = '100%';
}
const charSize = this.term._core._renderService._charSizeService;
if (!charSize.height || !charSize.width) {
// Fallback values if char size not available yet
logTerminal('warn', '[Terminal] Character size not available, retrying...');
setTimeout(() => this.resizeTerminal(), 100);
return;
}
// Calculate new dimensions with padding considerations
const rows = Math.floor(height / charSize.height) - 1;
const cols = Math.floor(width / charSize.width) - 1;
if (rows > 0 && cols > 0) {
// Check if dimensions actually changed to avoid unnecessary resizes
const currentCols = this.term.cols;
const currentRows = this.term.rows;
if (cols !== currentCols || rows !== currentRows) {
this.term.resize(cols, rows);
this.sendMessage({
resize: { cols: cols, rows: rows }
});
}
} else {
logTerminal('warn', '[Terminal] Invalid calculated dimensions:', { rows, cols, height, width, charSize });
if (
this.term.cols > 0 &&
this.term.rows > 0 &&
(this.term.cols !== previousCols || this.term.rows !== previousRows)
) {
this.sendMessage({
resize: { cols: this.term.cols, rows: this.term.rows },
});
}
} catch (error) {
logTerminal('error', '[Terminal] Resize error:', error);

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