diff --git a/.env.development.example b/.env.development.example index 29162a59a..6c0fe2189 100644 --- a/.env.development.example +++ b/.env.development.example @@ -9,6 +9,7 @@ APP_PORT=8000 APP_DEBUG=true SSH_MUX_ENABLED=true COOLIFY_CONTAINER_ROLE=all +DEV_SENTINEL_URL= # PostgreSQL Database Configuration DB_DATABASE=coolify diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index c89e11d7b..06c5f9eb3 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -113,4 +113,3 @@ jobs: if: always() with: webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} - diff --git a/.github/workflows/coolify-release.yml b/.github/workflows/coolify-release.yml index 9cd3b90e5..7951005ee 100644 --- a/.github/workflows/coolify-release.yml +++ b/.github/workflows/coolify-release.yml @@ -1,12 +1,18 @@ -name: Release Coolify +name: Release Coolify Stable on: - release: - types: [published] + workflow_dispatch: + inputs: + tag: + description: Existing draft release tag (for example, v4.3.1) + required: true + type: string -permissions: - contents: read - packages: write +permissions: {} + +concurrency: + group: coolify-fix-release + cancel-in-progress: false env: GITHUB_REGISTRY: ghcr.io @@ -14,14 +20,117 @@ env: IMAGE_NAME: coollabsio/coolify jobs: - promote-image: + validate: runs-on: ubuntu-24.04 + environment: production-release + permissions: + contents: write + outputs: + release_id: ${{ steps.draft.outputs.release_id }} + version: ${{ steps.version.outputs.version }} steps: + - name: Reject releases outside v4.x + if: ${{ github.ref_name != 'v4.x' }} + run: | + echo "Fix releases must run from v4.x, not ${{ github.ref_name }}." + exit 1 + - uses: actions/checkout@v5 with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.event.release.tag_name }} + + - name: Validate version + id: version + env: + TAG_NAME: ${{ inputs.tag }} + run: | + if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unsupported fix release tag: ${TAG_NAME}" + exit 1 + fi + + VERSION="${TAG_NAME#v}" + CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php) + + if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then + echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}." + exit 1 + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Validate and pin draft release + id: draft + uses: actions/github-script@v8 + env: + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releases = await github.paginate(github.rest.repos.listReleases, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME); + + if (!release) { + core.setFailed(`Create a draft release for ${process.env.TAG_NAME} before running this workflow.`); + return; + } + if (!release.draft) { + core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`); + return; + } + if (release.prerelease) { + core.setFailed(`Fix release ${process.env.TAG_NAME} cannot be marked as a prerelease.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft release ${process.env.TAG_NAME} must contain reviewed release notes.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + }); + core.setOutput('release_id', release.id); + + build: + needs: validate + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false - uses: docker/setup-buildx-action@v3 @@ -39,69 +148,112 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Resolve release image - id: release - env: - TAG_NAME: ${{ github.event.release.tag_name }} - run: | - if [[ ! "${TAG_NAME}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then - echo "Unsupported release tag: ${TAG_NAME}" - exit 1 - fi - - VERSION="${TAG_NAME#v}" - RELEASE_SHA=$(git rev-list -n 1 "${TAG_NAME}") - CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php) - - if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then - echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}." - exit 1 - fi - - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "sha=${RELEASE_SHA}" >> "$GITHUB_OUTPUT" - - - name: Promote version on ${{ env.GITHUB_REGISTRY }} - env: - REGISTRY: ${{ env.GITHUB_REGISTRY }} - VERSION: ${{ steps.release.outputs.version }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - SOURCE_TAG="sha-${RELEASE_SHA}" - docker buildx imagetools create "${IMAGE}:${SOURCE_TAG}" --tag "${IMAGE}:${VERSION}" - - - name: Promote version on ${{ env.DOCKER_REGISTRY }} - env: - REGISTRY: ${{ env.DOCKER_REGISTRY }} - VERSION: ${{ steps.release.outputs.version }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - SOURCE_TAG="sha-${RELEASE_SHA}" - docker buildx imagetools create "${IMAGE}:${SOURCE_TAG}" --tag "${IMAGE}:${VERSION}" - - - name: Promote latest on ${{ env.GITHUB_REGISTRY }} - if: ${{ ! github.event.release.prerelease }} - env: - REGISTRY: ${{ env.GITHUB_REGISTRY }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - SOURCE_TAG="sha-${RELEASE_SHA}" - docker buildx imagetools create "${IMAGE}:${SOURCE_TAG}" --tag "${IMAGE}:latest" - - - name: Promote latest on ${{ env.DOCKER_REGISTRY }} - if: ${{ ! github.event.release.prerelease }} - env: - REGISTRY: ${{ env.DOCKER_REGISTRY }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - SOURCE_TAG="sha-${RELEASE_SHA}" - docker buildx imagetools create "${IMAGE}:${SOURCE_TAG}" --tag "${IMAGE}:latest" - - - uses: sarisia/actions-status-discord@v1 - if: always() + - name: Build and push release image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 with: - webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }} + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.validate.outputs.version }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }} + + publish: + needs: [validate, build] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish version and latest on ${{ env.GITHUB_REGISTRY }} + env: + REGISTRY: ${{ env.GITHUB_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:latest" + + - name: Publish version and latest on ${{ env.DOCKER_REGISTRY }} + env: + REGISTRY: ${{ env.DOCKER_REGISTRY }} + VERSION: ${{ needs.validate.outputs.version }} + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SOURCE="release-${VERSION}-${GITHUB_SHA}" + docker buildx imagetools create \ + "${IMAGE}:${SOURCE}-amd64" \ + "${IMAGE}:${SOURCE}-aarch64" \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:latest" + + - name: Publish reviewed draft release + uses: actions/github-script@v8 + env: + RELEASE_ID: ${{ needs.validate.outputs.release_id }} + TAG_NAME: ${{ inputs.tag }} + with: + script: | + const releaseId = Number(process.env.RELEASE_ID); + const { data: release } = await github.rest.repos.getRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: releaseId, + }); + + if (release.tag_name !== process.env.TAG_NAME || !release.draft || release.prerelease) { + core.setFailed(`Draft release ${process.env.TAG_NAME} changed while the images were building.`); + return; + } + if (!release.body?.trim()) { + core.setFailed(`Draft release ${process.env.TAG_NAME} no longer contains release notes.`); + return; + } + if (release.target_commitish !== context.sha) { + core.setFailed(`Draft release ${process.env.TAG_NAME} no longer targets ${context.sha}.`); + return; + } + + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${process.env.TAG_NAME}`, + }); + core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: Number(process.env.RELEASE_ID), + tag_name: process.env.TAG_NAME, + target_commitish: context.sha, + draft: false, + }); diff --git a/.github/workflows/coolify-sha-build.yml b/.github/workflows/coolify-sha-build.yml index 8c653e1d1..522dc21f5 100644 --- a/.github/workflows/coolify-sha-build.yml +++ b/.github/workflows/coolify-sha-build.yml @@ -2,7 +2,7 @@ name: Build Coolify (SHA) on: push: - branches: ["v4.x", "main"] + branches: ["v4.x"] permissions: contents: read @@ -30,6 +30,12 @@ jobs: with: persist-credentials: false + - name: Resolve internal version + id: version + run: | + BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php) + echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT" + - name: Login to ${{ env.GITHUB_REGISTRY }} uses: docker/login-action@v3 with: @@ -51,6 +57,8 @@ jobs: file: docker/production/Dockerfile platforms: ${{ matrix.platform }} push: true + build-args: | + COOLIFY_VERSION=${{ steps.version.outputs.version }} tags: | ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }} ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }} @@ -78,33 +86,21 @@ jobs: - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} env: REGISTRY: ${{ env.GITHUB_REGISTRY }} - BRANCH: ${{ github.ref_name }} SHA: ${{ github.sha }} run: | IMAGE="${REGISTRY}/${IMAGE_NAME}" - TAG_ARGS=(--tag "${IMAGE}:sha-${SHA}") - # Moving tag for the latest production-line SHA image (v4.x only). - if [ "${BRANCH}" = "v4.x" ]; then - TAG_ARGS+=(--tag "${IMAGE}:edge") - fi docker buildx imagetools create \ "${IMAGE}:sha-${SHA}-amd64" \ "${IMAGE}:sha-${SHA}-aarch64" \ - "${TAG_ARGS[@]}" + --tag "${IMAGE}:sha-${SHA}" - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }} env: REGISTRY: ${{ env.DOCKER_REGISTRY }} - BRANCH: ${{ github.ref_name }} SHA: ${{ github.sha }} run: | IMAGE="${REGISTRY}/${IMAGE_NAME}" - TAG_ARGS=(--tag "${IMAGE}:sha-${SHA}") - # Moving tag for the latest production-line SHA image (v4.x only). - if [ "${BRANCH}" = "v4.x" ]; then - TAG_ARGS+=(--tag "${IMAGE}:edge") - fi docker buildx imagetools create \ "${IMAGE}:sha-${SHA}-amd64" \ "${IMAGE}:sha-${SHA}-aarch64" \ - "${TAG_ARGS[@]}" + --tag "${IMAGE}:sha-${SHA}" diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index df0508c98..c5b70ca92 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -4,7 +4,6 @@ on: push: branches-ignore: - v4.x - - main - v3.x - '**v5.x**' paths-ignore: diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 1a16ec1e2..a5fb7c69d 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -2,7 +2,7 @@ name: Generate Changelog on: push: - branches: [ v4.x, main ] + branches: [ v4.x ] paths-ignore: - .github/workflows/coolify-helper.yml - .github/workflows/coolify-helper-next.yml diff --git a/DESIGN.md b/DESIGN.md index 3976dd12b..5546b28f0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -399,6 +399,34 @@ 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. +#### Livewire dropdown state synchronization + +Instant-save listboxes must not flash back to an older value while Livewire is +saving or morphing the DOM. Treat the Alpine selection as the current visual +state until its request finishes: + +- await the Livewire change handler and prevent overlapping selections while + it is running; +- when a client-managed listbox can be rerendered by an unrelated or stale + Livewire response, use the listbox's `preserveValue` option so the morph does + not replace its newer Alpine value; +- scope `preserveValue` to controls whose value is owned by that interaction; + do not use it when external server events must replace the displayed value; +- after saving through a related model, refresh the parent component's loaded + relationship before rendering the response. A database write alone does not + update an already-loaded Eloquent collection; +- use stable `wire:key` values for rows containing listboxes. Do not include the + selected value in the key, because recreating the Alpine component causes a + visible reset; +- remember that a portalled options panel is teleported outside its visual + wrapper. Guard selection in the Alpine handler itself rather than relying + only on `pointer-events` or a disabled wrapper. + +The failure mode to avoid is: selection B is shown optimistically, selection A +is chosen next, the response for B morphs the listbox back to B, then the later +response finally shows A. The control should remain on the newest accepted +selection throughout the save sequence. + #### Multi-select filter dropdowns Toolbar filters that can combine criteria use one multi-select listbox rather diff --git a/RELEASE.md b/RELEASE.md index e83609ad3..493c690f1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,184 +1,66 @@ # Coolify Release Guide -This guide outlines the release process for Coolify, intended for developers and those interested in understanding how Coolify releases are managed and deployed. +## Branches -## Table of Contents -- [Branch Strategy](#branch-strategy) -- [Release Process](#release-process) -- [Version Types](#version-types) - - [Stable](#stable) - - [Nightly](#nightly) - - [Beta](#beta) -- [Version Availability](#version-availability) - - [Self-Hosted](#self-hosted) - - [Cloud](#cloud) -- [Manually Update to Specific Versions](#manually-update-to-specific-versions) +| Branch | Purpose | +| --- | --- | +| `main` | Latest production source | +| `next` | Feature integration and RC releases | +| `feature/*` | New features based on and merged into `next` | +| `hotfix/X.Y.Z` | Production fixes based on `main` | -## Branch Strategy +Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. -Coolify uses two long-lived branches so production fixes can ship without waiting on unfinished feature work. +## Feature and RC flow -| Branch | Role | Docker image tags | How it ships | -| --- | --- | --- | --- | -| **`v4.x`** | Production / releasable line | `sha-` and moving `edge` via **Build Coolify (SHA)** | GitHub release promotes the SHA image to a semantic version (and `latest` for stable releases) | -| **`next`** | Development line for features and larger changes | Branch tag (for example `next`) via **Staging Build** | Becomes production only after merge into `v4.x` | - -### Where to merge - -- **Fixes and release-ready patches** → open PRs against **`v4.x`**. This is the fast path for patch releases. -- **Features, refactors, and experimental work** → open PRs against **`next`** (or a feature branch that targets `next`). -- **Shipping features to production** → merge `next` into `v4.x` when the feature set is ready for a stable (or beta) release. Prefer a deliberate merge, not ad-hoc cherry-picks of large feature stacks. - -### Keeping the branches in sync - -- After each fix lands on `v4.x` (and after each production release), **merge `v4.x` back into `next`** so fixes are not lost and `next` does not reintroduce already-shipped bugs. -- When `next` has unfinished work and you need a hotfix, **open a small PR to `v4.x`** or **cherry-pick the fix commit** onto `v4.x`. Do not merge half-finished feature work from `next` just to ship a fix. -- Treat **database migrations and irreversible data changes** carefully when the branches diverge. Prefer minimal, forward-compatible migrations on the fix path. - -### Mental model - -``` -next ── features, refactors, experiments ──► (when ready) merge into v4.x - ▲ - │ regularly merge fixes back - │ -v4.x ── fixes / release prep ──► Build Coolify (SHA) ──► Release Coolify ──► CDN +```text +feature/* → next → RC ``` -Only commits on **`v4.x`** produce production SHA images and can be tagged for a GitHub release. +1. Merge feature branches into `next`. +2. Set the intended RC version on `next`, such as `4.4-rc.1`. +3. Regular builds publish `sha-`, `4.4-rc.1.`, and the moving `next` tag. +4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. +5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft. +6. Advance `next` to the next intended RC version. -## Release Process +## Stable release flow -1. **Prepare the Release** - - Land the work on **`v4.x`**: merge a fix PR into `v4.x`, or merge ready work from `next` into `v4.x` for a feature release. - - Set the release version in `config/constants.php` and `versions.json` on the commit you will tag. Both values must match the planned Git tag without the `v` prefix (for example, `4.2.0` for tag `v4.2.0`). - - Verify the changelog and required tests before merging. - - After the release (or after the fix merges), merge `v4.x` back into `next` if those branches have diverged. - -2. **Build the Release Commit** - - Merge the release commit into `v4.x` through a pull request. - - The `Build Coolify (SHA)` workflow builds AMD64 and ARM64 images and publishes them to Docker Hub and GHCR using immutable architecture tags. - - After both builds complete, the workflow creates the multi-architecture `sha-` 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-` tags. - - This workflow does not update a semantic version tag or `latest`. - -3. **Wait for the SHA Image** - - Confirm the complete `Build Coolify (SHA)` workflow, including its `merge-manifest` job, succeeded. - - Do not publish the release before the multi-architecture SHA image exists in both registries. - -4. **Create and Publish the GitHub Release** - - Create a GitHub release with a semantic version tag such as `v4.2.0`, targeting the exact commit that produced the SHA image. - - Mark beta or other test releases as prereleases. Publish production versions as stable releases. - - Publishing the release starts the `Release Coolify` workflow. It verifies that the Git tag matches `config/constants.php`, then promotes the existing SHA image without rebuilding it. - - The workflow assigns the semantic version tag in Docker Hub and GHCR. Stable releases also update `latest`; prereleases do not. - -5. **Verify the Promotion** - - Confirm the `Release Coolify` workflow succeeded. - - Verify the semantic version image has the same manifest digest as `sha-` in Docker Hub and GHCR. - - For stable releases, also verify `latest` points to the promoted release manifest. - -6. **Update the CDN** - - To make a new version available to self-hosted instances, update the version information on the CDN manually. - - Confirm the new version is available at [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json). - -> [!NOTE] -> The CDN update may not occur immediately after the GitHub release. It can take hours or even days due to additional testing, stability checks, or potential hotfixes. **The update becomes available only after the CDN is updated. After the CDN is updated, a discord announcement will be made in the Production Release channel.** - -## Version Types - -
- Stable - -- **Stable** - - The production version suitable for stable, production environments (recommended). - - **Update Frequency:** Every 2 to 4 weeks, with more frequent possible fixes. - - **Release Size:** Larger but less frequent releases. Multiple nightly versions are consolidated into a single stable release. - - **Versioning Scheme:** Follows semantic versioning (e.g., `v4.0.0`, `4.1.0`, etc.). - - **Installation Command:** - ```bash - curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash - ``` - -
- -
- Nightly - -- **Nightly** - - The latest development version, suitable for testing the latest changes and experimenting with new features. - - **Update Frequency:** Daily or bi-weekly updates. - - **Release Size:** Smaller, more frequent releases. - - **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-nightly.1`, `4.1.0-nightly.2`, etc.). - - **Installation Command:** - ```bash - curl -fsSL https://cdn.coollabs.io/coolify-nightly/install.sh | bash -s next - ``` - -
- -
- Beta - -- **Beta** - - Test releases for the upcoming stable version. - - **Purpose:** Allows users to test and provide feedback on new features and changes before they become stable. - - **Update Frequency:** Available if we think beta testing is necessary. - - **Release Size:** Same size as stable release as it will become the next stable release after some time. - - **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-beta.1`, `4.1.0-beta.2`, etc.). - - **Installation Command:** - ```bash - curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash - ``` - -
- -> [!WARNING] -> Do not use nightly/beta builds in production as there is no guarantee of stability. - -## Version Availability - -When a new version is released and a new GitHub release is created, it doesn't immediately become available for your instance. Here's how version availability works for different instance types. - -### Self-Hosted - -- **Update Frequency:** More frequent updates, especially on the nightly release channel. -- **Update Availability:** New versions are available once the CDN has been updated. -- **Update Methods:** - 1. **Manual Update in Instance Settings:** - - Go to `Settings > Update Check Frequency` and click the `Check Manually` button. - - If an update is available, an upgrade button will appear on the sidebar. - 2. **Automatic Update:** - - If enabled, the instance will update automatically at the time set in the settings. - 3. **Re-run Installation Script:** - - Run the installation script again to upgrade to the latest version available on the CDN: - ```bash - curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash - ``` - -> [!IMPORTANT] -> If a new release is available on GitHub but your instance hasn't updated yet or no upgrade button is shown in the UI, the CDN might not have been updated yet. This intentional delay ensures stability and allows for hotfixes before official release. - -### Cloud - -- **Update Frequency:** Less frequent as it's a managed service. -- **Update Availability:** New versions are available once Andras has updated the cloud version manually. -- **Update Method:** - - Updates are managed by Andras, who ensures each cloud version is thoroughly tested and stable before releasing it. - -> [!IMPORTANT] -> The cloud version of Coolify may be several versions behind the latest GitHub releases even if the CDN is updated. This is intentional to ensure stability and reliability for cloud users and Andras will manually update the cloud version when the update is ready. - -## Manually Update/ Downgrade to Specific Versions - -> [!CAUTION] -> Updating to unreleased versions is not recommended and can cause issues. - -> [!IMPORTANT] -> Downgrading is supported but not recommended and can cause issues because of database migrations and other changes. - -To update your Coolify instance to a specific version, use the following command: - -```bash -curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s +```text +next → main → stable release ``` -Replace `` with the version you want to update to (for example `4.0.0-beta.332`). + +1. Temporarily stop merging features into `next`. +2. Change the version on `next` from the approved RC to the stable version, such as `4.4.0`. +3. Merge `next` into `main`. +4. Create a reviewed draft GitHub Release named `v4.4.0`. +5. Run the stable release workflow from `main`. +6. The workflow rebuilds the exact stable version, publishes `4.4.0` and `latest`, then publishes the draft. +7. Update the CDN only after the release is approved. +8. Advance `next` to the next development version. + +## Hotfix flow + +```text +main → hotfix/X.Y.Z → main → next +``` + +1. Create `hotfix/X.Y.Z` from `main` and set the intended patch version. +2. Implement and test the fix. SHA images report `X.Y.Z-dev.`. +3. Merge the hotfix into `main`. +4. Create a reviewed draft GitHub Release named `vX.Y.Z`. +5. Run the stable release workflow from `main`. +6. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch. +7. Update the CDN only after the release is approved. + +## Image tags + +| Tag | Meaning | +| --- | --- | +| `latest` | Latest stable release | +| `next` | Latest successful `next` build | +| `X.Y.Z` | Exact stable release | +| `X.Y-rc.N` | Exact RC release | +| `sha-` | Exact commit build | + +Git tags use the `v` prefix, such as `v4.4.0`. Docker image tags do not. diff --git a/UI_REDESIGN.md b/UI_REDESIGN.md deleted file mode 100644 index bc690cd1c..000000000 --- a/UI_REDESIGN.md +++ /dev/null @@ -1,687 +0,0 @@ -# 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 -`` (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. -> - Use `` for dropdown controls. Never add a native -> `` on any redesigned route, including mobile -fallbacks. Use: - -```blade - -``` - -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. - -#### Multi-select filter dropdowns - -Toolbar filters that can combine criteria use one multi-select listbox rather -than separate dropdowns or a single selected value. Follow the deployment -history filter in -`resources/views/livewire/project/application/deployment/index.blade.php`: - -- set `aria-multiselectable="true"` on the listbox; -- group related options under compact uppercase labels; -- keep the dropdown open while options are toggled; -- use the shared 16px custom checkbox treatment: purple checked fill in light - mode, yellow checked fill in dark mode, and a high-contrast check mark; -- show the number of active selections in a small count pill on the static - `Filter` trigger; -- combine selections within one group with OR logic and combine different - groups with AND logic; -- constrain only the options area with `max-h-80 overflow-y-auto`; -- place a persistent `Reset filters` action in a separate footer below the - scrollable options, divided by a top border; -- disable the reset action when no filter is active, and close the dropdown - after resetting. - -Do not represent the empty state as a selectable `All` option. The footer reset -action is the single way to return the multi-select to its unfiltered state. - -### Standard table controls - -Dense tables use the shared `x-table.*` components so search, filters, sorting, -and backend loading states remain visually and behaviorally consistent: - -- `` owns the responsive search-left/actions-right layout; -- `` owns the search icon, optional loading indicator, clear - action, sizing, and input anatomy; -- `` owns the static Filter trigger, active-count pill, - multi-select panel, scrollable options area, and Reset filters footer; -- `` owns the static Sort trigger and single-select panel; -- `` overlays only the changing table data for backend search, - filter, sort, and pagination requests. - -Tables continue to own their filter options, sort choices, headers, rows, -queries, permissions, and empty states. Backend-filtered or paginated tables -must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar -and control anatomy but do not show an artificial loading state. - -### 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 OS-aware mod+K (`⌘K` on macOS, `Ctrl+K` on Windows/Linux) / `/` / `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 mod+K (⌘K / Ctrl+K by OS) 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` | -| Standard table toolbar controls | `resources/views/components/table/*` | -| 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 -`` 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. diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 99c2567f2..a20a1231b 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -38,7 +38,7 @@ class Index extends Component public $avatar; - public function uploadAvatar(AvatarStorageService $avatarStorage): void + public function uploadAvatar(AvatarStorageService $avatarStorage): bool { try { $this->validate([ @@ -49,8 +49,12 @@ class Index extends Component $this->reset('avatar'); $this->dispatch('avatar-updated', url: route('profile.avatar', ['v' => Auth::user()->fresh()->updated_at->timestamp])); $this->dispatch('success', 'Profile picture updated.'); + + return true; } catch (\Throwable $e) { handleError($e, $this); + + return false; } } diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index eb2d68c82..b44481e84 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -130,6 +130,7 @@ class Domains extends Component $application->setNoindexDomains($domains); $application->save(); $this->service->parse(); + $this->refreshDomains(); $this->dispatch('configurationChanged')->to(ConfigurationChecker::class); $this->dispatch('success', 'Search engine indexing updated.'); } diff --git a/app/Livewire/Team/Member/Index.php b/app/Livewire/Team/Member/Index.php index e057ba3f6..33776539a 100644 --- a/app/Livewire/Team/Member/Index.php +++ b/app/Livewire/Team/Member/Index.php @@ -22,6 +22,11 @@ class Index extends Component public function render() { - return view('livewire.team.member.index'); + $members = currentTeam()->members; + + return view('livewire.team.member.index', [ + 'members' => $members, + 'membersWithoutTwoFactorCount' => $members->whereNull('two_factor_confirmed_at')->count(), + ]); } } diff --git a/config/constants.php b/config/constants.php index fc15a6899..f3e17ad5a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.3.0', + 'version' => env('COOLIFY_VERSION') ?: '4.3.0', 'helper_version' => '1.0.14', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', @@ -99,6 +99,7 @@ return [ ], 'sentinel' => [ + 'dev_url' => env('DEV_SENTINEL_URL'), // How often (seconds) PushServerUpdateJob is force-dispatched even when // the container state hash is unchanged. Keeps exited-detection and // storage checks from going stale without writing every resource row on diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index 3cf913933..ebae97078 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -16,6 +16,14 @@ class SentinelSeeder extends Seeder if (str($server->settings->sentinel_token)->isEmpty()) { $server->settings->generateSentinelToken(ignoreEvent: true); } + $developmentUrl = isDev() ? config('constants.sentinel.dev_url') : null; + if (filled($developmentUrl)) { + $server->settings->sentinel_custom_url = $developmentUrl; + $server->settings->saveQuietly(); + + continue; + } + if (str($server->settings->sentinel_custom_url)->isEmpty()) { $url = $server->settings->generateSentinelUrl(ignoreEvent: true); if (str($url)->isEmpty()) { diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 263788e4d..cce3764c4 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -76,8 +76,11 @@ ARG TARGETPLATFORM ARG POSTGRES_VERSION ARG CLOUDFLARED_VERSION ARG NGINX_VERSION +ARG COOLIFY_VERSION ARG CI=true +ENV COOLIFY_VERSION=${COOLIFY_VERSION} + WORKDIR /var/www/html USER root diff --git a/resources/css/app.css b/resources/css/app.css index 1d73bf328..67562aa08 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -78,7 +78,13 @@ @layer components { .terminal-mobile-key { - @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25; + @apply min-h-8 shrink-0 rounded-full border bg-transparent px-3 py-1 text-sm font-medium text-neutral-300 active:text-white; + border-color: color-mix(in srgb, var(--terminal-scrollbar, #fff) 24%, transparent); + } + + .terminal-key-row { + border: 1px solid color-mix(in srgb, var(--terminal-scrollbar, #fff) 22%, transparent); + background: transparent; } /* Active state is a solid fill only (no accent rail / border). */ @@ -714,17 +720,6 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina color: #52525b; } -.terminal-session-expiry { - font-size: 0.75rem; - font-weight: 500; - color: rgb(255 255 255 / 0.6); -} - -html:not(.dark) .application-console-shell[data-console-theme="system"] .terminal-session-expiry, -html:not(.dark) .terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry { - color: #52525b; -} - .terminal-target-picker { color: rgb(255 255 255 / 0.75); background: rgb(0 0 0 / 0.18); @@ -2114,11 +2109,15 @@ input[type="search"]::-webkit-search-results-decoration { } .domains-table-grid { - grid-template-columns: minmax(0, 1.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 11rem 6.5rem; +} + +.domains-table-grid-service { + grid-template-columns: minmax(0, 1.8fr) 8.5rem 10rem 6.5rem; } .domains-table-grid-compose { - grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem minmax(7rem, 0.9fr) 10rem 11rem 6.5rem; + grid-template-columns: minmax(0, 1.6fr) minmax(6rem, 0.8fr) 8.5rem 10rem 11rem 6.5rem; } .domains-mobile-label { @@ -2132,9 +2131,9 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Last checked" (3rd of 4) */ - .domains-table-grid > :nth-child(3) { - display: none; + .domains-table-grid-service { + grid-template-columns: minmax(0, 1fr) 8.25rem 9rem 5.5rem; + gap: 0.75rem; } .domains-table-grid-compose { @@ -2142,20 +2141,21 @@ input[type="search"]::-webkit-search-results-decoration { gap: 0.75rem; } - /* Hide "Service" (2) and "Last checked" (4) of 5 */ - .domains-table-grid-compose > :nth-child(2), - .domains-table-grid-compose > :nth-child(4) { + /* Hide "Service" */ + .domains-table-grid-compose > :nth-child(2) { display: none; } } @media (max-width: 768px) { .data-table-header.domains-table-grid, + .data-table-header.domains-table-grid-service, .data-table-header.domains-table-grid-compose { display: none; } .data-table-row.domains-table-grid, + .data-table-row.domains-table-grid-service, .data-table-row.domains-table-grid-compose { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -2177,26 +2177,28 @@ input[type="search"]::-webkit-search-results-decoration { 'indexing indexing'; } - .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(5), - .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(6) { + .data-table-row.domains-table-grid.domains-row-without-direction > :nth-child(4), + .data-table-row.domains-table-grid-compose.domains-row-without-direction > :nth-child(5) { display: none; } /* Domain cell */ .data-table-row.domains-table-grid > :nth-child(1), + .data-table-row.domains-table-grid-service > :nth-child(1), .data-table-row.domains-table-grid-compose > :nth-child(1) { grid-area: domain; min-width: 0; } .data-table-row.domains-table-grid > :nth-child(1) a, + .data-table-row.domains-table-grid-service > :nth-child(1) a, .data-table-row.domains-table-grid-compose > :nth-child(1) a { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } - /* Non-compose: 1 Domain, 2 DNS, 3 Last checked, 4 Indexing, 5 Direction, 6 Actions */ + /* Non-compose: 1 Domain, 2 DNS, 3 Indexing, 4 Direction, 5 Actions */ .data-table-row.domains-table-grid > :nth-child(2) { grid-area: meta; display: flex !important; @@ -2206,23 +2208,37 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid > :nth-child(3) { - display: none !important; - } - - .data-table-row.domains-table-grid > :nth-child(4) { grid-area: indexing; } - .data-table-row.domains-table-grid > :nth-child(5) { + .data-table-row.domains-table-grid > :nth-child(4) { grid-area: direction; } - .data-table-row.domains-table-grid > :nth-child(6) { + .data-table-row.domains-table-grid > :nth-child(5) { grid-area: actions; align-self: center; } - /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Last checked, 5 Indexing, 6 Direction, 7 Actions */ + /* Compose service group: 1 Domain, 2 DNS, 3 Indexing, 4 Actions */ + .data-table-row.domains-table-grid-service > :nth-child(2) { + grid-area: meta; + display: flex !important; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + } + + .data-table-row.domains-table-grid-service > :nth-child(3) { + grid-area: indexing; + } + + .data-table-row.domains-table-grid-service > :nth-child(4) { + grid-area: actions; + align-self: center; + } + + /* Compose: 1 Domain, 2 Service, 3 DNS, 4 Indexing, 5 Direction, 6 Actions */ .data-table-row.domains-table-grid-compose > :nth-child(2) { display: none !important; } @@ -2236,18 +2252,14 @@ input[type="search"]::-webkit-search-results-decoration { } .data-table-row.domains-table-grid-compose > :nth-child(4) { - display: none !important; - } - - .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: indexing; } - .data-table-row.domains-table-grid-compose > :nth-child(6) { + .data-table-row.domains-table-grid-compose > :nth-child(5) { grid-area: direction; } - .data-table-row.domains-table-grid-compose > :nth-child(7) { + .data-table-row.domains-table-grid-compose > :nth-child(6) { grid-area: actions; align-self: center; } @@ -2457,6 +2469,16 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr); } +@media (min-width: 1024px) { + .deployment-table-scroll { + overflow-x: visible; + } + + .deployment-table-grid { + min-width: 0; + } +} + .dashboard-deployment-table-grid { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem; } @@ -2465,6 +2487,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.55fr) 7rem minmax(10rem, 0.9fr); } +.team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.55fr) 7rem 4rem minmax(10rem, 0.9fr); +} + .admin-users-table-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 5rem; } @@ -2529,6 +2555,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1fr) 7rem 7rem; } + .team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1fr) 7rem 4rem 7rem; + } + .team-members-table-grid > :nth-child(2) { display: none; } @@ -2580,6 +2610,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1fr) 6.5rem; } + .team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1fr) 4rem 6.5rem; + } + .team-members-table-grid > :nth-child(3) { display: none; } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 26f4cc4ce..668cb755b 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -163,7 +163,7 @@ } @utility auth-tooltip { - @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; + @apply fixed z-[10000] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10; } @utility alert-success { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index bdcc3824d..766bd5f86 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -196,6 +196,14 @@ export function initializeTerminalComponent() { isDocumentVisible: true, wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, + terminalModifier: null, + keyboardInset: 0, + keyboardAnchorTop: 0, + keyboardViewportHeight: 0, + keyboardViewportWidth: 0, + keyboardInsetSettleTimeout: null, + updateKeyboardInset: null, + syncKeyboardInset: null, // Inline style snapshots for ancestors unlocked while fullscreen (no DOM reparenting). fullscreenAncestorPatches: null, pageScrollLocked: false, @@ -212,6 +220,53 @@ export function initializeTerminalComponent() { init() { this.starting = this.$el.dataset.autoStart === 'true'; + this.updateKeyboardInset = () => { + const viewport = window.visualViewport; + const viewportWidth = viewport?.width ?? window.innerWidth; + const layoutHeight = Math.max( + window.innerHeight, + document.documentElement.clientHeight, + viewport ? viewport.height + viewport.offsetTop : 0, + ); + + // Track the tallest viewport seen at this width — an open software + // keyboard shrinks the visual viewport well below it. A large width + // change (rotation) resets the baseline. + if (Math.abs(this.keyboardViewportWidth - viewportWidth) > 80) { + this.keyboardViewportHeight = layoutHeight; + } else { + this.keyboardViewportHeight = Math.max(this.keyboardViewportHeight, layoutHeight); + } + this.keyboardViewportWidth = viewportWidth; + + const visualBottom = viewport ? viewport.height + viewport.offsetTop : layoutHeight; + this.keyboardInset = window.innerWidth < 640 && viewport + ? Math.max(0, Math.round(this.keyboardViewportHeight - visualBottom)) + : 0; + // position:fixed resolves `top` against the layout viewport and + // visualViewport.offsetTop is relative to it, so offsetTop + height + // is the exact bottom edge of the visible area — a toolbar pinned at + // this anchor rides on top of the keyboard no matter how the browser + // reports keyboard geometry (iOS overlay or Android layout resize). + this.keyboardAnchorTop = Math.round(visualBottom); + + this.syncFullscreenShellWithKeyboard(viewport); + + if (this.fullscreen) { + this.$nextTick(() => this.resizeTerminal()); + } + }; + this.syncKeyboardInset = () => { + // iOS fires viewport events mid keyboard animation — re-measure once + // the keyboard settles. + this.updateKeyboardInset(); + clearTimeout(this.keyboardInsetSettleTimeout); + this.keyboardInsetSettleTimeout = setTimeout(this.updateKeyboardInset, 250); + }; + this.updateKeyboardInset(); + window.visualViewport?.addEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset); + window.addEventListener('resize', this.syncKeyboardInset); this.themeObserver = new MutationObserver(() => { if (this.selectedTheme === 'system') { applicationTerminalThemes.system = createSystemTerminalTheme(); @@ -257,7 +312,7 @@ export function initializeTerminalComponent() { } this.$nextTick(() => { if (active) { - this.$refs.terminalWrapper.style.display = 'block'; + this.$refs.terminalWrapper.style.removeProperty('display'); this.resizeTerminal(); // Start observing terminal wrapper for resize changes @@ -266,8 +321,11 @@ export function initializeTerminalComponent() { } } else { const terminalElement = document.getElementById('terminal'); - this.$refs.terminalWrapper.style.display = - terminalElement?.dataset.terminalStyle === 'application' ? 'block' : 'none'; + if (terminalElement?.dataset.terminalStyle === 'application') { + this.$refs.terminalWrapper.style.removeProperty('display'); + } else { + this.$refs.terminalWrapper.style.display = 'none'; + } // Stop observing when terminal is inactive if (this.resizeObserver) { @@ -305,6 +363,10 @@ export function initializeTerminalComponent() { }, cleanup() { + window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset); + window.removeEventListener('resize', this.syncKeyboardInset); + clearTimeout(this.keyboardInsetSettleTimeout); this.checkIfProcessIsRunningAndKillIt(); this.clearAllTimers(); this.connectionState = 'disconnected'; @@ -848,6 +910,10 @@ export function initializeTerminalComponent() { destroy() { this.themeObserver?.disconnect(); + window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset); + window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset); + window.removeEventListener('resize', this.syncKeyboardInset); + clearTimeout(this.keyboardInsetSettleTimeout); }, @@ -856,7 +922,6 @@ export function initializeTerminalComponent() { return; } - this.term.focus(); this.sendMessage({ message: data }); }, @@ -868,14 +933,35 @@ export function initializeTerminalComponent() { arrowLeft: '\x1b[D', tab: '\t', escape: '\x1b', - ctrlC: '\x03' + ctrlC: '\x03', + ctrlBackslash: '\x1c', + ctrlS: '\x13', + ctrlZ: '\x1a' }; if (terminalSequences[sequence]) { + this.terminalModifier = null; this.sendTerminalInput(terminalSequences[sequence]); } }, + toggleTerminalModifier(modifier) { + this.terminalModifier = this.terminalModifier === modifier ? null : modifier; + }, + + sendTerminalKey(key) { + let input = key; + + if (this.terminalModifier === 'ctrl') { + input = String.fromCharCode(key.toUpperCase().charCodeAt(0) & 31); + } else if (this.terminalModifier === 'alt') { + input = `\x1b${key}`; + } + + this.terminalModifier = null; + this.sendTerminalInput(input); + }, + async pasteFromClipboard() { if (!navigator.clipboard?.readText) { this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.'); @@ -979,6 +1065,29 @@ export function initializeTerminalComponent() { this.sendMessage({ checkActive: 'force' }); }, + /** + * While the software keyboard is open, shrink the fullscreen shell to the + * visual viewport so xterm rows and the mobile key row stay visible above + * the keyboard. Inline !important is required to outrank the stylesheet's + * `inset: 0 !important` / `height: auto !important` fullscreen rules. + */ + syncFullscreenShellWithKeyboard(viewport) { + const wrapper = this.$refs.terminalWrapper; + if (!wrapper) { + return; + } + + if (this.fullscreen && viewport && this.keyboardInset > 0) { + wrapper.style.setProperty('top', `${Math.round(viewport.offsetTop)}px`, 'important'); + wrapper.style.setProperty('height', `${Math.round(viewport.height)}px`, 'important'); + wrapper.style.setProperty('bottom', 'auto', 'important'); + } else { + wrapper.style.removeProperty('top'); + wrapper.style.removeProperty('height'); + wrapper.style.removeProperty('bottom'); + } + }, + makeFullscreen() { if (this.fullscreen) { this.exitFullscreen(); @@ -1012,6 +1121,7 @@ export function initializeTerminalComponent() { this.fullscreen = true; document.documentElement.classList.add('terminal-is-fullscreen'); document.body.classList.add('terminal-is-fullscreen'); + this.updateKeyboardInset?.(); this.scheduleTerminalResize(); }, @@ -1032,6 +1142,7 @@ export function initializeTerminalComponent() { // Recover from older portal builds that left the terminal on . this.salvageStrayFullscreenNodes(); + this.updateKeyboardInset?.(); this.scheduleTerminalResize(); }, diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php deleted file mode 100644 index f01ba0e73..000000000 --- a/resources/views/components/applications/advanced.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -
- - - -
diff --git a/resources/views/components/forms/domain-input.blade.php b/resources/views/components/forms/domain-input.blade.php index 482788906..030fc0f5f 100644 --- a/resources/views/components/forms/domain-input.blade.php +++ b/resources/views/components/forms/domain-input.blade.php @@ -43,15 +43,17 @@
- +
+ +
@error($errorId ?? $id) @@ -60,13 +62,17 @@
- +
+ +
- +
+ +

diff --git a/resources/views/components/forms/listbox.blade.php b/resources/views/components/forms/listbox.blade.php index 531d32660..b61396a0d 100644 --- a/resources/views/components/forms/listbox.blade.php +++ b/resources/views/components/forms/listbox.blade.php @@ -15,6 +15,7 @@ 'disabled' => false, 'tooltip' => true, 'portal' => false, + 'preserveValue' => false, ]) @php @@ -45,22 +46,33 @@

whereStartsWith('x-model') }} + }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" + {{ $attributes->whereStartsWith('x-model') }} {{ $attributes->whereStartsWith('x-effect') }} + @if ($preserveValue) wire:ignore @endif @click.outside="open = false" @keydown.escape="open = false" @resize.window="open && positionPanel()">
diff --git a/resources/views/components/popup-small.blade.php b/resources/views/components/popup-small.blade.php index fca570f34..45f12eb6e 100644 --- a/resources/views/components/popup-small.blade.php +++ b/resources/views/components/popup-small.blade.php @@ -4,6 +4,7 @@ 'compactAfter' => null, 'compactStorageKey' => null, 'compactStoragePrefix' => null, + 'position' => 'bottom-right', ])
- + class="fixed right-4 z-999 {{ $position === 'top-right' ? 'top-16' : 'bottom-4' }}"> + -
+
+
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index d02fd2af9..d932dd8fe 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -45,6 +45,7 @@ 'refresh3' => '', 'restart' => '', 'stop' => '', + 'stop-circle' => '', 'play-circle' => '', 'browser-code' => '', 'database' => '', diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index d0254054b..006efb2ca 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -83,7 +83,7 @@ [ 'label' => 'Terminal', 'route' => 'server.command', - 'active' => request()->routeIs('server.command'), + 'active' => $activeMenu === 'terminal', 'icon' => 'browser-terminal', 'group' => 'Operations', 'navigate' => false, diff --git a/resources/views/components/services/advanced.blade.php b/resources/views/components/services/advanced.blade.php deleted file mode 100644 index dd2b10b7e..000000000 --- a/resources/views/components/services/advanced.blade.php +++ /dev/null @@ -1,51 +0,0 @@ -@php - $status = str($service->status ?? ''); - $canDeploy = auth()->user()->can('deploy', $service); - $canStop = auth()->user()->can('stop', $service); -@endphp - -
- - - -
diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 533464815..8c3eef13e 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -6,7 +6,7 @@ type: options.type ?? 'default', message, description: options.description ?? '', - position: options.position ?? 'top-center', + position: options.position ?? 'bottom-right', html: options.html ?? '', }, })); @@ -17,9 +17,9 @@