mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
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:
co-authored by
ShadowArcanist
parent
0b843bb07c
commit
8ae56587d5
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -40,6 +40,7 @@ CHANGELOG.md
|
||||
/.workspaces
|
||||
tests/Browser/Screenshots
|
||||
tests/v4/Browser/Screenshots
|
||||
ref
|
||||
|
||||
# Local generated Lima configs
|
||||
.dev/bin/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
- 13–14px 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 X–Y 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 “1–2 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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".');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+22
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-7
File diff suppressed because one or more lines are too long
Vendored
-2
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
-5
@@ -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
File diff suppressed because it is too large
Load Diff
+59
-23
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user