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:
parent
0b843bb07c
commit
8ae56587d5
497 changed files with 44047 additions and 22632 deletions
|
|
@ -29,11 +29,12 @@ DB_PORT=5432
|
||||||
# DB_WRITE_PASSWORD=
|
# DB_WRITE_PASSWORD=
|
||||||
# DB_STICKY=true
|
# 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
|
# 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.
|
# 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
|
SESSION_DRIVER=array
|
||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
MAIL_MAILER=array
|
MAIL_MAILER=array
|
||||||
TELESCOPE_ENABLED=false
|
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
|
|
||||||
|
|
|
||||||
32
.github/workflows/coolify-sha-build.yml
vendored
32
.github/workflows/coolify-sha-build.yml
vendored
|
|
@ -76,15 +76,35 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.GITHUB_REGISTRY }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
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 \
|
docker buildx imagetools create \
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-amd64 \
|
"${IMAGE}:sha-${SHA}-amd64" \
|
||||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-aarch64 \
|
"${IMAGE}:sha-${SHA}-aarch64" \
|
||||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
|
"${TAG_ARGS[@]}"
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.DOCKER_REGISTRY }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
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 \
|
docker buildx imagetools create \
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-amd64 \
|
"${IMAGE}:sha-${SHA}-amd64" \
|
||||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-aarch64 \
|
"${IMAGE}:sha-${SHA}-aarch64" \
|
||||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
|
"${TAG_ARGS[@]}"
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -40,6 +40,7 @@ CHANGELOG.md
|
||||||
/.workspaces
|
/.workspaces
|
||||||
tests/Browser/Screenshots
|
tests/Browser/Screenshots
|
||||||
tests/v4/Browser/Screenshots
|
tests/v4/Browser/Screenshots
|
||||||
|
ref
|
||||||
|
|
||||||
# Local generated Lima configs
|
# Local generated Lima configs
|
||||||
.dev/bin/
|
.dev/bin/
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,6 @@ ## Foundational Context
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
- laravel/dusk (DUSK) - v8
|
- laravel/dusk (DUSK) - v8
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/telescope (TELESCOPE) - v5
|
|
||||||
- pestphp/pest (PEST) - v4
|
- pestphp/pest (PEST) - v4
|
||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
|
|
|
||||||
|
|
@ -140,13 +140,19 @@ ## 6. Start Development
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
|
||||||
| Mailpit (email catcher) | `http://localhost:8025` | |
|
| Mailpit (email catcher) | `http://localhost:8025` | |
|
||||||
| Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default |
|
|
||||||
|
|
||||||
> [!NOTE]
|
**Server-Timing + HUD** (headers + bottom-right pill on full HTML pages):
|
||||||
> To enable Telescope, add the following to your `.env` file:
|
|
||||||
> ```env
|
| Setting | Effect |
|
||||||
> TELESCOPE_ENABLED=true
|
|---------|--------|
|
||||||
> ```
|
| `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
|
## Development Notes
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ ## Branch Strategy
|
||||||
|
|
||||||
| Branch | Role | Docker image tags | How it ships |
|
| 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` |
|
| **`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
|
### Where to merge
|
||||||
|
|
@ -59,6 +59,7 @@ ## Release Process
|
||||||
- Merge the release commit into `v4.x` through a pull request.
|
- 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.
|
- 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.
|
- 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`.
|
- This workflow does not update a semantic version tag or `latest`.
|
||||||
|
|
||||||
3. **Wait for the SHA Image**
|
3. **Wait for the SHA Image**
|
||||||
|
|
|
||||||
640
UI_REDESIGN.md
Normal file
640
UI_REDESIGN.md
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
# Coolify UI redesign
|
||||||
|
|
||||||
|
This branch restyles Coolify without changing its Livewire + Blade + Alpine +
|
||||||
|
Tailwind v4 architecture. The visual system now covers the global shell,
|
||||||
|
project and environment pages, application navigation, settings surfaces,
|
||||||
|
tables, modals, toasts, terminals, and metrics.
|
||||||
|
|
||||||
|
Use this file as the source of truth when updating another page. The older
|
||||||
|
Graphite-only notes are no longer accurate.
|
||||||
|
|
||||||
|
Onboarding validation and live server validation checkpoints share
|
||||||
|
`<x-checkpoint-item>` (idle / pending / running / success / error) inside a
|
||||||
|
compact divided list, not legacy green check SVGs or fixed-width status rows.
|
||||||
|
|
||||||
|
> **Maintainer rules**
|
||||||
|
>
|
||||||
|
> - Keep the work frontend-focused unless existing data must be exposed to the
|
||||||
|
> view.
|
||||||
|
> - Preserve routes, Livewire bindings, permissions, confirmations, and working
|
||||||
|
> interactions while changing layout and presentation.
|
||||||
|
> - Do not write or run tests for this redesign branch.
|
||||||
|
> - Validate Blade with `docker exec coolify php artisan view:cache`, then clear
|
||||||
|
> it with `docker exec coolify php artisan view:clear`.
|
||||||
|
> - Build frontend assets in the Vitee container with
|
||||||
|
> `docker exec coolify-vite npm run build`.
|
||||||
|
> - Use existing components before adding another styling abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual direction
|
||||||
|
|
||||||
|
The interface is compact and product-focused:
|
||||||
|
|
||||||
|
- near-neutral layered surfaces instead of large bordered boxes;
|
||||||
|
- 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 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
||||||
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
$helperImageWithVersion = "$helperImage:$helperImageVersion";
|
||||||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||||
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||||
|
$buildxMetadataVolume = isDev() && $server->isLocalhost()
|
||||||
|
? 'coolify-buildx'
|
||||||
|
: '$HOME/.docker/buildx';
|
||||||
|
|
||||||
$cleanupLog = [];
|
$cleanupLog = [];
|
||||||
|
|
||||||
|
|
@ -51,7 +54,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
||||||
'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"',
|
'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,
|
$imagePruneCmd,
|
||||||
'docker builder prune -af',
|
'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=$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=$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",
|
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
class Init extends Command
|
class Init extends Command
|
||||||
|
|
@ -161,10 +160,12 @@ private function pullHelperImage()
|
||||||
|
|
||||||
private function pullTemplatesFromCDN()
|
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()) {
|
if ($response->successful()) {
|
||||||
$services = $response->json();
|
store_service_templates_bundle($response->body());
|
||||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,7 @@ public function applications(Request $request)
|
||||||
properties: [
|
properties: [
|
||||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
'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")'],
|
'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 @@ public function create_public_application(Request $request)
|
||||||
properties: [
|
properties: [
|
||||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
'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")'],
|
'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 @@ public function create_private_gh_app_application(Request $request)
|
||||||
properties: [
|
properties: [
|
||||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
'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")'],
|
'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 @@ private function create_application(Request $request, $type)
|
||||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
|
||||||
'docker_compose_domains' => 'array|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.*.name' => 'string|required',
|
||||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||||
|
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||||
];
|
];
|
||||||
// ports_exposes is not required for dockercompose
|
// ports_exposes is not required for dockercompose
|
||||||
if ($request->build_pack === 'dockercompose') {
|
if ($request->build_pack === 'dockercompose') {
|
||||||
|
|
@ -1343,7 +1347,7 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validationMessages = [
|
$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);
|
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
|
|
@ -1435,7 +1439,12 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
|
|
||||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
$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');
|
$request->offsetUnset('docker_compose_domains');
|
||||||
}
|
}
|
||||||
|
|
@ -1552,13 +1561,14 @@ private function create_application(Request $request, $type)
|
||||||
'github_app_uuid' => 'string|required',
|
'github_app_uuid' => 'string|required',
|
||||||
'watch_paths' => 'string|nullable',
|
'watch_paths' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|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.*.name' => 'string|required',
|
||||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||||
|
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||||
];
|
];
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validationMessages = [
|
$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);
|
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
|
|
@ -1688,7 +1698,12 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
|
|
||||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
$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');
|
$request->offsetUnset('docker_compose_domains');
|
||||||
}
|
}
|
||||||
|
|
@ -1804,14 +1819,15 @@ private function create_application(Request $request, $type)
|
||||||
'private_key_uuid' => 'string|required',
|
'private_key_uuid' => 'string|required',
|
||||||
'watch_paths' => 'string|nullable',
|
'watch_paths' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|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.*.name' => 'string|required',
|
||||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||||
|
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||||
];
|
];
|
||||||
|
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validationMessages = [
|
$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);
|
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||||
|
|
||||||
|
|
@ -1913,7 +1929,12 @@ private function create_application(Request $request, $type)
|
||||||
}
|
}
|
||||||
|
|
||||||
$dockerComposeDomains->each(function ($domain) use ($dockerComposeDomainsJson) {
|
$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');
|
$request->offsetUnset('docker_compose_domains');
|
||||||
}
|
}
|
||||||
|
|
@ -2647,6 +2668,7 @@ public function delete_by_uuid(Request $request)
|
||||||
properties: [
|
properties: [
|
||||||
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
|
'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")'],
|
'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 @@ public function update_by_uuid(Request $request)
|
||||||
'static_image' => 'string',
|
'static_image' => 'string',
|
||||||
'watch_paths' => 'string|nullable',
|
'watch_paths' => 'string|nullable',
|
||||||
'docker_compose_domains' => 'array|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.*.name' => 'string|required',
|
||||||
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
|
||||||
|
'docker_compose_domains.*.redirect' => 'nullable|string|in:www,non-www,both',
|
||||||
'custom_nginx_configuration' => 'string|nullable',
|
'custom_nginx_configuration' => 'string|nullable',
|
||||||
'is_http_basic_auth_enabled' => 'boolean|nullable',
|
'is_http_basic_auth_enabled' => 'boolean|nullable',
|
||||||
'is_preview_deployments_enabled' => 'boolean|nullable',
|
'is_preview_deployments_enabled' => 'boolean|nullable',
|
||||||
|
|
@ -2785,7 +2808,7 @@ public function update_by_uuid(Request $request)
|
||||||
];
|
];
|
||||||
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
$validationRules = array_merge(sharedDataApplications(), $validationRules);
|
||||||
$validationMessages = [
|
$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);
|
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
|
||||||
|
|
||||||
|
|
@ -2993,10 +3016,18 @@ public function update_by_uuid(Request $request)
|
||||||
|
|
||||||
$yaml = Yaml::parse($application->docker_compose_raw);
|
$yaml = Yaml::parse($application->docker_compose_raw);
|
||||||
$services = data_get($yaml, 'services', []);
|
$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');
|
$name = data_get($domain, 'name');
|
||||||
if ($name && is_array($services) && isset($services[$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');
|
$request->offsetUnset('docker_compose_domains');
|
||||||
|
|
|
||||||
|
|
@ -858,7 +858,7 @@ public function delete_server(Request $request)
|
||||||
if ($server->definedResources()->count() > 0 && ! $force) {
|
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);
|
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);
|
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http;
|
namespace App\Http;
|
||||||
|
|
||||||
|
use App\Http\Middleware\AddServerTimingHeaders;
|
||||||
use App\Http\Middleware\ApiAbility;
|
use App\Http\Middleware\ApiAbility;
|
||||||
use App\Http\Middleware\ApiSensitiveData;
|
use App\Http\Middleware\ApiSensitiveData;
|
||||||
use App\Http\Middleware\Authenticate;
|
use App\Http\Middleware\Authenticate;
|
||||||
|
|
@ -51,6 +52,8 @@ class Kernel extends HttpKernel
|
||||||
* @var array<int, class-string|string>
|
* @var array<int, class-string|string>
|
||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
|
// Outermost so Server-Timing includes the full middleware + app cost.
|
||||||
|
AddServerTimingHeaders::class,
|
||||||
TrustHosts::class,
|
TrustHosts::class,
|
||||||
TrustProxies::class,
|
TrustProxies::class,
|
||||||
HandleCors::class,
|
HandleCors::class,
|
||||||
|
|
|
||||||
152
app/Http/Middleware/AddServerTimingHeaders.php
Normal file
152
app/Http/Middleware/AddServerTimingHeaders.php
Normal file
|
|
@ -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 @@ private function prepare_builder_image(bool $firstTry = true)
|
||||||
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
$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();
|
$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->use_build_server) {
|
||||||
if ($this->dockerConfigFileExists === 'NOK') {
|
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.');
|
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\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
|
class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public $timeout = 10;
|
public $timeout = 60;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|
@ -28,14 +28,24 @@ public function handle(): void
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
return;
|
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()) {
|
if ($response->successful()) {
|
||||||
$services = $response->json();
|
// Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled.
|
||||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
store_service_templates_bundle($response->body());
|
||||||
} else {
|
} 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());
|
send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body());
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('PullTemplatesFromCDN exception', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
send_internal_notification('PullTemplatesAndVersions failed with: '.$e->getMessage());
|
send_internal_notification('PullTemplatesAndVersions failed with: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
257
app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php
Normal file
257
app/Livewire/Concerns/InteractsWithCloudflareDomainConnect.php
Normal file
|
|
@ -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 @@ public function mount()
|
||||||
{
|
{
|
||||||
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
||||||
$this->servers = Server::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()
|
public function render()
|
||||||
|
|
|
||||||
68
app/Livewire/Dashboard/ActiveDeployments.php
Normal file
68
app/Livewire/Dashboard/ActiveDeployments.php
Normal file
|
|
@ -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;
|
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]
|
#[Computed]
|
||||||
public function deployments()
|
public function deployments()
|
||||||
{
|
{
|
||||||
|
|
@ -53,4 +69,20 @@ public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.deployments-indicator');
|
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;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\DiscordNotificationSettings;
|
use App\Models\DiscordNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -12,7 +13,7 @@
|
||||||
|
|
||||||
class Discord extends Component
|
class Discord extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
public Team $team;
|
public Team $team;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Notifications;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\EmailNotificationSettings;
|
use App\Models\EmailNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -13,7 +14,7 @@
|
||||||
|
|
||||||
class Email extends Component
|
class Email extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Notifications;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\PushoverNotificationSettings;
|
use App\Models\PushoverNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -12,7 +13,7 @@
|
||||||
|
|
||||||
class Pushover extends Component
|
class Pushover extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Notifications;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\SlackNotificationSettings;
|
use App\Models\SlackNotificationSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -13,7 +14,7 @@
|
||||||
|
|
||||||
class Slack extends Component
|
class Slack extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Notifications;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\TelegramNotificationSettings;
|
use App\Models\TelegramNotificationSettings;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -12,7 +13,7 @@
|
||||||
|
|
||||||
class Telegram extends Component
|
class Telegram extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
protected $listeners = ['refresh' => '$refresh'];
|
protected $listeners = ['refresh' => '$refresh'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Livewire\Notifications;
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\WebhookNotificationSettings;
|
use App\Models\WebhookNotificationSettings;
|
||||||
use App\Notifications\Test;
|
use App\Notifications\Test;
|
||||||
|
|
@ -12,7 +13,7 @@
|
||||||
|
|
||||||
class Webhook extends Component
|
class Webhook extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests, TogglesNotificationEvents;
|
||||||
|
|
||||||
public Team $team;
|
public Team $team;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project\Application\Deployment;
|
namespace App\Livewire\Project\Application\Deployment;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationDeploymentStatus;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -28,6 +30,18 @@ class Index extends Component
|
||||||
|
|
||||||
public ?string $pull_request_id = null;
|
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'];
|
protected $queryString = ['pull_request_id'];
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
|
|
@ -64,8 +78,16 @@ public function mount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
|
|
||||||
$this->application = $application;
|
$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 = $deployments;
|
||||||
$this->deployments_count = $count;
|
$this->deployments_count = $count;
|
||||||
$this->current_url = url()->current();
|
$this->current_url = url()->current();
|
||||||
|
|
@ -114,14 +136,75 @@ public function nextPage(?int $take = null)
|
||||||
$this->loadDeployments();
|
$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()
|
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 = $deployments;
|
||||||
$this->deployments_count = $count;
|
$this->deployments_count = $count;
|
||||||
$this->showMore();
|
$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)
|
public function updatedPullRequestId($value)
|
||||||
{
|
{
|
||||||
// Sanitize and validate the pull request ID
|
// Sanitize and validate the pull request ID
|
||||||
|
|
@ -139,20 +222,15 @@ public function updatedPullRequestId($value)
|
||||||
$this->pull_request_id = null;
|
$this->pull_request_id = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pagination when filter changes
|
$this->deploymentFilter = 'all';
|
||||||
$this->skip = 0;
|
$this->resetPaginationAndLoad();
|
||||||
$this->showPrev = false;
|
|
||||||
$this->updateCurrentPage();
|
|
||||||
$this->loadDeployments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearFilter()
|
public function clearFilter()
|
||||||
{
|
{
|
||||||
$this->pull_request_id = null;
|
$this->pull_request_id = null;
|
||||||
$this->skip = 0;
|
$this->deploymentFilter = 'all';
|
||||||
$this->showPrev = false;
|
$this->resetPaginationAndLoad();
|
||||||
$this->updateCurrentPage();
|
|
||||||
$this->loadDeployments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateCurrentPage()
|
private function updateCurrentPage()
|
||||||
|
|
@ -160,6 +238,95 @@ private function updateCurrentPage()
|
||||||
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
|
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.project.application.deployment.index');
|
return view('livewire.project.application.deployment.index');
|
||||||
|
|
|
||||||
1557
app/Livewire/Project/Application/Domains.php
Normal file
1557
app/Livewire/Project/Application/Domains.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -94,6 +94,9 @@ class General extends Component
|
||||||
|
|
||||||
public bool $isSpa = false;
|
public bool $isSpa = false;
|
||||||
|
|
||||||
|
/** UI-only aggregate of isStatic/isSpa: dynamic | static | spa */
|
||||||
|
public string $siteType = 'dynamic';
|
||||||
|
|
||||||
public bool $isBuildServerEnabled = false;
|
public bool $isBuildServerEnabled = false;
|
||||||
|
|
||||||
public bool $isPreserveRepositoryEnabled = false;
|
public bool $isPreserveRepositoryEnabled = false;
|
||||||
|
|
@ -437,6 +440,7 @@ public function syncData(bool $toModel = false): void
|
||||||
// Application settings properties
|
// Application settings properties
|
||||||
$this->isStatic = $this->application->settings->is_static;
|
$this->isStatic = $this->application->settings->is_static;
|
||||||
$this->isSpa = $this->application->settings->is_spa;
|
$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->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
|
||||||
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
|
$this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
|
||||||
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
|
$this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
|
||||||
|
|
@ -444,6 +448,13 @@ public function syncData(bool $toModel = false): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setSiteType(): void
|
||||||
|
{
|
||||||
|
$this->isStatic = $this->siteType !== 'dynamic';
|
||||||
|
$this->isSpa = $this->siteType === 'spa';
|
||||||
|
$this->instantSave();
|
||||||
|
}
|
||||||
|
|
||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -680,7 +691,10 @@ public function checkFqdns($showToaster = true)
|
||||||
if ($this->application->additional_servers->count() === 0) {
|
if ($this->application->additional_servers->count() === 0) {
|
||||||
foreach ($domains as $domain) {
|
foreach ($domains as $domain) {
|
||||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
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 @@ public function setRedirect()
|
||||||
$this->application->redirect = $this->redirect;
|
$this->application->redirect = $this->redirect;
|
||||||
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
$has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count();
|
||||||
if ($has_www === 0 && $this->application->redirect === 'www') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -847,7 +864,10 @@ public function submit($showToaster = true)
|
||||||
$domain = data_get($service, 'domain');
|
$domain = data_get($service, 'domain');
|
||||||
if ($domain) {
|
if ($domain) {
|
||||||
if (! validateDNSEntry($domain, $this->application->destination->server)) {
|
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 array $parameters;
|
||||||
|
|
||||||
|
public string $activeRouteName = '';
|
||||||
|
|
||||||
protected string $deploymentUuid;
|
protected string $deploymentUuid;
|
||||||
|
|
||||||
public bool $docker_cleanup = true;
|
public bool $docker_cleanup = true;
|
||||||
|
|
@ -38,6 +40,7 @@ public function getListeners()
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
|
$this->activeRouteName = request()->route()?->getName() ?? '';
|
||||||
$this->parameters = [
|
$this->parameters = [
|
||||||
'project_uuid' => $this->application->project()->uuid,
|
'project_uuid' => $this->application->project()->uuid,
|
||||||
'environment_uuid' => $this->application->environment->uuid,
|
'environment_uuid' => $this->application->environment->uuid,
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,10 @@ public function save_preview($preview_id)
|
||||||
$this->previewFqdns[$previewKey] = $fqdn;
|
$this->previewFqdns[$previewKey] = $fqdn;
|
||||||
|
|
||||||
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
|
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;
|
$success = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,9 +252,17 @@ public function backupNow()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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', [
|
return redirect()->route('project.database.backup.executions', [
|
||||||
'project_uuid' => $database->project()->uuid,
|
'project_uuid' => $project->uuid,
|
||||||
'environment_uuid' => $database->environment->uuid,
|
'environment_uuid' => $environment->uuid,
|
||||||
'database_uuid' => $database->uuid,
|
'database_uuid' => $database->uuid,
|
||||||
'backup_uuid' => $this->backup->uuid,
|
'backup_uuid' => $this->backup->uuid,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,71 @@
|
||||||
|
|
||||||
namespace App\Livewire\Project;
|
namespace App\Livewire\Project;
|
||||||
|
|
||||||
use App\Models\PrivateKey;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Server;
|
use Illuminate\Contracts\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
{
|
{
|
||||||
public $projects;
|
public $projects;
|
||||||
|
|
||||||
public $servers;
|
public function mount(): void
|
||||||
|
|
||||||
public $private_keys;
|
|
||||||
|
|
||||||
public function mount()
|
|
||||||
{
|
{
|
||||||
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
|
// Only load what the page renders. Servers/private keys were previously
|
||||||
$this->projects = Project::ownedByCurrentTeamCached();
|
// hydrated into public Livewire state but never used by the view.
|
||||||
$this->servers = Server::ownedByCurrentTeamCached();
|
$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 @@ public function loadServices()
|
||||||
[
|
[
|
||||||
'id' => 'public',
|
'id' => 'public',
|
||||||
'name' => 'Public Repository',
|
'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'),
|
'logo' => asset('svgs/git.svg'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'private-gh-app',
|
'id' => 'private-gh-app',
|
||||||
'name' => 'Private Repository (with GitHub 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'),
|
'logo' => asset('svgs/github.svg'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -179,7 +181,8 @@ public function loadServices()
|
||||||
[
|
[
|
||||||
'id' => 'private-deploy-key',
|
'id' => 'private-deploy-key',
|
||||||
'name' => 'Private Repository (with 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'),
|
'logo' => asset('svgs/git.svg'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -187,19 +190,22 @@ public function loadServices()
|
||||||
[
|
[
|
||||||
'id' => 'dockerfile',
|
'id' => 'dockerfile',
|
||||||
'name' => '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'),
|
'logo' => asset('svgs/docker.svg'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'docker-compose-empty',
|
'id' => 'docker-compose-empty',
|
||||||
'name' => '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'),
|
'logo' => asset('svgs/docker.svg'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'docker-image',
|
'id' => 'docker-image',
|
||||||
'name' => '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'),
|
'logo' => asset('svgs/docker.svg'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
@ -286,6 +292,13 @@ public function instantSave()
|
||||||
|
|
||||||
private function serviceTemplatesLastUpdated(): ?string
|
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());
|
return $this->formatLastModified($this->serviceTemplatesPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,24 +188,26 @@ public function render()
|
||||||
'dragonflies' => $this->dragonflies,
|
'dragonflies' => $this->dragonflies,
|
||||||
'clickhouses' => $this->clickhouses,
|
'clickhouses' => $this->clickhouses,
|
||||||
'services' => $this->services,
|
'services' => $this->services,
|
||||||
'applicationsJs' => $this->toSearchableArray($this->applications),
|
'applicationsJs' => $this->toSearchableArray($this->applications, 'application', 'Application'),
|
||||||
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls),
|
'postgresqlsJs' => $this->toSearchableArray($this->postgresqls, 'database', 'PostgreSQL'),
|
||||||
'redisJs' => $this->toSearchableArray($this->redis),
|
'redisJs' => $this->toSearchableArray($this->redis, 'database', 'Redis'),
|
||||||
'mongodbsJs' => $this->toSearchableArray($this->mongodbs),
|
'mongodbsJs' => $this->toSearchableArray($this->mongodbs, 'database', 'MongoDB'),
|
||||||
'mysqlsJs' => $this->toSearchableArray($this->mysqls),
|
'mysqlsJs' => $this->toSearchableArray($this->mysqls, 'database', 'MySQL'),
|
||||||
'mariadbsJs' => $this->toSearchableArray($this->mariadbs),
|
'mariadbsJs' => $this->toSearchableArray($this->mariadbs, 'database', 'MariaDB'),
|
||||||
'keydbsJs' => $this->toSearchableArray($this->keydbs),
|
'keydbsJs' => $this->toSearchableArray($this->keydbs, 'database', 'KeyDB'),
|
||||||
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies),
|
'dragonfliesJs' => $this->toSearchableArray($this->dragonflies, 'database', 'Dragonfly'),
|
||||||
'clickhousesJs' => $this->toSearchableArray($this->clickhouses),
|
'clickhousesJs' => $this->toSearchableArray($this->clickhouses, 'database', 'ClickHouse'),
|
||||||
'servicesJs' => $this->toSearchableArray($this->services),
|
'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) => [
|
return $items->map(fn ($item) => [
|
||||||
'uuid' => $item->uuid,
|
'uuid' => $item->uuid,
|
||||||
'name' => $item->name,
|
'name' => $item->name,
|
||||||
|
'type' => $type,
|
||||||
|
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
|
||||||
'fqdn' => $item->fqdn ?? null,
|
'fqdn' => $item->fqdn ?? null,
|
||||||
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
|
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
|
||||||
'status' => $item->status ?? '',
|
'status' => $item->status ?? '',
|
||||||
|
|
|
||||||
1268
app/Livewire/Project/Service/Domains.php
Normal file
1268
app/Livewire/Project/Service/Domains.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -282,6 +282,18 @@ public function convertToApplication()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function enablePublicAccess(): void
|
||||||
|
{
|
||||||
|
$this->isPublic = true;
|
||||||
|
$this->instantSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disablePublicAccess(): void
|
||||||
|
{
|
||||||
|
$this->isPublic = false;
|
||||||
|
$this->instantSave();
|
||||||
|
}
|
||||||
|
|
||||||
public function instantSave()
|
public function instantSave()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Models\EnvironmentVariable;
|
use App\Models\EnvironmentVariable;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use App\Traits\EnvironmentVariableProtection;
|
use App\Traits\EnvironmentVariableProtection;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -29,10 +30,22 @@ class All extends Component
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $environmentFilter = 'all';
|
||||||
|
|
||||||
|
public int $page = 1;
|
||||||
|
|
||||||
|
public int $perPage = 10;
|
||||||
|
|
||||||
public bool $is_env_sorting_enabled = false;
|
public bool $is_env_sorting_enabled = false;
|
||||||
|
|
||||||
public bool $use_build_secrets = 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 = [
|
protected $listeners = [
|
||||||
'saveKey' => 'submit',
|
'saveKey' => 'submit',
|
||||||
'refreshEnvs',
|
'refreshEnvs',
|
||||||
|
|
@ -41,6 +54,7 @@ class All extends Component
|
||||||
|
|
||||||
public function updatedSearch(): void
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
|
$this->page = 1;
|
||||||
$this->clearEnvironmentVariableCaches();
|
$this->clearEnvironmentVariableCaches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +65,11 @@ private function clearEnvironmentVariableCaches(): void
|
||||||
unset($this->hardcodedEnvironmentVariables);
|
unset($this->hardcodedEnvironmentVariables);
|
||||||
unset($this->hardcodedEnvironmentVariablesPreview);
|
unset($this->hardcodedEnvironmentVariablesPreview);
|
||||||
unset($this->hasEnvironmentVariables);
|
unset($this->hasEnvironmentVariables);
|
||||||
|
unset($this->environmentVariableRows);
|
||||||
|
unset($this->environmentVariablePageRows);
|
||||||
|
unset($this->environmentVariableRowCount);
|
||||||
|
unset($this->environmentVariableLastPage);
|
||||||
|
unset($this->currentEnvironmentVariablePage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
|
|
@ -63,7 +82,25 @@ public function mount()
|
||||||
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
|
||||||
$this->showPreview = true;
|
$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()
|
public function instantSave()
|
||||||
|
|
@ -71,10 +108,14 @@ public function instantSave()
|
||||||
try {
|
try {
|
||||||
$this->authorize('manageEnvironment', $this->resource);
|
$this->authorize('manageEnvironment', $this->resource);
|
||||||
|
|
||||||
|
$this->page = 1;
|
||||||
$this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled;
|
$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->use_build_secrets = $this->use_build_secrets;
|
||||||
$this->resource->settings->save();
|
$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('success', 'Environment variable settings updated.');
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
@ -84,20 +125,30 @@ public function instantSave()
|
||||||
|
|
||||||
public function getEnvironmentVariablesProperty()
|
public function getEnvironmentVariablesProperty()
|
||||||
{
|
{
|
||||||
|
if (! $this->readyToLoad) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getEnvironmentVariables(false);
|
return $this->getEnvironmentVariables(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEnvironmentVariablesPreviewProperty()
|
public function getEnvironmentVariablesPreviewProperty()
|
||||||
{
|
{
|
||||||
|
if (! $this->readyToLoad) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getEnvironmentVariables(true);
|
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()) {
|
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-value loads (dev view / tests) still use relationship queries.
|
||||||
|
// List/table pagination uses fetchManagedEnvironmentVariables() without values.
|
||||||
$query = $isPreview
|
$query = $isPreview
|
||||||
? $this->resource->environment_variables_preview()
|
? $this->resource->environment_variables_preview()
|
||||||
: $this->resource->environment_variables();
|
: $this->resource->environment_variables();
|
||||||
|
|
@ -116,7 +167,15 @@ private function getEnvironmentVariables(bool $isPreview, bool $withSearch = tru
|
||||||
$query->orderBy('order');
|
$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
|
private function searchTerm(): string
|
||||||
|
|
@ -131,14 +190,11 @@ private function supportsPreviewEnvironmentVariables(): bool
|
||||||
|
|
||||||
public function getHasEnvironmentVariablesProperty(): bool
|
public function getHasEnvironmentVariablesProperty(): bool
|
||||||
{
|
{
|
||||||
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
|
if (! $this->readyToLoad) {
|
||||||
$this->environmentVariablesPreview->isNotEmpty() ||
|
return false;
|
||||||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return $this->environmentVariables->isNotEmpty() ||
|
return $this->environmentVariableRowCount > 0;
|
||||||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
|
|
||||||
$hasPreviewEnvironmentVariables;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nullLockedValues($envs)
|
private function nullLockedValues($envs)
|
||||||
|
|
@ -162,14 +218,341 @@ public function getIsSearchActiveProperty(): bool
|
||||||
|
|
||||||
public function getHardcodedEnvironmentVariablesProperty()
|
public function getHardcodedEnvironmentVariablesProperty()
|
||||||
{
|
{
|
||||||
|
if (! $this->readyToLoad) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getHardcodedVariables(false);
|
return $this->getHardcodedVariables(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHardcodedEnvironmentVariablesPreviewProperty()
|
public function getHardcodedEnvironmentVariablesPreviewProperty()
|
||||||
{
|
{
|
||||||
|
if (! $this->readyToLoad) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getHardcodedVariables(true);
|
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)
|
protected function getHardcodedVariables(bool $isPreview)
|
||||||
{
|
{
|
||||||
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
|
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
|
||||||
|
|
@ -256,13 +639,17 @@ private function formatEnvironmentVariables($variables)
|
||||||
public function switch()
|
public function switch()
|
||||||
{
|
{
|
||||||
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
|
||||||
$this->getDevView();
|
if ($this->view === 'dev') {
|
||||||
|
$this->ensureEnvironmentVariablesLoaded();
|
||||||
|
$this->getDevView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit($data = null)
|
public function submit($data = null)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('manageEnvironment', $this->resource);
|
$this->authorize('manageEnvironment', $this->resource);
|
||||||
|
$this->ensureEnvironmentVariablesLoaded();
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
$this->handleBulkSubmit();
|
$this->handleBulkSubmit();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -270,7 +657,9 @@ public function submit($data = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->updateOrder();
|
$this->updateOrder();
|
||||||
$this->getDevView();
|
if ($this->view === 'dev') {
|
||||||
|
$this->getDevView();
|
||||||
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -278,6 +667,13 @@ public function submit($data = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensureEnvironmentVariablesLoaded(): void
|
||||||
|
{
|
||||||
|
if (! $this->readyToLoad) {
|
||||||
|
$this->loadEnvironmentVariables();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function updateOrder()
|
private function updateOrder()
|
||||||
{
|
{
|
||||||
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
|
||||||
|
|
@ -495,7 +891,10 @@ private function updateOrCreateVariables($isPreview, $variables)
|
||||||
public function refreshEnvs()
|
public function refreshEnvs()
|
||||||
{
|
{
|
||||||
$this->resource->refresh();
|
$this->resource->refresh();
|
||||||
|
$this->readyToLoad = true;
|
||||||
$this->clearEnvironmentVariableCaches();
|
$this->clearEnvironmentVariableCaches();
|
||||||
$this->getDevView();
|
if ($this->view === 'dev') {
|
||||||
|
$this->getDevView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ class Show extends Component
|
||||||
|
|
||||||
public string $type;
|
public string $type;
|
||||||
|
|
||||||
|
public int $tableAlphabeticalOrder = 0;
|
||||||
|
|
||||||
|
public int $tableCreationOrder = 0;
|
||||||
|
|
||||||
public string $key;
|
public string $key;
|
||||||
|
|
||||||
public ?string $value = null;
|
public ?string $value = null;
|
||||||
|
|
@ -63,6 +67,18 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $isValueHidden = false;
|
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 = [];
|
public array $problematicVariables = [];
|
||||||
|
|
||||||
protected $listeners = [
|
protected $listeners = [
|
||||||
|
|
@ -116,10 +132,33 @@ public function refresh()
|
||||||
if (! $this->env->exists || ! $this->env->fresh()) {
|
if (! $this->env->exists || ! $this->env->fresh()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$this->valuesLoaded = false;
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
$this->checkEnvs();
|
$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)
|
public function syncData(bool $toModel = false)
|
||||||
{
|
{
|
||||||
if ($toModel) {
|
if ($toModel) {
|
||||||
|
|
@ -149,29 +188,55 @@ public function syncData(bool $toModel = false)
|
||||||
$this->env->is_literal = $this->is_literal;
|
$this->env->is_literal = $this->is_literal;
|
||||||
$this->env->is_shown_once = $this->is_shown_once;
|
$this->env->is_shown_once = $this->is_shown_once;
|
||||||
$this->env->save();
|
$this->env->save();
|
||||||
|
$this->valuesLoaded = true;
|
||||||
} else {
|
} else {
|
||||||
|
// Table metadata only — never decrypt here. Values load via loadValues().
|
||||||
|
$this->env->setAppends([]);
|
||||||
$this->key = $this->env->key;
|
$this->key = $this->env->key;
|
||||||
$this->value = $this->env->value;
|
|
||||||
$this->comment = $this->env->comment;
|
$this->comment = $this->env->comment;
|
||||||
$this->is_multiline = $this->env->is_multiline;
|
$this->is_multiline = (bool) $this->env->is_multiline;
|
||||||
$this->is_literal = $this->env->is_literal;
|
$this->is_literal = (bool) $this->env->is_literal;
|
||||||
$this->is_shown_once = $this->env->is_shown_once;
|
$this->is_shown_once = (bool) $this->env->is_shown_once;
|
||||||
$this->is_runtime = $this->env->is_runtime ?? true;
|
$this->is_runtime = (bool) ($this->env->is_runtime ?? true);
|
||||||
$this->is_buildtime = $this->env->is_buildtime ?? true;
|
$this->is_buildtime = (bool) ($this->env->is_buildtime ?? true);
|
||||||
$this->is_required = $this->env->is_required ?? false;
|
$this->is_required = (bool) ($this->env->is_required ?? false);
|
||||||
$this->is_really_required = $this->env->is_really_required ?? false;
|
// Use the stored column, not the value-based accessor (that decrypts).
|
||||||
$this->is_shared = $this->env->is_shared ?? false;
|
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
|
||||||
$this->real_value = $this->env->real_value;
|
$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->value = null;
|
||||||
$this->real_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()
|
public function checkEnvs()
|
||||||
{
|
{
|
||||||
$this->isDisabled = false;
|
$this->isDisabled = false;
|
||||||
|
|
@ -215,6 +280,7 @@ public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->env);
|
$this->authorize('update', $this->env);
|
||||||
|
$this->loadValues();
|
||||||
|
|
||||||
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
|
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
|
||||||
$oldValue = $this->env->getOriginal('value');
|
$oldValue = $this->env->getOriginal('value');
|
||||||
|
|
@ -238,7 +304,23 @@ public function submit()
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function availableSharedVariables(): array
|
public function availableSharedVariables(): array
|
||||||
{
|
{
|
||||||
|
// Shared across all Show row components in the same request (edit modals).
|
||||||
|
static $requestCache = [];
|
||||||
|
|
||||||
$team = currentTeam();
|
$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 = [
|
$result = [
|
||||||
'team' => [],
|
'team' => [],
|
||||||
'project' => [],
|
'project' => [],
|
||||||
|
|
@ -248,7 +330,7 @@ public function availableSharedVariables(): array
|
||||||
|
|
||||||
// Early return if no team
|
// Early return if no team
|
||||||
if (! $team) {
|
if (! $team) {
|
||||||
return $result;
|
return $requestCache[$cacheKey] = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user can view team variables
|
// Check if user can view team variables
|
||||||
|
|
@ -359,7 +441,7 @@ public function availableSharedVariables(): array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $requestCache[$cacheKey] = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete()
|
public function delete()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ class ShowHardcoded extends Component
|
||||||
|
|
||||||
public ?string $serviceName = null;
|
public ?string $serviceName = null;
|
||||||
|
|
||||||
|
public bool $isPreview = false;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->key = $this->env['key'];
|
$this->key = $this->env['key'];
|
||||||
|
|
|
||||||
|
|
@ -80,14 +80,31 @@ private function getContainersForServer($server)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $containers->toArray();
|
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) {
|
} catch (\Exception $e) {
|
||||||
// Log error but don't fail the entire operation
|
// Log error but don't fail the entire operation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ class Terminal extends Component
|
||||||
|
|
||||||
public bool $isTerminalConnected = false;
|
public bool $isTerminalConnected = false;
|
||||||
|
|
||||||
|
public string $variant = 'default';
|
||||||
|
|
||||||
private function checkShellAvailability(Server $server, string $container): bool
|
private function checkShellAvailability(Server $server, string $container): bool
|
||||||
{
|
{
|
||||||
$escapedContainer = escapeshellarg($container);
|
$escapedContainer = escapeshellarg($container);
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ public function submit()
|
||||||
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
|
'manual_webhook_secret_bitbucket' => $this->bitbucketManualWebhookSecret,
|
||||||
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
|
'manual_webhook_secret_gitea' => $this->giteaManualWebhookSecret,
|
||||||
]);
|
]);
|
||||||
$this->dispatch('success', 'Secret Saved.');
|
$this->dispatch('success', 'Webhook secrets saved.');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\Environment;
|
use App\Models\Environment;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
|
|
@ -34,7 +35,25 @@ protected function messages(): array
|
||||||
public function mount(string $project_uuid)
|
public function mount(string $project_uuid)
|
||||||
{
|
{
|
||||||
try {
|
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) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
@ -68,8 +87,49 @@ public function navigateToEnvironment($projectUuid, $environmentUuid)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 @@ public function delete($password, $selectedActions = [])
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->server);
|
$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) {
|
if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
|
||||||
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
|
$this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,11 @@ public function instantSave()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submit(string $type)
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->server);
|
$this->authorize('update', $this->server);
|
||||||
$this->syncData(true, $type);
|
$this->syncData(true);
|
||||||
$this->dispatch('success', 'Settings saved.');
|
$this->dispatch('success', 'Settings saved.');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class Navbar extends Component
|
||||||
|
|
||||||
public bool $restartInitiated = false;
|
public bool $restartInitiated = false;
|
||||||
|
|
||||||
|
public array $serverSwitcherOptions = [];
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
|
|
@ -49,6 +51,29 @@ public function mount(Server $server)
|
||||||
$this->currentRoute = request()->route()->getName();
|
$this->currentRoute = request()->route()->getName();
|
||||||
$this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip;
|
$this->serverIp = $this->server->id === 0 ? base_ip() : $this->server->ip;
|
||||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
$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();
|
$this->loadProxyConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ class Advanced extends Component
|
||||||
#[Validate('boolean')]
|
#[Validate('boolean')]
|
||||||
public bool $webhook_allow_localhost;
|
public bool $webhook_allow_localhost;
|
||||||
|
|
||||||
|
public ?string $domain_connect_private_key = null;
|
||||||
|
|
||||||
public function rules()
|
public function rules()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -63,6 +65,7 @@ public function rules()
|
||||||
'is_mcp_server_enabled' => 'boolean',
|
'is_mcp_server_enabled' => 'boolean',
|
||||||
'webhook_allowed_internal_hosts' => 'nullable|string',
|
'webhook_allowed_internal_hosts' => 'nullable|string',
|
||||||
'webhook_allow_localhost' => 'boolean',
|
'webhook_allow_localhost' => 'boolean',
|
||||||
|
'domain_connect_private_key' => 'nullable|string',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +87,8 @@ public function mount()
|
||||||
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
|
$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_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
|
||||||
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
|
$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()
|
public function submit()
|
||||||
|
|
@ -155,6 +160,11 @@ public function submit()
|
||||||
return;
|
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);
|
$this->instantSave($webhookAllowedInternalHosts);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
|
|
@ -187,6 +197,32 @@ public function instantSave(?array $webhookAllowedInternalHosts = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* @return array<int, string>|false
|
||||||
*/
|
*/
|
||||||
|
|
@ -230,33 +266,6 @@ private function isValidWebhookAllowlistEntry(string $entry): bool
|
||||||
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.settings.advanced');
|
return view('livewire.settings.advanced');
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,9 @@ public function submit()
|
||||||
|
|
||||||
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
|
if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) {
|
||||||
if (! validateDNSEntry($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;
|
$error_show = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class ScheduledJobs extends Component
|
||||||
|
|
||||||
public string $filterDate = 'last_24h';
|
public string $filterDate = 'last_24h';
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public string $sortOrder = 'newest';
|
||||||
|
|
||||||
public int $skipPage = 0;
|
public int $skipPage = 0;
|
||||||
|
|
||||||
public int $skipDefaultTake = 20;
|
public int $skipDefaultTake = 20;
|
||||||
|
|
@ -76,6 +80,16 @@ public function updatedFilterDate(): void
|
||||||
$this->loadData();
|
$this->loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSortOrder(): void
|
||||||
|
{
|
||||||
|
$this->loadData();
|
||||||
|
}
|
||||||
|
|
||||||
public function skipNextPage(): void
|
public function skipNextPage(): void
|
||||||
{
|
{
|
||||||
$this->skipPage += $this->skipDefaultTake;
|
$this->skipPage += $this->skipDefaultTake;
|
||||||
|
|
@ -241,8 +255,26 @@ private function getExecutions(?int $teamId = null): Collection
|
||||||
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
|
$cleanups = $this->getCleanupExecutions($dateFrom, $teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $backups->concat($tasks)->concat($cleanups)
|
$executions = $backups->concat($tasks)->concat($cleanups);
|
||||||
->sortByDesc('created_at')
|
|
||||||
|
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()
|
->values()
|
||||||
->take(100);
|
->take(100);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,16 @@ public function instantSave(string $type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function instantSaveSmtp(): void
|
||||||
|
{
|
||||||
|
$this->instantSave('SMTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function instantSaveResend(): void
|
||||||
|
{
|
||||||
|
$this->instantSave('Resend');
|
||||||
|
}
|
||||||
|
|
||||||
public function submitSmtp()
|
public function submitSmtp()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,8 @@ public function mount()
|
||||||
$this->activeTab = 'permissions';
|
$this->activeTab = 'permissions';
|
||||||
} elseif ($routeName === 'source.github.resources') {
|
} elseif ($routeName === 'source.github.resources') {
|
||||||
$this->activeTab = 'resources';
|
$this->activeTab = 'resources';
|
||||||
|
} elseif ($routeName === 'source.github.danger') {
|
||||||
|
$this->activeTab = 'danger';
|
||||||
} else {
|
} else {
|
||||||
$this->activeTab = 'general';
|
$this->activeTab = 'general';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,4 +73,9 @@ public function subscribeStripe($type)
|
||||||
|
|
||||||
return redirect($session->url, 303);
|
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()
|
public function mount()
|
||||||
{
|
{
|
||||||
try {
|
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()) {
|
if (str($this->tagName)->isNotEmpty()) {
|
||||||
$tag = $this->tags->where('name', $this->tagName)->first();
|
$tag = $this->tags->where('name', $this->tagName)->first();
|
||||||
|
if (! $tag) {
|
||||||
|
return redirect()->route('tags.show');
|
||||||
|
}
|
||||||
|
|
||||||
$this->webhook = generateTagDeployWebhook($tag->name);
|
$this->webhook = generateTagDeployWebhook($tag->name);
|
||||||
$this->applications = $tag->applications()->get();
|
$this->applications = $tag->applications()->get();
|
||||||
$this->services = $tag->services()->get();
|
$this->services = $tag->services()->get();
|
||||||
$this->tag = $tag;
|
$this->tag = $tag;
|
||||||
$this->getDeployments();
|
$this->getDeployments();
|
||||||
|
} else {
|
||||||
|
$this->deploymentsPerTagPerServer = [];
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
|
|
@ -57,6 +69,12 @@ public function mount()
|
||||||
public function getDeployments()
|
public function getDeployments()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
if (! $this->applications) {
|
||||||
|
$this->deploymentsPerTagPerServer = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$resource_ids = $this->applications->pluck('id');
|
$resource_ids = $this->applications->pluck('id');
|
||||||
$this->deploymentsPerTagPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('application_id', $resource_ids)->get([
|
$this->deploymentsPerTagPerServer = ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])->whereIn('application_id', $resource_ids)->get([
|
||||||
'id',
|
'id',
|
||||||
|
|
@ -99,6 +117,20 @@ public function redeployAll()
|
||||||
|
|
||||||
public function render()
|
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 @@
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
class AdminView extends Component
|
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()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (! isInstanceAdmin()) {
|
if (! isInstanceAdmin()) {
|
||||||
return redirect()->route('dashboard');
|
return redirect()->route('dashboard');
|
||||||
}
|
}
|
||||||
$this->getUsers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submitSearch()
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
if (! isInstanceAdmin()) {
|
if (! isInstanceAdmin()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($this->search !== '') {
|
|
||||||
$this->users = User::where(function ($query) {
|
$this->resetPage();
|
||||||
$query->where('name', 'like', "%{$this->search}%")
|
|
||||||
->orWhere('email', 'like', "%{$this->search}%");
|
|
||||||
})->get()->filter(function ($user) {
|
|
||||||
return $user->id !== auth()->id();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
$this->getUsers();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUsers()
|
public function updatedTeamFilter(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSort(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitSearch(): void
|
||||||
{
|
{
|
||||||
if (! isInstanceAdmin()) {
|
if (! isInstanceAdmin()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$users = User::where('id', '!=', auth()->id())->get();
|
|
||||||
if ($users->count() > $this->number_of_users_to_show) {
|
$this->resetPage();
|
||||||
$this->lots_of_users = true;
|
|
||||||
$this->users = $users->take($this->number_of_users_to_show);
|
|
||||||
} else {
|
|
||||||
$this->lots_of_users = false;
|
|
||||||
$this->users = $users;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete($id, $password, $selectedActions = [])
|
public function delete($id, $password, $selectedActions = [])
|
||||||
|
|
@ -76,7 +72,7 @@ public function delete($id, $password, $selectedActions = [])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$user->delete();
|
$user->delete();
|
||||||
$this->getUsers();
|
$this->resetPage();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|
@ -86,6 +82,31 @@ public function delete($id, $password, $selectedActions = [])
|
||||||
|
|
||||||
public function render()
|
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 @@ public function submit()
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'personal_team' => false,
|
'personal_team' => false,
|
||||||
|
'is_mcp_server_enabled' => true,
|
||||||
]);
|
]);
|
||||||
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
||||||
refreshSession($team);
|
refreshSession($team);
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ private function syncData(bool $toModel = false): void
|
||||||
// Sync FROM model (on load/refresh)
|
// Sync FROM model (on load/refresh)
|
||||||
$this->name = $this->team->name;
|
$this->name = $this->team->name;
|
||||||
$this->description = $this->team->description;
|
$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',
|
||||||
'docker_compose_raw',
|
'docker_compose_raw',
|
||||||
'docker_compose_domains',
|
'docker_compose_domains',
|
||||||
|
'domain_dns_statuses',
|
||||||
'docker_compose_custom_start_command',
|
'docker_compose_custom_start_command',
|
||||||
'docker_compose_custom_build_command',
|
'docker_compose_custom_build_command',
|
||||||
'swarm_replicas',
|
'swarm_replicas',
|
||||||
|
|
@ -233,6 +234,7 @@ class Application extends BaseModel
|
||||||
'docker_compose',
|
'docker_compose',
|
||||||
'docker_compose_raw',
|
'docker_compose_raw',
|
||||||
'custom_labels',
|
'custom_labels',
|
||||||
|
'domain_dns_statuses',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|
@ -243,6 +245,7 @@ protected function casts(): array
|
||||||
'manual_webhook_secret_gitlab' => 'encrypted',
|
'manual_webhook_secret_gitlab' => 'encrypted',
|
||||||
'manual_webhook_secret_bitbucket' => 'encrypted',
|
'manual_webhook_secret_bitbucket' => 'encrypted',
|
||||||
'manual_webhook_secret_gitea' => 'encrypted',
|
'manual_webhook_secret_gitea' => 'encrypted',
|
||||||
|
'domain_dns_statuses' => 'array',
|
||||||
'restart_count' => 'integer',
|
'restart_count' => 'integer',
|
||||||
'max_restart_count' => 'integer',
|
'max_restart_count' => 'integer',
|
||||||
'last_restart_at' => 'datetime',
|
'last_restart_at' => 'datetime',
|
||||||
|
|
@ -1097,16 +1100,101 @@ public function get_last_days_deployments()
|
||||||
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get();
|
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)
|
/**
|
||||||
{
|
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
|
||||||
$deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc');
|
*/
|
||||||
|
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) {
|
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();
|
$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 [
|
return [
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class InstanceSettings extends Model
|
||||||
'resend_api_key',
|
'resend_api_key',
|
||||||
'is_dns_validation_enabled',
|
'is_dns_validation_enabled',
|
||||||
'custom_dns_servers',
|
'custom_dns_servers',
|
||||||
|
'domain_connect_private_key',
|
||||||
'instance_name',
|
'instance_name',
|
||||||
'is_api_enabled',
|
'is_api_enabled',
|
||||||
'allowed_ips',
|
'allowed_ips',
|
||||||
|
|
@ -58,6 +59,7 @@ class InstanceSettings extends Model
|
||||||
'smtp_username',
|
'smtp_username',
|
||||||
'smtp_password',
|
'smtp_password',
|
||||||
'resend_api_key',
|
'resend_api_key',
|
||||||
|
'domain_connect_private_key',
|
||||||
'sentinel_token',
|
'sentinel_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -74,6 +76,7 @@ class InstanceSettings extends Model
|
||||||
|
|
||||||
'resend_enabled' => 'boolean',
|
'resend_enabled' => 'boolean',
|
||||||
'resend_api_key' => 'encrypted',
|
'resend_api_key' => 'encrypted',
|
||||||
|
'domain_connect_private_key' => 'encrypted',
|
||||||
|
|
||||||
'allowed_ip_ranges' => 'array',
|
'allowed_ip_ranges' => 'array',
|
||||||
'is_auto_update_enabled' => 'boolean',
|
'is_auto_update_enabled' => 'boolean',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ class ServiceApplication extends BaseModel
|
||||||
'human_name',
|
'human_name',
|
||||||
'description',
|
'description',
|
||||||
'fqdn',
|
'fqdn',
|
||||||
|
'redirect',
|
||||||
|
'domain_dns_statuses',
|
||||||
'ports',
|
'ports',
|
||||||
'exposes',
|
'exposes',
|
||||||
'status',
|
'status',
|
||||||
|
|
@ -31,6 +33,22 @@ class ServiceApplication extends BaseModel
|
||||||
'is_migrated',
|
'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()
|
protected static function booted()
|
||||||
{
|
{
|
||||||
static::deleting(function ($service) {
|
static::deleting(function ($service) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
use App\Notifications\Channels\SendsDiscord;
|
use App\Notifications\Channels\SendsDiscord;
|
||||||
use App\Notifications\Channels\SendsEmail;
|
use App\Notifications\Channels\SendsEmail;
|
||||||
use App\Notifications\Channels\SendsPushover;
|
use App\Notifications\Channels\SendsPushover;
|
||||||
use App\Support\V5\V5Feature;
|
|
||||||
use App\Notifications\Channels\SendsSlack;
|
use App\Notifications\Channels\SendsSlack;
|
||||||
|
use App\Support\V5\V5Feature;
|
||||||
use App\Traits\HasNotificationSettings;
|
use App\Traits\HasNotificationSettings;
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
|
@ -52,6 +52,10 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||||
'is_mcp_server_enabled',
|
'is_mcp_server_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'is_mcp_server_enabled' => true,
|
||||||
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'personal_team' => 'boolean',
|
'personal_team' => 'boolean',
|
||||||
'is_mcp_server_enabled' => 'boolean',
|
'is_mcp_server_enabled' => 'boolean',
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,12 @@
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Laravel\Telescope\TelescopeServiceProvider;
|
|
||||||
use Stripe\StripeClient;
|
use Stripe\StripeClient;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
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')));
|
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Laravel\Telescope\IncomingEntry;
|
|
||||||
use Laravel\Telescope\Telescope;
|
|
||||||
use Laravel\Telescope\TelescopeApplicationServiceProvider;
|
|
||||||
|
|
||||||
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
// Telescope::night();
|
|
||||||
|
|
||||||
$this->hideSensitiveRequestDetails();
|
|
||||||
|
|
||||||
$isLocal = $this->app->environment('local');
|
|
||||||
|
|
||||||
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
|
|
||||||
return $isLocal ||
|
|
||||||
$entry->isReportableException() ||
|
|
||||||
$entry->isFailedRequest() ||
|
|
||||||
$entry->isFailedJob() ||
|
|
||||||
$entry->isScheduledTask() ||
|
|
||||||
$entry->hasMonitoredTag();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prevent sensitive request details from being logged by Telescope.
|
|
||||||
*/
|
|
||||||
protected function hideSensitiveRequestDetails(): void
|
|
||||||
{
|
|
||||||
if ($this->app->environment('local')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Telescope::hideRequestParameters(['_token']);
|
|
||||||
|
|
||||||
Telescope::hideRequestHeaders([
|
|
||||||
'cookie',
|
|
||||||
'x-csrf-token',
|
|
||||||
'x-xsrf-token',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the Telescope gate.
|
|
||||||
*
|
|
||||||
* This gate determines who can access Telescope in non-local environments.
|
|
||||||
*/
|
|
||||||
protected function gate(): void
|
|
||||||
{
|
|
||||||
Gate::define('viewTelescope', function ($user) {
|
|
||||||
$root_user = User::find(0);
|
|
||||||
|
|
||||||
return in_array($user->email, [
|
|
||||||
$root_user->email,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
133
app/Support/DnsRecordHints.php
Normal file
133
app/Support/DnsRecordHints.php
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-agnostic DNS records to point hostnames at a Coolify server.
|
||||||
|
*/
|
||||||
|
class DnsRecordHints
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Build A/AAAA entries for every hostname (deduped).
|
||||||
|
*
|
||||||
|
* @param array<int, string|null> $hostnames
|
||||||
|
* @return array<int, array{type: string, name: string, value: string}>
|
||||||
|
*/
|
||||||
|
public static function forHostnames(array $hostnames, ?string $ipv4, ?string $ipv6 = null): array
|
||||||
|
{
|
||||||
|
$records = [];
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($hostnames as $hostname) {
|
||||||
|
if (! is_string($hostname) || trim($hostname) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::forTarget($hostname, $ipv4, $ipv6) as $record) {
|
||||||
|
$key = strtolower($record['type'].'|'.$record['name'].'|'.$record['value']);
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$records[] = $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($records, function (array $a, array $b): int {
|
||||||
|
return [$a['name'], $a['type'], $a['value']] <=> [$b['name'], $b['type'], $b['value']];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{type: string, name: string, value: string}>
|
||||||
|
*/
|
||||||
|
public static function forTarget(?string $hostname, ?string $ipv4, ?string $ipv6 = null): array
|
||||||
|
{
|
||||||
|
$records = [];
|
||||||
|
$fqdn = self::normalizeHostname($hostname);
|
||||||
|
if ($fqdn === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($ipv4) && filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
|
||||||
|
$records[] = [
|
||||||
|
'type' => 'A',
|
||||||
|
'name' => $fqdn,
|
||||||
|
'value' => $ipv4,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
|
||||||
|
$records[] = [
|
||||||
|
'type' => 'AAAA',
|
||||||
|
'name' => $fqdn,
|
||||||
|
'value' => $ipv6,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $records;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeHostname(?string $hostname): ?string
|
||||||
|
{
|
||||||
|
if (blank($hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = strtolower(trim($hostname));
|
||||||
|
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
|
||||||
|
$hostname = explode('/', $hostname)[0] ?? $hostname;
|
||||||
|
$hostname = explode(':', $hostname)[0] ?? $hostname;
|
||||||
|
$hostname = rtrim($hostname, '.');
|
||||||
|
|
||||||
|
if ($hostname === '' || $hostname === '@' || ! str_contains($hostname, '.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip path-like noise; host only.
|
||||||
|
if (filter_var($hostname, FILTER_VALIDATE_IP)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative name for a zone (e.g. app for app.example.com, @ for example.com).
|
||||||
|
*/
|
||||||
|
public static function relativeName(?string $hostname): string
|
||||||
|
{
|
||||||
|
$fqdn = self::normalizeHostname($hostname);
|
||||||
|
if ($fqdn === null) {
|
||||||
|
return '@';
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = array_values(array_filter(explode('.', $fqdn), fn (string $p) => $p !== ''));
|
||||||
|
if (count($labels) <= 2) {
|
||||||
|
return '@';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('.', array_slice($labels, 0, -2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text block suitable for clipboard (type / name / value).
|
||||||
|
*
|
||||||
|
* @param array<int, array{type: string, name: string, value: string}> $records
|
||||||
|
*/
|
||||||
|
public static function toCopyText(array $records): string
|
||||||
|
{
|
||||||
|
if ($records === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = ["Type\tName\tValue"];
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$lines[] = "{$record['type']}\t{$record['name']}\t{$record['value']}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
338
app/Support/DomainConnect/CloudflareDomainConnect.php
Normal file
338
app/Support/DomainConnect/CloudflareDomainConnect.php
Normal file
|
|
@ -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 @@ public function __construct(
|
||||||
public string|bool $instantSave = false,
|
public string|bool $instantSave = false,
|
||||||
public bool $live = false,
|
public bool $live = false,
|
||||||
public bool $disabled = 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 ?string $canGate = null,
|
||||||
public mixed $canResource = null,
|
public mixed $canResource = null,
|
||||||
public bool $autoDisable = true,
|
public bool $autoDisable = true,
|
||||||
|
|
@ -41,10 +41,6 @@ public function __construct(
|
||||||
$this->instantSave = false; // Disable instant save for unauthorized users
|
$this->instantSave = false; // Disable instant save for unauthorized users
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->disabled) {
|
|
||||||
$this->defaultClass .= ' opacity-40';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,10 @@ public function render(): View|Closure|string
|
||||||
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->type === 'password') {
|
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
|
||||||
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
|
// 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 = [
|
$this->scopeUrls = [
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,10 @@ public function render(): View|Closure|string
|
||||||
if (is_null($this->name)) {
|
if (is_null($this->name)) {
|
||||||
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
|
||||||
}
|
}
|
||||||
if ($this->type === 'password') {
|
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
|
||||||
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
|
// 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);
|
// $this->label = Str::title($this->label);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,21 @@
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use Illuminate\Support\Collection;
|
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
|
function isValidDomainUrl(string $url): bool
|
||||||
{
|
{
|
||||||
$components = parse_url($url);
|
$components = parse_url($url);
|
||||||
|
|
|
||||||
|
|
@ -1340,6 +1340,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
if ($isPullRequest) {
|
if ($isPullRequest) {
|
||||||
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
|
||||||
}
|
}
|
||||||
|
$composeRedirect = data_get($domains, "$changedServiceName.redirect");
|
||||||
|
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
|
||||||
|
? $composeRedirect
|
||||||
|
: 'both';
|
||||||
if ($shouldGenerateLabelsExactly) {
|
if ($shouldGenerateLabelsExactly) {
|
||||||
switch ($server->proxyType()) {
|
switch ($server->proxyType()) {
|
||||||
case ProxyTypes::TRAEFIK->value:
|
case ProxyTypes::TRAEFIK->value:
|
||||||
|
|
@ -1351,7 +1355,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image
|
image: $image,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
case ProxyTypes::CADDY->value:
|
case ProxyTypes::CADDY->value:
|
||||||
|
|
@ -1365,7 +1370,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image,
|
image: $image,
|
||||||
predefinedPort: $predefinedPort
|
predefinedPort: $predefinedPort,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1378,7 +1384,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image
|
image: $image,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||||
network: $labelNetwork,
|
network: $labelNetwork,
|
||||||
|
|
@ -1390,7 +1397,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image,
|
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;
|
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
|
||||||
$uuid = $resource->uuid;
|
$uuid = $resource->uuid;
|
||||||
$network = data_get($resource, 'destination.network');
|
$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) {
|
if ($shouldGenerateLabelsExactly) {
|
||||||
switch ($server->proxyType()) {
|
switch ($server->proxyType()) {
|
||||||
case ProxyTypes::TRAEFIK->value:
|
case ProxyTypes::TRAEFIK->value:
|
||||||
|
|
@ -2610,7 +2621,8 @@ function serviceParser(Service $resource): Collection
|
||||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image
|
image: $image,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
case ProxyTypes::CADDY->value:
|
case ProxyTypes::CADDY->value:
|
||||||
|
|
@ -2624,7 +2636,8 @@ function serviceParser(Service $resource): Collection
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image,
|
image: $image,
|
||||||
predefinedPort: $predefinedPort
|
predefinedPort: $predefinedPort,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -2637,7 +2650,8 @@ function serviceParser(Service $resource): Collection
|
||||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image
|
image: $image,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||||
network: $network,
|
network: $network,
|
||||||
|
|
@ -2649,7 +2663,8 @@ function serviceParser(Service $resource): Collection
|
||||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: $image,
|
image: $image,
|
||||||
predefinedPort: $predefinedPort
|
predefinedPort: $predefinedPort,
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1316,23 +1316,92 @@ function sslip(Server $server)
|
||||||
return "http://{$server->ip}.sslip.io";
|
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
|
function get_service_templates(bool $force = false): Collection
|
||||||
{
|
{
|
||||||
if ($force) {
|
if ($force) {
|
||||||
try {
|
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()) {
|
if ($response->failed()) {
|
||||||
return collect([]);
|
return collect([]);
|
||||||
}
|
}
|
||||||
$services = $response->json();
|
store_service_templates_bundle($response->body());
|
||||||
|
|
||||||
return collect($services);
|
return collect(json_decode($response->body()))->sortKeys();
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return get_service_templates();
|
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;
|
$mtime = filemtime($path) ?: 0;
|
||||||
|
|
||||||
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
|
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)
|
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);
|
$url = Url::fromString($fqdn);
|
||||||
$host = $url->getHost();
|
$host = $url->getHost();
|
||||||
if (str($host)->contains('sslip.io')) {
|
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 = data_get($settings, 'custom_dns_servers');
|
||||||
$dns_servers = str($dns_servers)->explode(',');
|
$dns_servers = str($dns_servers)->explode(',');
|
||||||
if ($server->id === 0) {
|
$ip = serverDnsTargetIp($server);
|
||||||
$ip = data_get($settings, 'public_ipv4', data_get($settings, 'public_ipv6', $server->ip));
|
|
||||||
} else {
|
|
||||||
$ip = $server->ip;
|
|
||||||
}
|
|
||||||
$found_matching_ip = false;
|
$found_matching_ip = false;
|
||||||
$type = DNSTypes::NAME_A;
|
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||||
foreach ($dns_servers as $dns_server) {
|
foreach ($dns_servers as $dns_server) {
|
||||||
try {
|
try {
|
||||||
$query = new DNSQuery($dns_server);
|
$query = new DNSQuery($dns_server);
|
||||||
|
|
@ -1831,11 +2073,11 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||||
} else {
|
} else {
|
||||||
foreach ($results as $result) {
|
foreach ($results as $result) {
|
||||||
if ($result->getType() == $type) {
|
if ($result->getType() == $type) {
|
||||||
if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
|
if (isCloudflareIp($result->getData())) {
|
||||||
$found_matching_ip = true;
|
$found_matching_ip = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if ($result->getData() === $ip) {
|
if ($ip && $result->getData() === $ip) {
|
||||||
$found_matching_ip = true;
|
$found_matching_ip = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1849,11 +2091,48 @@ function validateDNSEntry(string $fqdn, Server $server)
|
||||||
return $found_matching_ip;
|
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)
|
function ipMatch($ip, $cidrs, &$match = null)
|
||||||
{
|
{
|
||||||
foreach ((array) $cidrs as $cidr) {
|
foreach ((array) $cidrs as $cidr) {
|
||||||
[$subnet, $mask] = explode('/', $cidr);
|
[$subnet, $prefixLength] = explode('/', $cidr);
|
||||||
if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) {
|
$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;
|
$match = $cidr;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -2758,6 +3037,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
if (! $isDatabase && $fqdns->count() > 0) {
|
if (! $isDatabase && $fqdns->count() > 0) {
|
||||||
if ($fqdns) {
|
if ($fqdns) {
|
||||||
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
|
$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) {
|
if ($shouldGenerateLabelsExactly) {
|
||||||
switch ($resource->server->proxyType()) {
|
switch ($resource->server->proxyType()) {
|
||||||
case ProxyTypes::TRAEFIK->value:
|
case ProxyTypes::TRAEFIK->value:
|
||||||
|
|
@ -2769,7 +3051,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_gzip_enabled: $savedService->isGzipEnabled(),
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: data_get($service, 'image')
|
image: data_get($service, 'image'),
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
case ProxyTypes::CADDY->value:
|
case ProxyTypes::CADDY->value:
|
||||||
|
|
@ -2782,7 +3065,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_gzip_enabled: $savedService->isGzipEnabled(),
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: data_get($service, 'image')
|
image: data_get($service, 'image'),
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -2795,7 +3079,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_gzip_enabled: $savedService->isGzipEnabled(),
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
service_name: $serviceName,
|
||||||
image: data_get($service, 'image')
|
image: data_get($service, 'image'),
|
||||||
|
redirect_direction: $redirectDirection
|
||||||
));
|
));
|
||||||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
|
||||||
network: $resource->destination->network,
|
network: $resource->destination->network,
|
||||||
|
|
@ -2806,7 +3091,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_gzip_enabled: $savedService->isGzipEnabled(),
|
is_gzip_enabled: $savedService->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
|
||||||
service_name: $serviceName,
|
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;
|
$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) {
|
if ($shouldGenerateLabelsExactly) {
|
||||||
switch ($server->proxyType()) {
|
switch ($server->proxyType()) {
|
||||||
case ProxyTypes::TRAEFIK->value:
|
case ProxyTypes::TRAEFIK->value:
|
||||||
|
|
@ -3546,6 +3836,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||||
|
redirect_direction: $redirectDirection,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -3560,6 +3851,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||||
|
redirect_direction: $redirectDirection,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
@ -3575,6 +3867,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||||
|
redirect_direction: $redirectDirection,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$serviceLabels = $serviceLabels->merge(
|
$serviceLabels = $serviceLabels->merge(
|
||||||
|
|
@ -3587,6 +3880,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
||||||
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
is_force_https_enabled: $resource->isForceHttpsEnabled(),
|
||||||
is_gzip_enabled: $resource->isGzipEnabled(),
|
is_gzip_enabled: $resource->isGzipEnabled(),
|
||||||
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
|
||||||
|
redirect_direction: $redirectDirection,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3857,8 +4151,17 @@ function loggy($message = null, array $context = [])
|
||||||
|
|
||||||
return app('log')->debug($message, $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(',');
|
$domains = str($domains)->trim()->explode(',');
|
||||||
$showSslipHttpsWarning = false;
|
$showSslipHttpsWarning = false;
|
||||||
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
|
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use Stripe\BillingPortal\Session;
|
||||||
|
use Stripe\Customer;
|
||||||
use Stripe\Stripe;
|
use Stripe\Stripe;
|
||||||
|
|
||||||
function isSubscriptionActive()
|
function isSubscriptionActive()
|
||||||
|
|
@ -65,7 +67,7 @@ function getStripeCustomerPortalSession(Team $team)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return \Stripe\BillingPortal\Session::create([
|
return Session::create([
|
||||||
'customer' => $stripe_customer_id,
|
'customer' => $stripe_customer_id,
|
||||||
'return_url' => $return_url,
|
'return_url' => $return_url,
|
||||||
]);
|
]);
|
||||||
|
|
@ -80,6 +82,9 @@ function allowedPathsForUnsubscribedAccounts()
|
||||||
'two-factor-challenge',
|
'two-factor-challenge',
|
||||||
'livewire/update',
|
'livewire/update',
|
||||||
'admin',
|
'admin',
|
||||||
|
// Account basics stay available without a paid plan.
|
||||||
|
'profile',
|
||||||
|
'profile/appearance',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function allowedPathsForBoardingAccounts()
|
function allowedPathsForBoardingAccounts()
|
||||||
|
|
@ -114,7 +119,7 @@ function updateStripeCustomerEmail(Team $team, string $newEmail): void
|
||||||
|
|
||||||
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
Stripe::setApiKey(config('subscription.stripe_api_key'));
|
||||||
|
|
||||||
\Stripe\Customer::update(
|
Customer::update(
|
||||||
$stripe_customer_id,
|
$stripe_customer_id,
|
||||||
['email' => $newEmail]
|
['email' => $newEmail]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,11 @@
|
||||||
"zircote/swagger-php": "^5.8.0"
|
"zircote/swagger-php": "^5.8.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"barryvdh/laravel-debugbar": "^3.16.5",
|
|
||||||
"driftingly/rector-laravel": "^2.1.9",
|
"driftingly/rector-laravel": "^2.1.9",
|
||||||
"fakerphp/faker": "^1.24.1",
|
"fakerphp/faker": "^1.24.1",
|
||||||
"laravel/boost": "^2.1",
|
"laravel/boost": "^2.1",
|
||||||
"laravel/dusk": "^8.3.4",
|
"laravel/dusk": "^8.3.4",
|
||||||
"laravel/pint": "^1.27",
|
"laravel/pint": "^1.27",
|
||||||
"laravel/telescope": "^5.16.1",
|
|
||||||
"mockery/mockery": "^1.6.12",
|
"mockery/mockery": "^1.6.12",
|
||||||
"nunomaduro/collision": "^8.8.3",
|
"nunomaduro/collision": "^8.8.3",
|
||||||
"pestphp/pest": "^4.3.2",
|
"pestphp/pest": "^4.3.2",
|
||||||
|
|
@ -104,13 +102,6 @@
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"sort-packages": true
|
"sort-packages": true
|
||||||
},
|
},
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"dont-discover": [
|
|
||||||
"laravel/telescope"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||||
|
|
|
||||||
232
composer.lock
generated
232
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "3bc1d41688bd98785b556172ae2410ed",
|
"content-hash": "69407ef96f7081245b664c24115fd382",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -13075,91 +13075,6 @@
|
||||||
],
|
],
|
||||||
"time": "2025-08-24T17:25:34+00:00"
|
"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",
|
"name": "brianium/paratest",
|
||||||
"version": "v7.20.0",
|
"version": "v7.20.0",
|
||||||
|
|
@ -14051,75 +13966,6 @@
|
||||||
},
|
},
|
||||||
"time": "2026-03-05T07:58:43+00:00"
|
"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",
|
"name": "league/uri-components",
|
||||||
"version": "7.8.1",
|
"version": "7.8.1",
|
||||||
|
|
@ -15035,80 +14881,6 @@
|
||||||
},
|
},
|
||||||
"time": "2022-02-21T01:04:05+00:00"
|
"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",
|
"name": "php-webdriver/webdriver",
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
|
|
@ -17489,5 +17261,5 @@
|
||||||
"php": "^8.4"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
110
config/app.php
110
config/app.php
|
|
@ -1,6 +1,35 @@
|
||||||
<?php
|
<?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\Support\Facades\Facade;
|
||||||
|
use Illuminate\Translation\TranslationServiceProvider;
|
||||||
|
use Illuminate\Validation\ValidationServiceProvider;
|
||||||
|
use Illuminate\View\ViewServiceProvider;
|
||||||
|
use SocialiteProviders\Manager\ServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
|
|
@ -45,6 +74,29 @@
|
||||||
|
|
||||||
'debug' => (bool) env('APP_DEBUG', false),
|
'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
|
| Application URL
|
||||||
|
|
@ -161,45 +213,45 @@
|
||||||
/*
|
/*
|
||||||
* Laravel Framework Service Providers...
|
* Laravel Framework Service Providers...
|
||||||
*/
|
*/
|
||||||
Illuminate\Auth\AuthServiceProvider::class,
|
AuthServiceProvider::class,
|
||||||
Illuminate\Broadcasting\BroadcastServiceProvider::class,
|
BroadcastServiceProvider::class,
|
||||||
Illuminate\Bus\BusServiceProvider::class,
|
BusServiceProvider::class,
|
||||||
Illuminate\Cache\CacheServiceProvider::class,
|
CacheServiceProvider::class,
|
||||||
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
|
ConsoleSupportServiceProvider::class,
|
||||||
Illuminate\Cookie\CookieServiceProvider::class,
|
CookieServiceProvider::class,
|
||||||
Illuminate\Database\DatabaseServiceProvider::class,
|
DatabaseServiceProvider::class,
|
||||||
Illuminate\Encryption\EncryptionServiceProvider::class,
|
EncryptionServiceProvider::class,
|
||||||
Illuminate\Filesystem\FilesystemServiceProvider::class,
|
FilesystemServiceProvider::class,
|
||||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
FoundationServiceProvider::class,
|
||||||
Illuminate\Hashing\HashServiceProvider::class,
|
HashServiceProvider::class,
|
||||||
Illuminate\Mail\MailServiceProvider::class,
|
MailServiceProvider::class,
|
||||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
NotificationServiceProvider::class,
|
||||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
PaginationServiceProvider::class,
|
||||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
PipelineServiceProvider::class,
|
||||||
Illuminate\Queue\QueueServiceProvider::class,
|
QueueServiceProvider::class,
|
||||||
Illuminate\Redis\RedisServiceProvider::class,
|
RedisServiceProvider::class,
|
||||||
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
|
PasswordResetServiceProvider::class,
|
||||||
Illuminate\Session\SessionServiceProvider::class,
|
SessionServiceProvider::class,
|
||||||
Illuminate\Translation\TranslationServiceProvider::class,
|
TranslationServiceProvider::class,
|
||||||
Illuminate\Validation\ValidationServiceProvider::class,
|
ValidationServiceProvider::class,
|
||||||
Illuminate\View\ViewServiceProvider::class,
|
ViewServiceProvider::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Package Service Providers...
|
* Package Service Providers...
|
||||||
*/
|
*/
|
||||||
\SocialiteProviders\Manager\ServiceProvider::class,
|
ServiceProvider::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Application Service Providers...
|
* Application Service Providers...
|
||||||
*/
|
*/
|
||||||
App\Providers\AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
FortifyServiceProvider::class,
|
||||||
App\Providers\AuthServiceProvider::class,
|
App\Providers\AuthServiceProvider::class,
|
||||||
App\Providers\BroadcastServiceProvider::class,
|
App\Providers\BroadcastServiceProvider::class,
|
||||||
App\Providers\EventServiceProvider::class,
|
EventServiceProvider::class,
|
||||||
App\Providers\HorizonServiceProvider::class,
|
HorizonServiceProvider::class,
|
||||||
App\Providers\RouteServiceProvider::class,
|
RouteServiceProvider::class,
|
||||||
App\Providers\ConfigurationServiceProvider::class,
|
ConfigurationServiceProvider::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
'services' => [
|
'services' => [
|
||||||
'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json',
|
'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json',
|
||||||
'file_name' => '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' => [
|
'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 @@
|
||||||
'base_url' => env('ZITADEL_BASE_URL'),
|
'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 Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Team>
|
* @extends Factory<Team>
|
||||||
*/
|
*/
|
||||||
class TeamFactory extends Factory
|
class TeamFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|
@ -24,6 +24,7 @@ public function definition(): array
|
||||||
'description' => $this->faker->sentence(),
|
'description' => $this->faker->sentence(),
|
||||||
'personal_team' => false,
|
'personal_team' => false,
|
||||||
'show_boarding' => false,
|
'show_boarding' => false,
|
||||||
|
'is_mcp_server_enabled' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the migration connection name.
|
|
||||||
*/
|
|
||||||
public function getConnection(): ?string
|
|
||||||
{
|
|
||||||
return config('telescope.storage.database.connection');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
$schema = Schema::connection($this->getConnection());
|
|
||||||
|
|
||||||
$schema->create('telescope_entries', function (Blueprint $table) {
|
|
||||||
$table->bigIncrements('sequence');
|
|
||||||
$table->uuid('uuid');
|
|
||||||
$table->uuid('batch_id');
|
|
||||||
$table->string('family_hash')->nullable();
|
|
||||||
$table->boolean('should_display_on_index')->default(true);
|
|
||||||
$table->string('type', 20);
|
|
||||||
$table->longText('content');
|
|
||||||
$table->dateTime('created_at')->nullable();
|
|
||||||
|
|
||||||
$table->unique('uuid');
|
|
||||||
$table->index('batch_id');
|
|
||||||
$table->index('family_hash');
|
|
||||||
$table->index('created_at');
|
|
||||||
$table->index(['type', 'should_display_on_index']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$schema->create('telescope_entries_tags', function (Blueprint $table) {
|
|
||||||
$table->uuid('entry_uuid');
|
|
||||||
$table->string('tag');
|
|
||||||
|
|
||||||
$table->primary(['entry_uuid', 'tag']);
|
|
||||||
$table->index('tag');
|
|
||||||
|
|
||||||
$table->foreign('entry_uuid')
|
|
||||||
->references('uuid')
|
|
||||||
->on('telescope_entries')
|
|
||||||
->onDelete('cascade');
|
|
||||||
});
|
|
||||||
|
|
||||||
$schema->create('telescope_monitoring', function (Blueprint $table) {
|
|
||||||
$table->string('tag')->primary();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
$schema = Schema::connection($this->getConnection());
|
|
||||||
|
|
||||||
$schema->dropIfExists('telescope_entries_tags');
|
|
||||||
$schema->dropIfExists('telescope_entries');
|
|
||||||
$schema->dropIfExists('telescope_monitoring');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->json('domain_dns_statuses')->nullable()->after('docker_compose_domains');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('applications', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('domain_dns_statuses');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('service_applications', function (Blueprint $table) {
|
||||||
|
$table->json('domain_dns_statuses')->nullable()->after('fqdn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('service_applications', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('domain_dns_statuses');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('service_applications', function (Blueprint $table) {
|
||||||
|
$table->enum('redirect', ['www', 'non-www', 'both'])->default('both')->after('fqdn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('service_applications', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('redirect');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->text('domain_connect_private_key')->nullable()->after('custom_dns_servers');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('instance_settings', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('domain_connect_private_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1280,25 +1280,8 @@ CREATE TABLE IF NOT EXISTS "telegram_notification_settings" (
|
||||||
"traefik_outdated_telegram_notifications" INTEGER DEFAULT true NOT NULL
|
"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" (
|
CREATE TABLE IF NOT EXISTS "user_changelog_reads" (
|
||||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
"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_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 "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 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_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 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);
|
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 (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 (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 (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 (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 (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);
|
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": {
|
"coolify": {
|
||||||
"v4": {
|
"v4": {
|
||||||
"version": "4.2.0"
|
"version": "4.1.2"
|
||||||
},
|
},
|
||||||
"nightly": {
|
"nightly": {
|
||||||
"version": "4.2.0"
|
"version": "4.2.0"
|
||||||
|
|
@ -17,13 +17,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"traefik": {
|
"traefik": {
|
||||||
"v3.6": "3.6.11",
|
"v3.7": "3.7.8",
|
||||||
|
"v3.6": "3.6.23",
|
||||||
"v3.5": "3.5.6",
|
"v3.5": "3.5.6",
|
||||||
"v3.4": "3.4.5",
|
"v3.4": "3.4.5",
|
||||||
"v3.3": "3.3.7",
|
"v3.3": "3.3.7",
|
||||||
"v3.2": "3.2.5",
|
"v3.2": "3.2.5",
|
||||||
"v3.1": "3.1.7",
|
"v3.1": "3.1.7",
|
||||||
"v3.0": "3.0.4",
|
"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="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
<!-- The v5 bootstrap endpoint refuses to queue without a Flux URL; tests
|
<!-- The v5 bootstrap endpoint refuses to queue without a Flux URL; tests
|
||||||
that exercise the unconfigured path blank it via Config::set. -->
|
that exercise the unconfigured path blank it via Config::set. -->
|
||||||
|
|
|
||||||
8
public/vendor/telescope/app-dark.css
vendored
8
public/vendor/telescope/app-dark.css
vendored
File diff suppressed because one or more lines are too long
7
public/vendor/telescope/app.css
vendored
7
public/vendor/telescope/app.css
vendored
File diff suppressed because one or more lines are too long
2
public/vendor/telescope/app.js
vendored
2
public/vendor/telescope/app.js
vendored
File diff suppressed because one or more lines are too long
BIN
public/vendor/telescope/favicon.ico
vendored
BIN
public/vendor/telescope/favicon.ico
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
5
public/vendor/telescope/mix-manifest.json
vendored
5
public/vendor/telescope/mix-manifest.json
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"/app.js": "/app.js?id=6a9d3c0fef12c9aadd063e3a357cf7cf",
|
|
||||||
"/app-dark.css": "/app-dark.css?id=1ea407db56c5163ae29311f1f38eb7b9",
|
|
||||||
"/app.css": "/app.css?id=de4c978567bfd90b38d186937dee5ccf"
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -59,11 +59,11 @@ @utility input-focus {
|
||||||
|
|
||||||
/* input, select before */
|
/* input, select before */
|
||||||
@utility input-select {
|
@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;
|
@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: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
|
box-shadow: none;
|
||||||
|
|
||||||
&:where(.dark, .dark *) {
|
&:where(.dark, .dark *) {
|
||||||
box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
@ -82,11 +82,13 @@ @utility input {
|
||||||
@apply focus-visible:outline-none;
|
@apply focus-visible:outline-none;
|
||||||
|
|
||||||
&:focus-visible {
|
&: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 {
|
&: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 {
|
&:read-only {
|
||||||
|
|
@ -113,20 +115,37 @@ @utility select {
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&: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 {
|
&: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 {
|
@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 {
|
@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 {
|
@utility alert-success {
|
||||||
|
|
@ -145,6 +164,10 @@ @utility add-tag {
|
||||||
@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;
|
@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 {
|
@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;
|
@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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility menu-item {
|
@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 {
|
@utility menu-item-icon {
|
||||||
@apply shrink-0 size-4 dark:hover:text-white;
|
@apply shrink-0 size-[18px] opacity-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility menu-item-label {
|
@utility menu-item-label {
|
||||||
|
|
@ -193,15 +216,28 @@ @utility menu-item-label {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility menu-item-active {
|
@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 {
|
@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 {
|
@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 {
|
@utility sub-menu-item-icon {
|
||||||
|
|
@ -217,7 +253,7 @@ @utility icon {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility scrollbar {
|
@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 {
|
@utility main {
|
||||||
|
|
@ -229,7 +265,7 @@ @utility custom-modal {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility navbar-main {
|
@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 {
|
@utility loading {
|
||||||
|
|
@ -241,7 +277,7 @@ @utility kbd-custom {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility box {
|
@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 {
|
@utility box-boarding {
|
||||||
|
|
@ -257,7 +293,7 @@ @utility box-without-bg-without-border {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility coolbox {
|
@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 {
|
@utility on-box {
|
||||||
|
|
@ -265,11 +301,11 @@ @utility on-box {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility box-title {
|
@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 {
|
@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 {
|
@utility description {
|
||||||
|
|
@ -281,15 +317,15 @@ @utility bg-coollabs-gradient {
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility text-helper {
|
@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 {
|
@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 {
|
@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 {
|
@utility buyme {
|
||||||
|
|
|
||||||
20
resources/domain-connect/coolify.io.hosting.json
Normal file
20
resources/domain-connect/coolify.io.hosting.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,7 @@ document.addEventListener('livewire:navigated', () => {
|
||||||
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
|
document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
|
||||||
});
|
});
|
||||||
|
|
||||||
['livewire:navigated', 'alpine:init'].forEach((event) => {
|
// Register the terminal data provider before Alpine initializes the page.
|
||||||
document.addEventListener(event, () => {
|
// Keeping this registration independent from the current route also makes it
|
||||||
// tree-shaking
|
// available before Alpine processes terminal markup after wire:navigate.
|
||||||
if (document.getElementById('terminal-container')) {
|
document.addEventListener('alpine:init', initializeTerminalComponent);
|
||||||
initializeTerminalComponent()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,96 @@ import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
|
||||||
const terminalDebugEnabled = import.meta.env.DEV;
|
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) {
|
function logTerminal(level, message, ...context) {
|
||||||
if (!terminalDebugEnabled) {
|
if (!terminalDebugEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -59,11 +149,23 @@ export function initializeTerminalComponent() {
|
||||||
isDocumentVisible: true,
|
isDocumentVisible: true,
|
||||||
wasConnectedBeforeHidden: false,
|
wasConnectedBeforeHidden: false,
|
||||||
mobileToolbarCollapsed: 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,
|
terminalSessionStartedAt: null,
|
||||||
terminalSessionRemainingSeconds: null,
|
terminalSessionRemainingSeconds: null,
|
||||||
terminalSessionCountdownInterval: null,
|
terminalSessionCountdownInterval: null,
|
||||||
|
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
|
||||||
|
? localStorage.getItem('coolify-console-theme')
|
||||||
|
: 'shadows-cosmic-purple',
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
// Recover if a previous portal build left the terminal on <body>.
|
||||||
|
this.$nextTick(() => this.salvageStrayFullscreenNodes());
|
||||||
|
|
||||||
this.setupTerminal();
|
this.setupTerminal();
|
||||||
|
|
||||||
// Add a small delay for initial connection to ensure everything is ready
|
// 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);
|
this.resizeObserver.observe(this.$refs.terminalWrapper);
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// Stop observing when terminal is inactive
|
||||||
if (this.resizeObserver) {
|
if (this.resizeObserver) {
|
||||||
|
|
@ -146,6 +250,8 @@ export function initializeTerminalComponent() {
|
||||||
this.connectionState = 'disconnected';
|
this.connectionState = 'disconnected';
|
||||||
this.pendingCommand = null;
|
this.pendingCommand = null;
|
||||||
this.resetTerminalSessionCountdown();
|
this.resetTerminalSessionCountdown();
|
||||||
|
this.exitFullscreen();
|
||||||
|
this.unlockPageScroll();
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close(1000, 'Client cleanup');
|
this.socket.close(1000, 'Client cleanup');
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +338,32 @@ export function initializeTerminalComponent() {
|
||||||
return 'text-neutral-300 bg-black/70 border-white/10';
|
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() {
|
resetTerminal() {
|
||||||
if (this.term) {
|
if (this.term) {
|
||||||
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
|
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
|
||||||
|
|
@ -265,14 +397,24 @@ export function initializeTerminalComponent() {
|
||||||
setupTerminal() {
|
setupTerminal() {
|
||||||
const terminalElement = document.getElementById('terminal');
|
const terminalElement = document.getElementById('terminal');
|
||||||
if (terminalElement) {
|
if (terminalElement) {
|
||||||
|
const isApplicationConsole = terminalElement.dataset.terminalStyle === 'application';
|
||||||
this.term = new Terminal({
|
this.term = new Terminal({
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 30,
|
rows: 30,
|
||||||
fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"',
|
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,
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
rendererType: 'canvas',
|
rendererType: 'canvas',
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
disableStdin: false
|
disableStdin: false,
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: isApplicationConsole
|
||||||
|
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes['shadows-cosmic-purple']
|
||||||
|
: undefined
|
||||||
});
|
});
|
||||||
this.fitAddon = new FitAddon();
|
this.fitAddon = new FitAddon();
|
||||||
this.term.loadAddon(this.fitAddon);
|
this.term.loadAddon(this.fitAddon);
|
||||||
|
|
@ -527,7 +669,7 @@ export function initializeTerminalComponent() {
|
||||||
// Notify parent component that terminal connection failed
|
// Notify parent component that terminal connection failed
|
||||||
this.$wire.dispatch('terminalDisconnected');
|
this.$wire.dispatch('terminalDisconnected');
|
||||||
} else if (event.data === 'pty-exited') {
|
} else if (event.data === 'pty-exited') {
|
||||||
this.fullscreen = false;
|
this.exitFullscreen();
|
||||||
this.mobileToolbarCollapsed = false;
|
this.mobileToolbarCollapsed = false;
|
||||||
this.terminalActive = false;
|
this.terminalActive = false;
|
||||||
this.resetTerminalSessionCountdown();
|
this.resetTerminalSessionCountdown();
|
||||||
|
|
@ -735,73 +877,352 @@ export function initializeTerminalComponent() {
|
||||||
},
|
},
|
||||||
|
|
||||||
makeFullscreen() {
|
makeFullscreen() {
|
||||||
this.fullscreen = !this.fullscreen;
|
if (this.fullscreen) {
|
||||||
this.$nextTick(() => {
|
this.exitFullscreen();
|
||||||
// Force a layout reflow to ensure DOM changes are applied
|
} else {
|
||||||
this.$refs.terminalWrapper.offsetHeight;
|
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();
|
this.resizeTerminal();
|
||||||
}, 100);
|
setTimeout(() => this.resizeTerminal(), 50);
|
||||||
|
setTimeout(() => this.resizeTerminal(), 150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
resizeTerminal() {
|
resizeTerminal() {
|
||||||
if (!this.terminalActive || !this.term || !this.fitAddon) return;
|
if (!this.terminalActive || !this.term || !this.fitAddon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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();
|
this.fitAddon.fit();
|
||||||
|
|
||||||
// Get fresh dimensions from the terminal element itself. The mobile
|
// Keep the xterm chrome inside the host so overflow becomes scrollback,
|
||||||
// toolbar can live beside the terminal in normal flow, so wrapper dimensions
|
// not document growth after leaving fullscreen.
|
||||||
// would include controls that should not be counted as terminal rows.
|
if (this.term.element) {
|
||||||
const terminalElement = document.getElementById('terminal');
|
this.term.element.style.width = '100%';
|
||||||
const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
|
this.term.element.style.height = '100%';
|
||||||
const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
|
this.term.element.style.maxHeight = '100%';
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const charSize = this.term._core._renderService._charSizeService;
|
if (
|
||||||
|
this.term.cols > 0 &&
|
||||||
if (!charSize.height || !charSize.width) {
|
this.term.rows > 0 &&
|
||||||
// Fallback values if char size not available yet
|
(this.term.cols !== previousCols || this.term.rows !== previousRows)
|
||||||
logTerminal('warn', '[Terminal] Character size not available, retrying...');
|
) {
|
||||||
setTimeout(() => this.resizeTerminal(), 100);
|
this.sendMessage({
|
||||||
return;
|
resize: { cols: this.term.cols, rows: this.term.rows },
|
||||||
}
|
});
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logTerminal('error', '[Terminal] Resize error:', error);
|
logTerminal('error', '[Terminal] Resize error:', error);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue