mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-19 00:23:30 +00:00
Merge remote-tracking branch 'origin/next' into fix/backup-ssh-command-timeout-next
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -113,4 +113,3 @@ jobs:
|
||||
if: always()
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- v4.x
|
||||
- main
|
||||
- v3.x
|
||||
- '**v5.x**'
|
||||
paths-ignore:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+55
-173
@@ -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-<commit>` and moving `edge` via **Build Coolify (SHA)** | GitHub release promotes the SHA image to a semantic version (and `latest` for stable releases) |
|
||||
| **`next`** | Development line for features and larger changes | Branch tag (for example `next`) via **Staging Build** | Becomes production only after merge into `v4.x` |
|
||||
|
||||
### Where to merge
|
||||
|
||||
- **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-<commit>`, `4.4-rc.1.<short-sha>`, 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-<commit-sha>` manifest in both registries.
|
||||
- For pushes to **`v4.x`**, the same multi-architecture manifest is also tagged as `edge`, so `coollabsio/coolify:edge` always points at the latest production-line SHA image. Builds from `main` publish only the immutable `sha-<commit-sha>` tags.
|
||||
- This workflow does not update a semantic version tag or `latest`.
|
||||
|
||||
3. **Wait for the SHA Image**
|
||||
- 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-<commit-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
|
||||
|
||||
<details>
|
||||
<summary><strong>Stable</strong></summary>
|
||||
|
||||
- **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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Nightly</strong></summary>
|
||||
|
||||
- **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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Beta</strong></summary>
|
||||
|
||||
- **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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> [!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 <version>
|
||||
```text
|
||||
next → main → stable release
|
||||
```
|
||||
Replace `<version>` 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.<short-sha>`.
|
||||
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-<commit>` | Exact commit build |
|
||||
|
||||
Git tags use the `v` prefix, such as `v4.4.0`. Docker image tags do not.
|
||||
|
||||
-687
@@ -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
|
||||
`<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.
|
||||
> - Use `<x-forms.listbox>` for dropdown controls. Never add a native
|
||||
> `<select>` to a redesigned view, including compact table-row controls.
|
||||
|
||||
---
|
||||
|
||||
## 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;
|
||||
- solid active-item fills (neutral black/white opacity), not accent gradients;
|
||||
active state is the left accent rail plus a flat selected surface;
|
||||
- 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 are rounded pills (`rounded-md`) with an accent rail on
|
||||
the left plus a solid neutral selected fill (`bg-black/5` light,
|
||||
`bg-white/6` dark). Hover rows use the same radius. Do not use accent-tinted
|
||||
gradients on nav rows; yellow washes look muddy on dark UI.
|
||||
- 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.
|
||||
|
||||
#### 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:
|
||||
|
||||
- `<x-table.toolbar>` owns the responsive search-left/actions-right layout;
|
||||
- `<x-table.search>` owns the search icon, optional loading indicator, clear
|
||||
action, sizing, and input anatomy;
|
||||
- `<x-table.filter>` owns the static Filter trigger, active-count pill,
|
||||
multi-select panel, scrollable options area, and Reset filters footer;
|
||||
- `<x-table.sort>` owns the static Sort trigger and single-select panel;
|
||||
- `<x-table.loading>` 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
|
||||
`<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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
-36
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+116
-5
@@ -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 <body>.
|
||||
this.salvageStrayFullscreenNodes();
|
||||
this.updateKeyboardInset?.();
|
||||
this.scheduleTerminalResize();
|
||||
},
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="grid" class="size-3.5 opacity-70" />
|
||||
Advanced
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,15 +43,17 @@
|
||||
<div class="min-w-0">
|
||||
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https"
|
||||
x-model="scheme" portal :options="[
|
||||
['value' => 'https', 'label' => 'HTTPS'],
|
||||
['value' => 'http', 'label' => 'HTTP'],
|
||||
['value' => 'https', 'label' => 'https'],
|
||||
['value' => 'http', 'label' => 'http'],
|
||||
]" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<label for="{{ $id }}" class="mb-1.5 block text-sm font-medium">
|
||||
Domain <x-highlighted text="*" />
|
||||
</label>
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label for="{{ $id }}" class="mb-0! flex items-center gap-1.5 leading-4">
|
||||
Domain <x-highlighted text="*" />
|
||||
</label>
|
||||
</div>
|
||||
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="app.example.com"
|
||||
autocomplete="off" required />
|
||||
@error($errorId ?? $id)
|
||||
@@ -60,13 +62,17 @@
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<label for="{{ $id }}-port" class="mb-1.5 block text-sm font-medium">Port</label>
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label for="{{ $id }}-port" class="mb-0! flex items-center gap-1.5 leading-4">Port</label>
|
||||
</div>
|
||||
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000"
|
||||
min="1" max="65535" inputmode="numeric" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 sm:col-span-3">
|
||||
<label for="{{ $id }}-path" class="mb-1.5 block text-sm font-medium">Path</label>
|
||||
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
|
||||
<label for="{{ $id }}-path" class="mb-0! flex items-center gap-1.5 leading-4">Path</label>
|
||||
</div>
|
||||
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3"
|
||||
autocomplete="off" />
|
||||
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
'disabled' => false,
|
||||
'tooltip' => true,
|
||||
'portal' => false,
|
||||
'preserveValue' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -45,22 +46,33 @@
|
||||
<div class="relative min-w-0" x-data="{
|
||||
open: false,
|
||||
positioned: false,
|
||||
saving: false,
|
||||
options: @js(array_values($options)),
|
||||
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
|
||||
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
|
||||
get current() {
|
||||
const found = this.options.find((option) => String(option.value) === String(this.value));
|
||||
return found ? found.label : @js($placeholder);
|
||||
},
|
||||
choose(option) {
|
||||
if (option.disabled) return;
|
||||
async choose(option) {
|
||||
if (this.saving || option.disabled) return;
|
||||
this.open = false;
|
||||
if (String(option.value) === String(this.value)) return;
|
||||
this.value = option.value;
|
||||
this.$dispatch('listbox-change', { value: option.value });
|
||||
@if ($onChange && is_array($onChangeArgs))
|
||||
this.$nextTick(() => this.$wire.{{ $onChange }}(...@js($onChangeArgs), option.value));
|
||||
this.saving = true;
|
||||
try {
|
||||
await this.$wire.{{ $onChange }}(...@js($onChangeArgs), option.value);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@elseif ($onChange)
|
||||
this.$nextTick(() => this.$wire.{{ $onChange }}());
|
||||
this.saving = true;
|
||||
try {
|
||||
await this.$wire.{{ $onChange }}();
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@endif
|
||||
},
|
||||
toggle() {
|
||||
@@ -94,8 +106,10 @@
|
||||
panel.style.minWidth = `${triggerRect.width}px`;
|
||||
this.positioned = true;
|
||||
}
|
||||
}" x-modelable="value" {{ $attributes->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()">
|
||||
<button x-ref="trigger" id="{{ $triggerId }}" type="button" class="listbox-trigger" @click="toggle()"
|
||||
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
<div class="relative min-w-0" x-data="{
|
||||
open: false,
|
||||
query: '',
|
||||
saving: false,
|
||||
options: @js(array_values($options)),
|
||||
value: @if (!$wire) @js($value) @elseif ($live) @entangle($id).live @else @entangle($id) @endif,
|
||||
value: @if (!$wire) @js($value) @elseif ($live && ! $onChange) @entangle($id).live @else @entangle($id) @endif,
|
||||
get current() {
|
||||
const found = this.options.find((option) => String(option.value) === String(this.value));
|
||||
return found ? found.label : @js($placeholder);
|
||||
@@ -72,8 +73,8 @@
|
||||
this.open = false;
|
||||
this.query = '';
|
||||
},
|
||||
choose(option) {
|
||||
if (option.disabled) {
|
||||
async choose(option) {
|
||||
if (this.saving || option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,9 +84,17 @@
|
||||
}
|
||||
|
||||
this.value = option.value;
|
||||
@if ($onChange) this.$nextTick(() => this.$wire.{{ $onChange }}()); @endif
|
||||
@if ($onChange)
|
||||
this.saving = true;
|
||||
try {
|
||||
await this.$wire.{{ $onChange }}();
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
@endif
|
||||
}
|
||||
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}
|
||||
}" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
|
||||
{{ $attributes->whereStartsWith('x-model') }}
|
||||
{{ $attributes->whereStartsWith('x-effect') }}
|
||||
@click.outside="close()" @keydown.escape.window="open && close()">
|
||||
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="toggle()"
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
}
|
||||
}" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close"
|
||||
@resize.window="open && position()" @scroll.window="open && position()"
|
||||
{{ $attributes->merge(['class' => 'relative inline-block align-middle']) }}>
|
||||
{{ $attributes->merge(['class' => 'relative inline-flex align-middle']) }}>
|
||||
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
|
||||
<button type="button" x-ref="trigger" data-icon-tooltip-ignore
|
||||
@class([
|
||||
@@ -122,7 +122,7 @@
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
:style="style"
|
||||
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
|
||||
class="info-helper-popup fixed z-[10000] w-max max-w-[min(20rem,calc(100vw-2rem))] whitespace-normal"
|
||||
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
|
||||
<div class="px-3 py-2.5 text-[13px] leading-5">
|
||||
{!! $helper !!}
|
||||
|
||||
@@ -71,6 +71,6 @@
|
||||
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
|
||||
:style="`left: ${x}px; top: ${y}px;`"
|
||||
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
|
||||
class="pointer-events-none fixed z-[100] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
|
||||
class="pointer-events-none fixed z-[10000] whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,7 +328,8 @@
|
||||
class="w-auto" isError
|
||||
@click="
|
||||
if (dispatchEvent) {
|
||||
$wire.dispatch(dispatchEventType, dispatchEventMessage);
|
||||
modalOpen = false;
|
||||
$nextTick(() => $wire.dispatch(dispatchEventType, dispatchEventMessage));
|
||||
}
|
||||
if (confirmWithPassword && !skipPasswordConfirmation) {
|
||||
step++;
|
||||
|
||||
@@ -221,6 +221,6 @@
|
||||
</div>
|
||||
<div x-show="collapsed && tooltip.show" x-cloak x-transition.opacity.duration.100ms
|
||||
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
|
||||
class="fixed z-[100] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-white/10"
|
||||
class="fixed z-[10000] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-lg bg-neutral-900 dark:bg-raised text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-white/10"
|
||||
x-text="tooltip.text"></div>
|
||||
</nav>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'compactAfter' => null,
|
||||
'compactStorageKey' => null,
|
||||
'compactStoragePrefix' => null,
|
||||
'position' => 'bottom-right',
|
||||
])
|
||||
|
||||
<div x-data="{
|
||||
@@ -50,19 +51,21 @@
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="translate-y-0 opacity-100"
|
||||
x-transition:leave-end="translate-y-3 opacity-0"
|
||||
class="fixed bottom-4 right-4 z-999">
|
||||
<button x-show="iconOnly" type="button" @click="restore()" aria-label="Restore warning"
|
||||
class="flex rounded-lg p-2"
|
||||
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
|
||||
@isset($icon)
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
|
||||
{{ $icon }}
|
||||
</span>
|
||||
@endisset
|
||||
</button>
|
||||
class="fixed right-4 z-999 {{ $position === 'top-right' ? 'top-16' : 'bottom-4' }}">
|
||||
<template x-if="iconOnly">
|
||||
<button type="button" @click="restore()" aria-label="Restore warning" class="flex rounded-lg p-2"
|
||||
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
|
||||
@isset($icon)
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
|
||||
{{ $icon }}
|
||||
</span>
|
||||
@endisset
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div x-show="!iconOnly" class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
|
||||
<template x-if="!iconOnly">
|
||||
<div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
|
||||
:class="compact
|
||||
? 'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'
|
||||
: 'w-[calc(100vw-2rem)] max-w-sm'"
|
||||
@@ -90,4 +93,5 @@
|
||||
<x-reicon name="x" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
'refresh3' => '<path d="M14.55 21.67C18.84 20.54 22 16.64 22 12C22 6.48 17.56 2 12 2C5.33 2 2 7.56 2 7.56M2 7.56V3M2 7.56H4.01H6.44" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 12C2 17.52 6.48 22 12 22" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>',
|
||||
'restart' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M18.364 3.05762C18.7782 3.05762 19.114 3.3934 19.114 3.80762V8.05026C19.114 8.46447 18.7782 8.80026 18.364 8.80026H14.1213C13.7071 8.80026 13.3713 8.46447 13.3713 8.05026C13.3713 7.63604 13.7071 7.30026 14.1213 7.30026H16.4817C13.6363 5.05718 9.4987 5.24825 6.87348 7.87348C4.04217 10.7048 4.04217 15.2952 6.87348 18.1265C9.70478 20.9578 14.2952 20.9578 17.1265 18.1265C19.0234 16.2297 19.6504 13.5428 19.0039 11.1219C18.897 10.7217 19.1348 10.3106 19.535 10.2038C19.9352 10.0969 20.3462 10.3347 20.4531 10.7349C21.2321 13.6518 20.478 16.8964 18.1872 19.1872C14.7701 22.6043 9.2299 22.6043 5.81282 19.1872C2.39573 15.7701 2.39573 10.2299 5.81282 6.81282C9.04483 3.5808 14.1762 3.40576 17.614 6.28768V3.80762C17.614 3.3934 17.9497 3.05762 18.364 3.05762Z" fill="currentColor"/>',
|
||||
'stop' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9426 1.25H12.0574C14.3658 1.24999 16.1748 1.24998 17.5863 1.43975C19.031 1.63399 20.1711 2.03933 21.0659 2.93414C21.9607 3.82895 22.366 4.96897 22.5603 6.41371C22.75 7.82519 22.75 9.63423 22.75 11.9426V12.0574C22.75 14.3658 22.75 16.1748 22.5603 17.5863C22.366 19.031 21.9607 20.1711 21.0659 21.0659C20.1711 21.9607 19.031 22.366 17.5863 22.5603C16.1748 22.75 14.3658 22.75 12.0574 22.75H11.9426C9.63423 22.75 7.82519 22.75 6.41371 22.5603C4.96897 22.366 3.82895 21.9607 2.93414 21.0659C2.03933 20.1711 1.63399 19.031 1.43975 17.5863C1.24998 16.1748 1.24999 14.3658 1.25 12.0574V11.9426C1.24999 9.63423 1.24998 7.82519 1.43975 6.41371C1.63399 4.96897 2.03933 3.82895 2.93414 2.93414C3.82895 2.03933 4.96897 1.63399 6.41371 1.43975C7.82519 1.24998 9.63423 1.24999 11.9426 1.25ZM6.61358 2.92637C5.33517 3.09825 4.56445 3.42514 3.9948 3.9948C3.42514 4.56445 3.09825 5.33517 2.92637 6.61358C2.75159 7.91356 2.75 9.62177 2.75 12C2.75 14.3782 2.75159 16.0864 2.92637 17.3864C3.09825 18.6648 3.42514 19.4355 3.9948 20.0052C4.56445 20.5749 5.33517 20.9018 6.61358 21.0736C7.91356 21.2484 9.62177 21.25 12 21.25C14.3782 21.25 16.0864 21.2484 17.3864 21.0736C18.6648 20.9018 19.4355 20.5749 20.0052 20.0052C20.5749 19.4355 20.9018 18.6648 21.0736 17.3864C21.2484 16.0864 21.25 14.3782 21.25 12C21.25 9.62177 21.2484 7.91356 21.0736 6.61358C20.9018 5.33517 20.5749 4.56445 20.0052 3.9948C19.4355 3.42514 18.6648 3.09825 17.3864 2.92637C16.0864 2.75159 14.3782 2.75 12 2.75C9.62177 2.75 7.91356 2.75159 6.61358 2.92637Z" fill="currentColor"/>',
|
||||
'stop-circle' => '<circle cx="12" cy="12" r="10.25" stroke="currentColor" stroke-width="1.5"/><rect x="8.25" y="8.25" width="7.5" height="7.5" rx="1" stroke="currentColor" stroke-width="1.5"/>',
|
||||
'play-circle' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0748 7.50835C9.74622 6.72395 8.25 7.79065 8.25 9.21316V14.7868C8.25 16.2093 9.74622 17.276 11.0748 16.4916L15.795 13.7048C17.0683 12.953 17.0683 11.047 15.795 10.2952L11.0748 7.50835ZM9.75 9.21316C9.75 9.01468 9.84615 8.87585 9.95947 8.80498C10.0691 8.73641 10.1919 8.72898 10.3122 8.80003L15.0324 11.5869C15.165 11.6652 15.25 11.8148 15.25 12C15.25 12.1852 15.165 12.3348 15.0324 12.4131L10.3122 15.2C10.1919 15.271 10.0691 15.2636 9.95947 15.195C9.84615 15.1242 9.75 14.9853 9.75 14.7868V9.21316Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C6.06294 1.25 1.25 6.06294 1.25 12C1.25 17.9371 6.06294 22.75 12 22.75C17.9371 22.75 22.75 17.9371 22.75 12C22.75 6.06294 17.9371 1.25 12 1.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12Z" fill="currentColor"/>',
|
||||
'browser-code' => '<path d="M10.1303 11.5697C10.4232 11.8626 10.4232 12.3375 10.1303 12.6304L8.26067 14.5L10.1303 16.3697C10.4232 16.6626 10.4232 17.1375 10.1303 17.4304C9.83745 17.7233 9.36258 17.7233 9.06968 17.4304L6.66968 15.0304C6.37679 14.7375 6.37679 14.2626 6.66968 13.9697L9.06968 11.5697C9.36258 11.2768 9.83745 11.2768 10.1303 11.5697Z" fill="currentColor"/><path d="M14.9304 11.5697C14.6375 11.2768 14.1626 11.2768 13.8697 11.5697C13.5768 11.8626 13.5768 12.3375 13.8697 12.6304L15.7394 14.5L13.8697 16.3697C13.5768 16.6626 13.5768 17.1375 13.8697 17.4304C14.1626 17.7233 14.6375 17.7233 14.9304 17.4304L17.3304 15.0304C17.6232 14.7375 17.6232 14.2626 17.3304 13.9697L14.9304 11.5697Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.1318 2.25006H5.86821C5.12513 2.25005 4.52196 2.25005 4.03273 2.29002C3.52778 2.33128 3.07851 2.41882 2.66103 2.63154C2.00247 2.9671 1.46703 3.50253 1.13148 4.1611C0.918762 4.57857 0.831214 5.02784 0.789958 5.53279C0.749986 6.02202 0.749993 6.62518 0.75 7.36825V16.6319C0.749993 17.3749 0.749986 17.9781 0.789958 18.4673C0.831214 18.9723 0.918762 19.4215 1.13148 19.839C1.46703 20.4976 2.00247 21.033 2.66103 21.3686C3.07851 21.5813 3.52778 21.6688 4.03273 21.7101C4.52195 21.7501 5.12507 21.7501 5.86811 21.7501H18.1318C18.8748 21.7501 19.4781 21.7501 19.9673 21.7101C20.4722 21.6688 20.9215 21.5813 21.339 21.3686C21.9975 21.033 22.533 20.4976 22.8685 19.839C23.0812 19.4215 23.1688 18.9723 23.21 18.4673C23.25 17.9781 23.25 17.375 23.25 16.632V7.36825C23.25 6.62521 23.25 6.02201 23.21 5.53279C23.1688 5.02784 23.0812 4.57857 22.8685 4.1611C22.533 3.50253 21.9975 2.9671 21.339 2.63154C20.9215 2.41882 20.4722 2.33128 19.9673 2.29002C19.478 2.25005 18.8749 2.25005 18.1318 2.25006ZM21.75 7.25006C21.7498 6.54749 21.7472 6.04844 21.715 5.65494C21.6813 5.24275 21.619 5.01286 21.532 4.84208C21.3403 4.46576 21.0343 4.1598 20.658 3.96805C20.4872 3.88103 20.2573 3.81871 19.8451 3.78504C19.4242 3.75065 18.8824 3.75006 18.1 3.75006H5.9C5.11755 3.75006 4.57582 3.75065 4.15488 3.78504C3.74269 3.81871 3.5128 3.88103 3.34202 3.96805C2.9657 4.1598 2.65973 4.46576 2.46799 4.84208C2.38097 5.01286 2.31865 5.24275 2.28498 5.65494C2.25283 6.04844 2.25022 6.54749 2.25002 7.25006H21.75ZM2.25 8.75006H21.75V16.6001C21.75 17.3825 21.7494 17.9242 21.715 18.3452C21.6813 18.7574 21.619 18.9873 21.532 19.158C21.3403 19.5344 21.0343 19.8403 20.658 20.0321C20.4872 20.1191 20.2573 20.1814 19.8451 20.2151C19.4242 20.2495 18.8824 20.2501 18.1 20.2501H5.9C5.11755 20.2501 4.57582 20.2495 4.15488 20.2151C3.74269 20.1814 3.5128 20.1191 3.34202 20.0321C2.9657 19.8403 2.65973 19.5344 2.46799 19.158C2.38097 18.9873 2.31865 18.7574 2.28498 18.3452C2.25058 17.9242 2.25 17.3825 2.25 16.6001V8.75006Z" fill="currentColor"/>',
|
||||
'database' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M3.25 6C3.25 4.45831 4.48029 3.26447 6.00774 2.50075C7.58004 1.7146 9.69967 1.25 12 1.25C14.3003 1.25 16.42 1.7146 17.9923 2.50075C19.5197 3.26447 20.75 4.45831 20.75 6V18C20.75 19.5417 19.5197 20.7355 17.9923 21.4992C16.42 22.2854 14.3003 22.75 12 22.75C9.69967 22.75 7.58004 22.2854 6.00774 21.4992C4.48029 20.7355 3.25 19.5417 3.25 18V6ZM4.75 6C4.75 5.33255 5.31057 4.52639 6.67856 3.84239C8.00168 3.18083 9.88205 2.75 12 2.75C14.118 2.75 15.9983 3.18083 17.3214 3.84239C18.6894 4.52639 19.25 5.33255 19.25 6C19.25 6.66745 18.6894 7.47361 17.3214 8.15761C15.9983 8.81917 14.118 9.25 12 9.25C9.88205 9.25 8.00168 8.81917 6.67856 8.15761C5.31057 7.47361 4.75 6.66745 4.75 6ZM4.75 18C4.75 18.6674 5.31057 19.4736 6.67856 20.1576C8.00168 20.8192 9.88205 21.25 12 21.25C14.118 21.25 15.9983 20.8192 17.3214 20.1576C18.6894 19.4736 19.25 18.6674 19.25 18V14.7072C18.8733 15.0077 18.4459 15.2724 17.9923 15.4992C16.42 16.2854 14.3003 16.75 12 16.75C9.69967 16.75 7.58004 16.2854 6.00774 15.4992C5.55414 15.2724 5.12675 15.0077 4.75 14.7072V18ZM19.25 8.70722V12C19.25 12.6674 18.6894 13.4736 17.3214 14.1576C15.9983 14.8192 14.118 15.25 12 15.25C9.88205 15.25 8.00168 14.8192 6.67856 14.1576C5.31057 13.4736 4.75 12.6674 4.75 12V8.70722C5.12675 9.00772 5.55414 9.27245 6.00774 9.49925C7.58004 10.2854 9.69967 10.75 12 10.75C14.3003 10.75 16.42 10.2854 17.9923 9.49925C18.4459 9.27245 18.8733 9.00772 19.25 8.70722Z" fill="currentColor"/>',
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'active' => $activeMenu === 'terminal',
|
||||
'icon' => 'browser-terminal',
|
||||
'group' => 'Operations',
|
||||
'navigate' => false,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
@php
|
||||
$status = str($service->status ?? '');
|
||||
$canDeploy = auth()->user()->can('deploy', $service);
|
||||
$canStop = auth()->user()->can('stop', $service);
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="grid" class="size-3.5 opacity-70" />
|
||||
Advanced
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
|
||||
@if ($status->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull Latest Images & Restart
|
||||
</button>
|
||||
@elseif ($status->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canDeploy)
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(! $canStop)
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false"
|
||||
role="menuitem">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 @@
|
||||
<template x-teleport="body">
|
||||
<ul x-data="{
|
||||
toasts: [],
|
||||
position: 'top-center',
|
||||
position: 'bottom-right',
|
||||
addToast(event) {
|
||||
this.position = event.detail.position || 'top-center';
|
||||
this.position = event.detail.position || 'bottom-right';
|
||||
|
||||
const toast = {
|
||||
id: `toast-${Math.random().toString(16).slice(2)}`,
|
||||
@@ -29,6 +29,8 @@
|
||||
type: event.detail.type,
|
||||
html: event.detail.html ? window.sanitizeHTML(event.detail.html) : '',
|
||||
timeout: null,
|
||||
copied: false,
|
||||
copiedTimeout: null,
|
||||
};
|
||||
|
||||
this.toasts.unshift(toast);
|
||||
@@ -55,12 +57,21 @@
|
||||
resumeToast(toast) {
|
||||
this.scheduleToast(toast);
|
||||
},
|
||||
async copyToast(toast) {
|
||||
await navigator.clipboard.writeText(toast.description);
|
||||
toast.copied = true;
|
||||
clearTimeout(toast.copiedTimeout);
|
||||
toast.copiedTimeout = setTimeout(() => {
|
||||
toast.copied = false;
|
||||
}, 2000);
|
||||
},
|
||||
removeToast(id) {
|
||||
const toast = this.toasts.find(item => item.id === id);
|
||||
if (!toast) return;
|
||||
|
||||
toast.visible = false;
|
||||
clearTimeout(toast.timeout);
|
||||
clearTimeout(toast.copiedTimeout);
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter(item => item.id !== id);
|
||||
}, 150);
|
||||
@@ -124,13 +135,15 @@
|
||||
</template>
|
||||
|
||||
<button type="button" x-show="toast.description && !toast.html"
|
||||
@click="navigator.clipboard.writeText(toast.description)" title="Copy details"
|
||||
class="absolute right-10 top-2.5 flex size-7 items-center justify-center rounded-md text-neutral-400 opacity-0 transition-colors hover:bg-black/5 hover:text-neutral-700 group-hover:opacity-100 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<svg class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
@click="copyToast(toast)" :title="toast.copied ? 'Copied' : 'Copy details'"
|
||||
class="absolute right-10 top-2.5 flex size-7 items-center justify-center rounded-md text-neutral-400 opacity-0 transition-colors hover:bg-black/5 hover:text-neutral-700 group-hover:opacity-100 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
:class="{ 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied }">
|
||||
<svg x-show="!toast.copied" class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke-width="1.7" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 7.5V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0120.25 6v7.5A2.25 2.25 0 0118 15.75h-1.5m-8.25-8.25H6A2.25 2.25 0 003.75 9.75v7.5A2.25 2.25 0 006 19.5h7.5a2.25 2.25 0 002.25-2.25V15m-7.5-7.5h5.25A2.25 2.25 0 0115.75 9.75V15" />
|
||||
</svg>
|
||||
<x-reicon name="check" x-show="toast.copied" class="size-3.5" />
|
||||
</button>
|
||||
|
||||
<button type="button" @click="removeToast(toast.id)" aria-label="Dismiss"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@props([
|
||||
'enabled' => false,
|
||||
])
|
||||
|
||||
<span title="{{ $enabled ? 'Two-factor authentication is enabled for this account.' : 'This account is not protected by two-factor authentication.' }}"
|
||||
{{ $attributes->class([
|
||||
'inline-flex items-center',
|
||||
'text-green-600 dark:text-green-400' => $enabled,
|
||||
'text-neutral-400 dark:text-fg-faint' => ! $enabled,
|
||||
]) }}>
|
||||
@if ($enabled)
|
||||
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||
</svg>
|
||||
@else
|
||||
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Zm0 13.036h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
@endif
|
||||
<span class="sr-only">Two-factor authentication is {{ $enabled ? 'enabled' : 'disabled' }}</span>
|
||||
</span>
|
||||
@@ -41,7 +41,7 @@
|
||||
window.removeEventListener('resize', this.updateKeyboardInset);
|
||||
},
|
||||
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty"
|
||||
wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"
|
||||
wire:loading.class="is-saving"
|
||||
@keydown.enter.window="
|
||||
if ($el.classList.contains('is-dirty') &&
|
||||
!$event.repeat &&
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
"
|
||||
@if ($targets) wire:target="{{ $targets }}" @endif
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-neutral-200 bg-white py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 dark:border-white/10 dark:bg-surface [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 scale-95 flex-col items-stretch gap-2 rounded-2xl border border-neutral-200 bg-white py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform,scale] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] delay-0 dark:border-white/10 dark:bg-surface [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:scale-100 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 [&.is-saving]:pointer-events-none [&.is-saving]:translate-y-6 [&.is-saving]:scale-95 [&.is-saving]:opacity-0 [&.is-saving]:duration-200 [&.is-saving]:ease-in [&.is-saving]:delay-0 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
<span class="text-[13px] font-semibold leading-snug text-neutral-800 dark:text-fg sm:whitespace-nowrap">{{ $label }}</span>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2">
|
||||
<button type="button" onclick="window.location.reload()"
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<a {{ $attributes->merge(['class' => 'text-xs cursor-pointer opacity-90 hover:opacity-100 dark:hover:text-white hover:text-black']) }}
|
||||
href="https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}" target="_blank">
|
||||
v{{ config('constants.coolify.version') }}
|
||||
</a>
|
||||
@php($version = config('constants.coolify.version'))
|
||||
|
||||
@if (str_contains($version, '-dev.'))
|
||||
<span {{ $attributes->merge(['class' => 'text-xs opacity-90']) }}>v{{ $version }}</span>
|
||||
@else
|
||||
<a {{ $attributes->merge(['class' => 'text-xs cursor-pointer opacity-90 hover:opacity-100 dark:hover:text-white hover:text-black']) }}
|
||||
href="https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}" target="_blank">
|
||||
v{{ $version }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
{{-- ============ DESKTOP TOP BAR ============ --}}
|
||||
<header
|
||||
x-data="{ resourceActionsOpen: false }"
|
||||
@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"
|
||||
:class="{ 'z-[1000]': resourceActionsOpen }"
|
||||
class="hidden lg:flex fixed top-0 inset-x-0 z-50 h-12 items-center bg-white/95 dark:bg-panel/95 backdrop-blur border-b border-neutral-200 dark:border-white/[0.06]">
|
||||
{{-- Brand (width tracks sidebar) --}}
|
||||
<div class="flex items-center gap-2 h-full shrink-0 border-r border-neutral-200 dark:border-white/[0.06] transition-[width] duration-200"
|
||||
|
||||
@@ -169,11 +169,15 @@
|
||||
<div class="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($dashboardServers as $server)
|
||||
@php
|
||||
$proxyNeedsAttention = $server->proxySet() && $server->proxy->status !== 'running';
|
||||
$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive();
|
||||
|
||||
[$serverStatus, $serverStatusType] = match (true) {
|
||||
$server->settings->force_disabled => ['Disabled', 'error'],
|
||||
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
|
||||
! $server->settings->is_reachable => ['Unreachable', 'error'],
|
||||
! $server->settings->is_usable => ['Not ready', 'warning'],
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
|
||||
default => ['Ready', 'success'],
|
||||
};
|
||||
@endphp
|
||||
|
||||
@@ -52,9 +52,25 @@
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(value => value ? resolve(value) : reject(new Error('JPEG compression failed')), 'image/jpeg', 0.8);
|
||||
});
|
||||
this.preview = URL.createObjectURL(blob);
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
const compressed = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
|
||||
this.$wire.upload('avatar', compressed, () => this.processing = false, () => {
|
||||
this.$wire.upload('avatar', compressed, async () => {
|
||||
try {
|
||||
const uploaded = await this.$wire.uploadAvatar();
|
||||
if (uploaded) {
|
||||
if (this.preview) URL.revokeObjectURL(this.preview);
|
||||
this.preview = previewUrl;
|
||||
} else {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
this.uploadError = 'The image could not be uploaded.';
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}, () => {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
this.processing = false;
|
||||
this.uploadError = 'The image could not be uploaded.';
|
||||
});
|
||||
@@ -84,22 +100,22 @@
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<input type="file" x-on:change="prepareAvatar($event)" accept="image/jpeg,image/png,image/webp"
|
||||
class="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-200 file:px-3 file:py-2 file:text-xs file:font-medium file:text-neutral-800 hover:file:bg-neutral-300 dark:text-fg-dim dark:file:bg-white/[0.08] dark:file:text-fg dark:hover:file:bg-white/[0.12]">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input x-ref="avatarInput" type="file" x-on:change="prepareAvatar($event)"
|
||||
accept="image/jpeg,image/png,image/webp" class="hidden">
|
||||
<x-forms.button type="button" x-on:click="$refs.avatarInput.click()"
|
||||
x-bind:disabled="processing">
|
||||
<span x-text="processing ? 'Uploading…' : 'Browse…'"></span>
|
||||
</x-forms.button>
|
||||
@if (auth()->user()->avatar_path)
|
||||
<x-forms.button type="button" wire:click="removeAvatar" x-bind:disabled="processing"
|
||||
isError>Remove</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<p x-cloak x-show="uploadError" x-text="uploadError" class="text-xs text-red-500"></p>
|
||||
@error('avatar')
|
||||
<p class="text-xs text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-forms.button type="button" wire:click="uploadAvatar" wire:loading.attr="disabled"
|
||||
wire:target="avatar,uploadAvatar" x-bind:disabled="processing || !preview" isHighlighted>
|
||||
<span wire:loading.remove wire:target="uploadAvatar">Upload picture</span>
|
||||
<span wire:loading wire:target="uploadAvatar">Compressing…</span>
|
||||
</x-forms.button>
|
||||
@if (auth()->user()->avatar_path)
|
||||
<x-forms.button type="button" wire:click="removeAvatar" isError>Remove</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -245,19 +261,16 @@
|
||||
</div>
|
||||
@elseif (request()->user()->two_factor_confirmed_at)
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<x-status-badge status="Enabled" type="success" />
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<form action="/user/two-factor-recovery-codes" method="POST">
|
||||
@csrf
|
||||
<x-forms.button type="submit">Regenerate recovery codes</x-forms.button>
|
||||
</form>
|
||||
<form action="/user/two-factor-authentication" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<x-forms.button type="submit" isError>Disable 2FA</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<form action="/user/two-factor-recovery-codes" method="POST">
|
||||
@csrf
|
||||
<x-forms.button type="submit">Regenerate recovery codes</x-forms.button>
|
||||
</form>
|
||||
<form action="/user/two-factor-authentication" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<x-forms.button type="submit" isError>Disable 2FA</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
@if (session('status') === 'two-factor-authentication-confirmed'
|
||||
|| session('status') === 'recovery-codes-generated')
|
||||
|
||||
@@ -202,20 +202,38 @@
|
||||
wire:key="application-compose-domain-group-{{ $redirectWireKey }}"
|
||||
x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
|
||||
<div class="flex w-full items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div class="flex w-full items-center justify-between gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">
|
||||
{{ $serviceName }}
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="hidden text-xs text-neutral-500 sm:inline dark:text-fg-dim">Direction</span>
|
||||
@if (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
|
||||
<x-forms.listbox id="domain-direction-service-{{ $redirectWireKey }}" :wire="false"
|
||||
:value="$serviceRedirects[$redirectWireKey] ?? 'both'" preserveValue
|
||||
onChange="updateServiceRedirect" :onChangeArgs="[$serviceName]" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
['value' => 'non-www', 'label' => 'Redirect to non-www'],
|
||||
]" />
|
||||
@else
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ match ($serviceRedirects[$redirectWireKey] ?? 'both') {
|
||||
'www' => 'Redirect to www',
|
||||
'non-www' => 'Redirect to non-www',
|
||||
default => 'Allow both',
|
||||
} }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div wire:key="application-compose-domain-rows-{{ $redirectWireKey }}-{{ md5(serialize($rows->all())) }}"
|
||||
class="data-table w-full">
|
||||
<div class="data-table-header domains-table-grid">
|
||||
<div class="data-table-header domains-table-grid-service">
|
||||
<span>Domain</span>
|
||||
<span>DNS</span>
|
||||
<span>Last checked</span>
|
||||
<span>DNS Check</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@foreach ($rows as $row)
|
||||
@@ -232,7 +250,8 @@
|
||||
'application' => $application,
|
||||
'labelsAreWritable' => $labelsAreWritable,
|
||||
'isCompose' => false,
|
||||
'domainDirection' => $serviceRedirects[$redirectWireKey] ?? 'both',
|
||||
'showDirectionControl' => false,
|
||||
'domainGridClass' => 'domains-table-grid-service',
|
||||
])
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -250,7 +269,6 @@
|
||||
<div class="data-table-header domains-table-grid">
|
||||
<span>Domain</span>
|
||||
<span>DNS Check</span>
|
||||
<span>Last checked</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
|
||||
@@ -104,13 +104,13 @@
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@@ -183,30 +183,28 @@
|
||||
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
|
||||
<span class="px-2 text-[13px] text-neutral-500 dark:text-fg-dim">Load a Compose file to deploy.</span>
|
||||
@else
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<x-applications.advanced :application="$application" />
|
||||
@endif
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-applications.links :application="$application" />
|
||||
</div>
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<x-forms.button id="application-desktop-deploy" canGate="deploy" :canResource="$application"
|
||||
wire:click="deploy">
|
||||
<x-reicon name="play-circle" class="size-4 opacity-70" />
|
||||
Deploy
|
||||
</x-forms.button>
|
||||
@else
|
||||
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if (str($application->status)->startsWith('exited'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="deploy" @click="open = false" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@else
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('deploy', $application)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@@ -255,18 +253,28 @@
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
|
||||
<x-reicon name="stop" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $application))
|
||||
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force deploy without cache
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,7 @@
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
$checkedAt = ! empty($row['checked_at'])
|
||||
? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans()
|
||||
: null;
|
||||
$gridClass = ($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid';
|
||||
$gridClass = $domainGridClass ?? (($isCompose ?? false) ? 'domains-table-grid-compose' : 'domains-table-grid');
|
||||
$domainParts = $isSuggested ? null : parse_url($row['url']);
|
||||
$faviconUrl = is_array($domainParts) && isset($domainParts['scheme'], $domainParts['host'])
|
||||
? $domainParts['scheme'].'://'.$domainParts['host'].(isset($domainParts['port']) ? ':'.$domainParts['port'] : '').'/favicon.ico'
|
||||
@@ -38,7 +35,7 @@
|
||||
->filter(fn ($item) => $redirectPairKey($item['url']) === $pairKey)
|
||||
->keys()
|
||||
->first();
|
||||
$showDirection = ! $isSuggested && $firstPairRowIndex === $index;
|
||||
$showDirection = ($showDirectionControl ?? true) && ! $isSuggested && $firstPairRowIndex === $index;
|
||||
@endphp
|
||||
|
||||
<div wire:key="domain-row-{{ $index }}-{{ md5(($isSuggested ? 's:' : '') . $row['url'] . '|' . ($row['service'] ?? '')) }}"
|
||||
@@ -59,12 +56,19 @@
|
||||
</span>
|
||||
@else
|
||||
@if ($faviconUrl)
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer" x-on:error="$el.remove()"
|
||||
class="size-4 shrink-0 rounded-sm" />
|
||||
<span class="relative size-4 shrink-0" aria-hidden="true">
|
||||
<x-reicon name="globe"
|
||||
class="domain-favicon-fallback size-4 text-neutral-400 dark:text-fg-faint" />
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
x-init="if ($el.complete && $el.naturalWidth > 0) { $el.previousElementSibling.classList.add('hidden'); $el.classList.remove('invisible') }"
|
||||
x-on:load="$el.previousElementSibling.classList.add('hidden'); $el.classList.remove('invisible')"
|
||||
x-on:error="$el.remove()"
|
||||
class="invisible absolute inset-0 size-4 rounded-sm" />
|
||||
</span>
|
||||
@endif
|
||||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@@ -101,11 +105,6 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
|
||||
title="{{ $checkedAt ?? '' }}">
|
||||
{{ $checkedAt ?: '-' }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0" title="Search engine indexing">
|
||||
@unless ($isSuggested)
|
||||
<span class="domains-mobile-label">Search engine indexing</span>
|
||||
@@ -114,6 +113,7 @@
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">-</span>
|
||||
@elseif (auth()->user()?->can('update', $application) && ! $labelsAreWritable)
|
||||
<x-forms.listbox id="domain-indexing-{{ $index }}" :wire="false"
|
||||
preserveValue
|
||||
:value="$application->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain" :onChangeArgs="[$row['url']]" portal :options="[
|
||||
['value' => 'index', 'label' => 'Indexable'],
|
||||
@@ -126,6 +126,7 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($showDirectionControl ?? true)
|
||||
<div class="min-w-0" title="Direction">
|
||||
@php
|
||||
$rowDirection = $domainDirection ?? $redirect;
|
||||
@@ -140,6 +141,7 @@
|
||||
@endif
|
||||
@if ($showDirection && auth()->user()?->can('update', $application) && ! $labelsAreWritable)
|
||||
<x-forms.listbox id="domain-direction-{{ $index }}" :wire="false" :value="$rowDirection"
|
||||
preserveValue
|
||||
:onChange="$isCompose ? 'updateServiceRedirect' : 'updateRedirect'"
|
||||
:onChangeArgs="$isCompose ? [$row['service']] : []" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
@@ -150,6 +152,7 @@
|
||||
<span class="text-[13px] text-neutral-500 dark:text-fg-dim">{{ $directionLabel }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@can('update', $application)
|
||||
|
||||
@@ -107,13 +107,13 @@
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
|
||||
Stop
|
||||
</button>
|
||||
@endcan
|
||||
@@ -181,43 +181,71 @@
|
||||
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
|
||||
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($service->isDeployable)
|
||||
<x-services.advanced :service="$service" />
|
||||
<div class="resource-heading-menus shrink-0">
|
||||
<x-services.links :service="$service" />
|
||||
</div>
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
x-effect="$dispatch('resource-actions-toggled', { open })"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
|
||||
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
Restart
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
Stop
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Deploy
|
||||
</button>
|
||||
@endif
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
|
||||
@if ($serviceStatus->contains('running'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('pullAndRestartEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Pull Latest Images & Restart
|
||||
</button>
|
||||
@elseif ($serviceStatus->contains('degraded'))
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Restart
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('deploy', $service))
|
||||
@click="$wire.dispatch('forceDeployEvent'); open = false">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
Force Deploy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@disabled(!auth()->user()->can('stop', $service))
|
||||
@click="$wire.dispatch('cleanupEvent'); open = false">
|
||||
<x-reicon name="trash" class="size-3.5 opacity-70" />
|
||||
Force Cleanup Containers
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.button canGate="deploy" :canResource="$service"
|
||||
x-bind:disabled="deploying"
|
||||
@click="deploying = true; $wire.dispatch('startEvent')">
|
||||
<x-loading-on-button x-show="deploying" x-cloak />
|
||||
<x-reicon name="play-circle" class="size-4 opacity-70" x-show="!deploying" />
|
||||
<span x-text="deploying ? 'Deploying…' : 'Deploy'">Deploy</span>
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
|
||||
aria-label="Open required environment variables">
|
||||
@@ -264,7 +292,7 @@
|
||||
|
||||
if (isDeploymentProgress) {
|
||||
$wire.$dispatch('error',
|
||||
'There is a deployment in progress.<br><br>You can force deploy in the Advanced section.');
|
||||
'There is a deployment in progress.<br><br>You can force deploy from the Actions menu.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +306,7 @@
|
||||
|
||||
if (isDeploymentProgress) {
|
||||
$wire.$dispatch('error',
|
||||
'There is a deployment in progress.<br><br>You can force deploy in the Advanced section.');
|
||||
'There is a deployment in progress.<br><br>You can force deploy from the Actions menu.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
@if ($showServiceColumn)
|
||||
<span>Service</span>
|
||||
@endif
|
||||
<span>DNS</span>
|
||||
<span>Last checked</span>
|
||||
<span>DNS Check</span>
|
||||
<span>Search engine indexing</span>
|
||||
<span>Direction</span>
|
||||
<span></span>
|
||||
@@ -39,9 +38,6 @@
|
||||
'pending' => 'DNS pending',
|
||||
default => 'DNS unknown',
|
||||
};
|
||||
$checkedAt = ! empty($row['checked_at'])
|
||||
? \Illuminate\Support\Carbon::parse($row['checked_at'])->diffForHumans()
|
||||
: null;
|
||||
$serviceLabel = filled($row['service_name'] ?? null)
|
||||
? \Illuminate\Support\Str::headline($row['service_name'])
|
||||
: '-';
|
||||
@@ -84,12 +80,19 @@
|
||||
</span>
|
||||
@else
|
||||
@if ($faviconUrl)
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer" x-on:error="$el.remove()"
|
||||
class="size-4 shrink-0 rounded-sm" />
|
||||
<span class="relative size-4 shrink-0" aria-hidden="true">
|
||||
<x-reicon name="globe"
|
||||
class="domain-favicon-fallback size-4 text-neutral-400 dark:text-fg-faint" />
|
||||
<img src="{{ $faviconUrl }}" alt="" loading="lazy" decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
x-init="if ($el.complete && $el.naturalWidth > 0) { $el.previousElementSibling.classList.add('hidden'); $el.classList.remove('invisible') }"
|
||||
x-on:load="$el.previousElementSibling.classList.add('hidden'); $el.classList.remove('invisible')"
|
||||
x-on:error="$el.remove()"
|
||||
class="invisible absolute inset-0 size-4 rounded-sm" />
|
||||
</span>
|
||||
@endif
|
||||
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
|
||||
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
class="min-w-0 flex-1 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
|
||||
title="{{ $row['url'] }}">
|
||||
{{ $row['url'] }}
|
||||
</a>
|
||||
@@ -123,10 +126,6 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $checkedAt ?: '-' }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
@unless ($isSuggested)
|
||||
<span class="domains-mobile-label">Search engine indexing</span>
|
||||
@@ -136,6 +135,7 @@
|
||||
@elseif (auth()->user()?->can('update', $service))
|
||||
<x-forms.listbox id="service-domain-indexing-{{ $row['service_application_id'] }}-{{ $index }}"
|
||||
:wire="false"
|
||||
preserveValue
|
||||
:value="$service->applications->firstWhere('id', $row['service_application_id'])?->isDomainNoindexed($row['url']) ? 'noindex' : 'index'"
|
||||
onChange="toggleNoindexDomain"
|
||||
:onChangeArgs="[(int) $row['service_application_id'], $row['url']]" portal :options="[
|
||||
@@ -163,7 +163,7 @@
|
||||
@endif
|
||||
@if ($showDirection && auth()->user()?->can('update', $service))
|
||||
<x-forms.listbox id="service-domain-direction-{{ $row['service_application_id'] }}-{{ $index }}"
|
||||
:wire="false" :value="$rowDirection" onChange="updateServiceRedirect"
|
||||
:wire="false" :value="$rowDirection" preserveValue onChange="updateServiceRedirect"
|
||||
:onChangeArgs="[(int) $row['service_application_id']]" portal :options="[
|
||||
['value' => 'both', 'label' => 'Allow www & non-www'],
|
||||
['value' => 'www', 'label' => 'Redirect to www'],
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
@endphp
|
||||
<div wire:key="configuration-warning-{{ $currentConfigurationHash }}"
|
||||
x-data="{ configurationDiffModalOpen: false, expandedRows: {} }">
|
||||
<x-popup-small :compact-after="5000" :compact-storage-key="$compactStorageKey"
|
||||
<x-popup-small position="top-right" :compact-after="5000" :compact-storage-key="$compactStorageKey"
|
||||
:compact-storage-prefix="$compactStoragePrefix">
|
||||
<x-slot:title>
|
||||
The latest configuration has not been applied
|
||||
|
||||
@@ -60,10 +60,6 @@
|
||||
x-text="connectionState === 'reconnecting' ? `reconnecting… (attempt ${reconnectAttempts})` : (starting ? 'connecting…' : (connectionState === 'connecting' ? 'connecting…' : 'choose a container to start a session'))"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="terminalActive" x-cloak
|
||||
class="terminal-session-expiry pointer-events-none absolute right-3 bottom-2 z-20 font-mono"
|
||||
x-text="terminalSessionRemainingLabel()">
|
||||
</div>
|
||||
@else
|
||||
<div x-show="terminalActive" x-cloak class="mb-2 flex shrink-0 justify-start">
|
||||
<div class="inline-flex rounded-sm border px-2 py-1 text-xs font-medium"
|
||||
@@ -74,41 +70,35 @@
|
||||
|
||||
<div id="terminal" wire:ignore data-terminal-style="{{ $isApplicationConsole ? 'application' : 'default' }}"
|
||||
:class="fullscreen
|
||||
? (mobileToolbarCollapsed
|
||||
? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'
|
||||
: 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24')
|
||||
? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'
|
||||
: @js($isApplicationConsole
|
||||
? 'terminal-host relative min-h-0 flex-1 overflow-hidden pt-[5px] pr-px pb-[5px] pl-1 bg-transparent'
|
||||
: 'terminal-host h-[510px] max-h-[calc(100dvh-10rem)] overflow-hidden px-2 py-1 rounded-sm bg-black')">
|
||||
</div>
|
||||
|
||||
<div x-show="terminalActive" x-cloak
|
||||
:class="fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'"
|
||||
class="sm:hidden" data-terminal-mobile-toolbar>
|
||||
<div
|
||||
class="mx-auto max-w-3xl rounded-lg border border-white/10 bg-black/90 p-1.5 text-white shadow-lg backdrop-blur">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="px-2 text-[11px] font-medium uppercase tracking-wide text-neutral-400">Terminal keys</span>
|
||||
<button type="button"
|
||||
class="rounded px-2 py-1 text-xs text-neutral-300 hover:bg-white/10 hover:text-white"
|
||||
x-on:click="mobileToolbarCollapsed = !mobileToolbarCollapsed; $nextTick(() => resizeTerminal())"
|
||||
x-text="mobileToolbarCollapsed ? 'Show' : 'Hide'"
|
||||
aria-label="Toggle mobile terminal toolbar"></button>
|
||||
</div>
|
||||
<div x-show="!mobileToolbarCollapsed" class="mt-1 grid grid-cols-6 gap-1">
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowUp')"
|
||||
aria-label="Previous command">↑</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowDown')"
|
||||
aria-label="Next command">↓</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowLeft')"
|
||||
aria-label="Move cursor left">←</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('arrowRight')"
|
||||
aria-label="Move cursor right">→</button>
|
||||
<button type="button" class="terminal-mobile-key"
|
||||
x-on:click="sendTerminalControl('tab')">Tab</button>
|
||||
<button type="button" class="terminal-mobile-key"
|
||||
x-on:click="sendTerminalControl('escape')">Esc</button>
|
||||
</div>
|
||||
:class="fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2' : 'relative z-[2] mt-2 shrink-0')"
|
||||
:style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : ''"
|
||||
data-terminal-mobile-toolbar>
|
||||
<div class="terminal-key-row mx-auto flex max-w-3xl gap-1.5 overflow-x-auto whitespace-nowrap rounded-lg px-2 py-1.5 text-white [scrollbar-width:thin]">
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="pasteFromClipboard()">paste</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="copyTerminalSelection()">copy</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('escape')">ESC</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('tab')">tab</button>
|
||||
<button type="button" class="terminal-mobile-key"
|
||||
:class="terminalModifier === 'ctrl' ? 'border-white/35 bg-white/20 text-white' : ''"
|
||||
x-on:click="toggleTerminalModifier('ctrl')">ctrl</button>
|
||||
<button type="button" class="terminal-mobile-key"
|
||||
:class="terminalModifier === 'alt' ? 'border-white/35 bg-white/20 text-white' : ''"
|
||||
x-on:click="toggleTerminalModifier('alt')">alt</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('/')">/</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('|')">|</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('~')">~</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalKey('-')">-</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlC')">^C</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlBackslash')">^\</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlS')">^S</button>
|
||||
<button type="button" class="terminal-mobile-key" x-on:click="sendTerminalControl('ctrlZ')">^Z</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +114,7 @@
|
||||
<button type="button" title="Fullscreen" x-cloak x-show="!fullscreen && terminalActive"
|
||||
@class([
|
||||
'terminal-fullscreen-btn absolute z-20',
|
||||
'right-2 top-2 opacity-0 group-hover/terminal:opacity-100 focus-visible:opacity-100' => $isApplicationConsole,
|
||||
'right-2 top-2 opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100' => $isApplicationConsole,
|
||||
'right-5 top-6' => !$isApplicationConsole,
|
||||
])
|
||||
x-on:click="makeFullscreen">
|
||||
|
||||
@@ -33,18 +33,22 @@
|
||||
&& $server->settings->is_usable
|
||||
&& ! $server->settings->force_disabled
|
||||
&& ! $isTransferredAway;
|
||||
$proxyNeedsAttention = $isReady && $server->proxySet() && $server->proxy->status !== 'running';
|
||||
$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive();
|
||||
|
||||
$status = match (true) {
|
||||
$isTransferredAway => 'Transferred away',
|
||||
$server->settings->force_disabled => 'Disabled',
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => 'Attention required',
|
||||
$isReady => 'Ready',
|
||||
default => 'Validation required',
|
||||
};
|
||||
|
||||
$statusType = match (true) {
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => 'warning',
|
||||
$isReady => 'success',
|
||||
$isTransferredAway || $server->settings->force_disabled => 'error',
|
||||
default => 'warning',
|
||||
default => 'error',
|
||||
};
|
||||
|
||||
return [
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
[
|
||||
'label' => 'Terminal',
|
||||
'route' => 'server.command',
|
||||
'active' => request()->routeIs('server.command'),
|
||||
'active' => $currentRoute === 'server.command',
|
||||
'navigate' => false,
|
||||
'visible' => auth()->user()?->can('canAccessTerminal'),
|
||||
],
|
||||
@@ -171,33 +171,43 @@
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
|
||||
</span>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<x-reicon name="stop" class="size-3.5 text-error" />
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<x-reicon name="external-link" class="size-3.5 opacity-70" />
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
</div>
|
||||
@@ -276,55 +286,65 @@
|
||||
|
||||
@if ($server->proxySet())
|
||||
@can('manageProxy', $server)
|
||||
<div
|
||||
class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($proxyCanBeStopped)
|
||||
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
|
||||
<x-loading text="Checking Traefik dashboard" />
|
||||
</div>
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="button" target="_blank" href="http://{{ $serverIp }}:8080">
|
||||
Traefik Dashboard
|
||||
<x-external-link />
|
||||
</a>
|
||||
<div id="server-desktop-actions" class="resource-heading-actions relative shrink-0"
|
||||
x-data="{ open: false }" @click.outside="open = false"
|
||||
@keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu" wire:loading.attr="disabled" wire:loading.class="is-loading"
|
||||
wire:target="checkProxy,startProxy">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.right
|
||||
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
|
||||
@if ($proxyCanBeStopped)
|
||||
@if ($traefikDashboardAvailable)
|
||||
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
|
||||
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="external-link" class="size-3! opacity-70" />
|
||||
</span>
|
||||
Traefik Dashboard
|
||||
</a>
|
||||
@endif
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="restart" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Restart Proxy
|
||||
</button>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="stop-circle" class="size-3.5 text-error" />
|
||||
</span>
|
||||
Stop Proxy
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Start Proxy
|
||||
</button>
|
||||
@endif
|
||||
<x-modal-confirmation title="Confirm Proxy Restart?" buttonTitle="Restart Proxy"
|
||||
submitAction="restart" :actions="[
|
||||
'This proxy will be stopped and started again.',
|
||||
'All resources hosted on Coolify will be unavailable during the restart.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Restart Proxy" :dispatchEvent="true"
|
||||
dispatchEventType="restartEvent">
|
||||
<x-slot:content>
|
||||
<x-forms.button title="Restart proxy">
|
||||
<x-reicon name="restart"
|
||||
class="size-4 text-orange-500 dark:text-warning" />
|
||||
Restart Proxy
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
<x-modal-confirmation title="Confirm Proxy Stopping?" buttonTitle="Stop Proxy"
|
||||
submitAction="stop(true)" :actions="[
|
||||
'The Coolify proxy will be stopped.',
|
||||
'All resources hosted on Coolify will be unavailable.',
|
||||
]" :confirmWithText="false" :confirmWithPassword="false"
|
||||
step2ButtonText="Stop Proxy" :dispatchEvent="true"
|
||||
dispatchEventType="stopEvent">
|
||||
<x-slot:content>
|
||||
<x-forms.button isError title="Stop proxy">
|
||||
<x-reicon name="stop" class="size-4 text-error" />
|
||||
Stop Proxy
|
||||
</x-forms.button>
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
@else
|
||||
<x-forms.button @click="$wire.dispatch('checkProxyEvent')"
|
||||
wire:target="checkProxy,startProxy">
|
||||
<x-reicon name="play-circle"
|
||||
class="size-4 text-coollabs dark:text-warning" />
|
||||
Start Proxy
|
||||
</x-forms.button>
|
||||
@endif
|
||||
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300"
|
||||
role="separator"></div>
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
wire:click="checkProxyStatus" wire:loading.attr="disabled"
|
||||
@click="open = false" role="menuitem">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<x-reicon name="refresh" class="size-3.5 opacity-70" />
|
||||
</span>
|
||||
Refresh Proxy Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<div wire:key="team-member-row-{{ $member->id }}"
|
||||
x-cloak x-show="isMemberVisible({{ $member->id }})"
|
||||
x-bind:style="{ order: memberOrder({{ $member->id }}) }"
|
||||
class="data-table-row team-members-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
@class([
|
||||
'data-table-row team-members-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]',
|
||||
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
|
||||
])>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
@@ -24,6 +27,11 @@
|
||||
{{ data_get($member, 'pivot.role') }}
|
||||
</span>
|
||||
</div>
|
||||
@can('manageMembers', currentTeam())
|
||||
<div class="flex items-center">
|
||||
<x-two-factor-badge :enabled="filled($member->two_factor_confirmed_at)" />
|
||||
</div>
|
||||
@endcan
|
||||
<div class="flex justify-end">
|
||||
@can('manageMembers', currentTeam())
|
||||
@if ($member->id !== Auth::id())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
sortBy: 'name_asc',
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
members: @js(currentTeam()->members->map(fn ($member) => [
|
||||
members: @js($members->map(fn ($member) => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
@@ -95,14 +95,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manageMembers', currentTeam())
|
||||
<div
|
||||
class="border-b border-neutral-200 px-4 py-2.5 text-[12px] dark:border-white/[0.08]">
|
||||
@if ($membersWithoutTwoFactorCount > 0)
|
||||
<span class="text-warning-700 dark:text-warning">{{ $membersWithoutTwoFactorCount }} of {{ $members->count() }} {{ Str::plural('member', $members->count()) }} {{ $membersWithoutTwoFactorCount === 1 ? 'does' : 'do' }} not have two-factor authentication enabled.</span>
|
||||
@else
|
||||
<span class="text-neutral-500 dark:text-fg-dim">All members have two-factor authentication enabled.</span>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<div x-cloak x-show="filteredMembers.length > 0" class="data-table flex flex-col">
|
||||
<div class="data-table-header team-members-table-grid">
|
||||
<div @class([
|
||||
'data-table-header team-members-table-grid',
|
||||
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
|
||||
])>
|
||||
<span>Name</span>
|
||||
<span>Email</span>
|
||||
<span>Role</span>
|
||||
@can('manageMembers', currentTeam())
|
||||
<span>2FA</span>
|
||||
@endcan
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
@foreach (currentTeam()->members as $member)
|
||||
@foreach ($members as $member)
|
||||
<livewire:team.member :member="$member" :wire:key="$member->id" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -108,12 +108,39 @@ it('lists existing domains as individual rows', function () {
|
||||
->assertSee('https://example.com')
|
||||
->assertSee('https://www.example.com')
|
||||
->assertSee('https://example.com/favicon.ico', false)
|
||||
->assertSee('class="relative size-4 shrink-0"', false)
|
||||
->assertSee('domain-favicon-fallback', false)
|
||||
->assertSee('class="invisible absolute inset-0 size-4 rounded-sm"', false)
|
||||
->assertSee('$el.previousElementSibling.classList.add(\'hidden\')', false)
|
||||
->assertSee('x-on:error="$el.remove()"', false)
|
||||
->assertSee('class="min-w-0 flex-1 text-[13px]', false)
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateRedirect('))->toBe(2);
|
||||
});
|
||||
|
||||
it('shows one redirect direction control in each compose service header', function () {
|
||||
$this->application->update([
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => "services:\n api:\n image: nginx:alpine\n",
|
||||
'docker_compose_domains' => json_encode([
|
||||
'api' => [
|
||||
'domain' => 'https://api.example.com,https://www.api.example.com',
|
||||
'redirect' => 'www',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$html = Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
->assertSee('api')
|
||||
->html();
|
||||
|
||||
expect(substr_count($html, 'this.$wire.updateServiceRedirect('))->toBe(1)
|
||||
->and(substr_count($html, 'this.$wire.updateRedirect('))->toBe(0)
|
||||
->and(substr_count($html, 'domain-direction-service-api'))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows dns entries control next to Add', function () {
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
|
||||
->assertSuccessful()
|
||||
@@ -1225,15 +1252,25 @@ it('uses the compact service domains layout for compose applications', function
|
||||
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
|
||||
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
|
||||
->toContain('bg-neutral-50 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]')
|
||||
->toContain('class="data-table-header domains-table-grid"')
|
||||
->toContain('class="data-table-header domains-table-grid-service"')
|
||||
->toContain('<span>Direction</span>')
|
||||
->toContain('<span>Search engine indexing</span>')
|
||||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain('id="edit-domain-direction"')
|
||||
->not->toContain('htmlId="application-compose-domain-redirect-{{ $redirectWireKey }}"')
|
||||
->not->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
|
||||
->toContain('id="domain-direction-service-{{ $redirectWireKey }}"')
|
||||
->toContain('onChange="updateServiceRedirect"')
|
||||
->toContain("'showDirectionControl' => false")
|
||||
->not->toContain('title="No domains for this service"');
|
||||
});
|
||||
|
||||
it('does not render a last checked column in the domains table', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
|
||||
expect($view)->not->toContain('<span>Last checked</span>')
|
||||
->and($row)->not->toContain('$checkedAt');
|
||||
});
|
||||
|
||||
it('uses compact labeled domain cards on mobile', function () {
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
$row = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
|
||||
@@ -1264,6 +1301,10 @@ it('uses segmented fields when adding and editing application domains', function
|
||||
->toContain("scheme: 'https'")
|
||||
->toContain('<x-forms.listbox id="{{ $id }}-protocol"')
|
||||
->not->toContain('<select id="{{ $id }}-protocol"')
|
||||
->toContain("['value' => 'https', 'label' => 'https']")
|
||||
->toContain("['value' => 'http', 'label' => 'http']")
|
||||
->toContain('class="mb-1.5 flex h-4 w-full items-center gap-1.5"')
|
||||
->not->toContain('class="mb-1.5 block text-sm font-medium"')
|
||||
->toContain('min="1"')
|
||||
->toContain('max="65535"');
|
||||
});
|
||||
@@ -1407,6 +1448,7 @@ it('updates search engine indexing from the domains view', function () {
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
|
||||
@@ -187,6 +187,8 @@ it('keeps deployment history fields and the log status badge accessible on mobil
|
||||
->and($appCss)
|
||||
->toContain(".deployment-table-scroll {\n overflow-x: auto;")
|
||||
->toContain(".deployment-table-grid {\n min-width: 59rem;")
|
||||
->toContain("@media (min-width: 1024px) {\n .deployment-table-scroll {\n overflow-x: visible;")
|
||||
->toContain(".deployment-table-grid {\n min-width: 0;")
|
||||
->not->toContain('.deployment-table-grid > :nth-child')
|
||||
->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Header version badge should open the matching GitHub release page.
|
||||
*/
|
||||
@@ -15,3 +17,14 @@ test('desktop header version links to the coolify github release for the install
|
||||
->toContain("https://github.com/coollabsio/coolify/releases/tag/v{{ config('constants.coolify.version') }}")
|
||||
->toContain('target="_blank"');
|
||||
});
|
||||
|
||||
test('development versions are not linked to nonexistent github releases', function () {
|
||||
config(['constants.coolify.version' => '4.3.1-dev.d64cbda3e']);
|
||||
|
||||
$version = Blade::render('<x-version />');
|
||||
|
||||
expect($version)
|
||||
->toContain('v4.3.1-dev.d64cbda3e')
|
||||
->not->toContain('href=')
|
||||
->not->toContain('target="_blank"');
|
||||
});
|
||||
|
||||
@@ -88,6 +88,31 @@ test('listbox forwards dynamic disabled state to its trigger', function () {
|
||||
->toContain('x-bind:disabled="!selectedMoveProject || availableEnvironments.length === 0"');
|
||||
});
|
||||
|
||||
test('listbox waits for change handlers and prevents overlapping selections', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('saving: false')
|
||||
->toContain('async choose(option)')
|
||||
->toContain('await this.$wire.')
|
||||
->toContain('if (this.saving || option.disabled) return;')
|
||||
->toContain("'pointer-events-none opacity-70': saving");
|
||||
});
|
||||
|
||||
test('listbox does not send a second live entangle request when using a change handler', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
|
||||
});
|
||||
|
||||
test('listbox can preserve its client value across Livewire morphs', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain("'preserveValue' => false")
|
||||
->toContain('@if ($preserveValue) wire:ignore @endif');
|
||||
});
|
||||
|
||||
test('notification event multiselect truncates long selected summaries', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-notification.event-multiselect id="server-slack-events" label="Servers" :events="[
|
||||
|
||||
@@ -75,6 +75,7 @@ it('renders the changed configuration labels without a second backend request',
|
||||
|
||||
expect($view)
|
||||
->toContain(':compact-after="5000"')
|
||||
->toContain('position="top-right"')
|
||||
->toContain(':compact-storage-key="$compactStorageKey"')
|
||||
->toContain('wire:key="configuration-warning-{{ $currentConfigurationHash }}"')
|
||||
->toContain('x-on:click="configurationDiffModalOpen = true"')
|
||||
@@ -85,6 +86,7 @@ it('supports timed compact popup notifications', function () {
|
||||
$view = file_get_contents(resource_path('views/components/popup-small.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain("\$position === 'top-right' ? 'top-16' : 'bottom-4'")
|
||||
->toContain('compactAfter')
|
||||
->toContain('compactStorageKey')
|
||||
->toContain("localStorage.setItem(this.storageKey, 'compact')")
|
||||
@@ -94,8 +96,10 @@ it('supports timed compact popup notifications', function () {
|
||||
->toContain('compact = true')
|
||||
->toContain('@click="restore()"')
|
||||
->toContain('@click.stop="minimizeToIcon()"')
|
||||
->toContain('x-show="iconOnly"')
|
||||
->toContain('x-show="!iconOnly"')
|
||||
->toContain('<template x-if="iconOnly">')
|
||||
->toContain('<template x-if="!iconOnly">')
|
||||
->not->toContain('<button x-show="iconOnly"')
|
||||
->not->toContain('<div x-show="!iconOnly"')
|
||||
->not->toContain(':class="iconOnly')
|
||||
->toContain('x-show="!compact"')
|
||||
->toContain("'w-[calc(100vw-2rem)] cursor-pointer sm:w-auto sm:max-w-[calc(100vw-2rem)]'");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
test('confirmation modal closes before dispatching an event that can open another modal', function () {
|
||||
$modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php'));
|
||||
|
||||
expect($modal)->toMatch(
|
||||
'/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s'
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ it('aggregates preview container and health check status', function () {
|
||||
->toContain('Health check')
|
||||
->toContain('Not configured')
|
||||
->toContain('aria-label="About unconfigured health checks"')
|
||||
->toContain('class="relative inline-flex align-middle"')
|
||||
->toContain('Traffic can still be routed to the container')
|
||||
->toContain('aria-haspopup="menu"')
|
||||
->toContain('right-auto! left-0!')
|
||||
|
||||
@@ -27,6 +27,13 @@ it('opens the email change form without a Livewire request', function () {
|
||||
->not->toContain('wire:click="showEmailChangeForm"');
|
||||
});
|
||||
|
||||
it('does not show a redundant enabled badge for two-factor authentication', function () {
|
||||
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
|
||||
|
||||
expect($profileView)
|
||||
->not->toContain('<x-status-badge status="Enabled" type="success" />');
|
||||
});
|
||||
|
||||
it('keeps color theme preferences on the profile appearance view without page width or density controls', function () {
|
||||
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
|
||||
|
||||
@@ -104,14 +104,21 @@ it('falls back cleanly when the avatars S3 storage no longer exists', function (
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('renders the profile upload and user menu avatar', function () {
|
||||
it('automatically uploads a selected profile picture and keeps the current avatar until it succeeds', function () {
|
||||
$profile = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
|
||||
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
|
||||
|
||||
expect($profile)
|
||||
->toContain("this.\$wire.upload('avatar', compressed")
|
||||
->toContain('await this.$wire.uploadAvatar()')
|
||||
->toContain('if (uploaded)')
|
||||
->toContain('canvas.toBlob')
|
||||
->toContain('wire:click="uploadAvatar"')
|
||||
->toContain('x-ref="avatarInput"')
|
||||
->toContain('class="hidden"')
|
||||
->toContain("processing ? 'Uploading…' : 'Browse…'")
|
||||
->not->toContain('wire:click="uploadAvatar"')
|
||||
->not->toContain('Upload picture')
|
||||
->not->toContain('type="file" x-on:change')
|
||||
->and($menu)
|
||||
->toContain("route('profile.avatar',");
|
||||
});
|
||||
|
||||
@@ -186,17 +186,11 @@ it('uses the redesigned terminal canvas and controls on resource terminal pages'
|
||||
->not->toContain('application-console-header flex h-[30px]');
|
||||
});
|
||||
|
||||
it('uses a readable theme-aware terminal session expiry label', function () {
|
||||
it('does not overlay the session expiry label on the application terminal', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$styles = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('terminal-session-expiry')
|
||||
->and($styles)
|
||||
->toContain('.terminal-session-expiry')
|
||||
->toContain('font-size: 0.75rem;')
|
||||
->toContain('color: rgb(255 255 255 / 0.6);')
|
||||
->toContain('.terminal-fullscreen-shell[data-console-theme="system"] .terminal-session-expiry');
|
||||
->not->toContain('terminal-session-expiry');
|
||||
});
|
||||
|
||||
it('copies the realtime terminal utilities into the container image', function () {
|
||||
@@ -329,27 +323,52 @@ it('preserves terminal scrollback across transient reconnects', function () {
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
|
||||
it('renders a compact mobile terminal toolbar with shell control keys', function () {
|
||||
it('renders a horizontally scrollable mobile terminal key row', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('Terminal keys')
|
||||
->toContain('sm:hidden')
|
||||
->toContain("sendTerminalControl('arrowUp')")
|
||||
->toContain("sendTerminalControl('arrowDown')")
|
||||
->toContain("sendTerminalControl('arrowLeft')")
|
||||
->toContain("sendTerminalControl('arrowRight')")
|
||||
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
|
||||
->toContain('overflow-x-auto')
|
||||
->toContain('whitespace-nowrap')
|
||||
->toContain('pasteFromClipboard()')
|
||||
->toContain('copyTerminalSelection()')
|
||||
->toContain("sendTerminalControl('tab')")
|
||||
->toContain("sendTerminalControl('escape')")
|
||||
->not->toContain("sendTerminalControl('ctrlC')")
|
||||
->not->toContain('pasteFromClipboard()')
|
||||
->not->toContain('copyTerminalSelection()')
|
||||
->toContain('mobileToolbarCollapsed')
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2' : 'relative mt-2 shrink-0'")
|
||||
->toContain('sendTerminalControl(\'escape\')">ESC</button>')
|
||||
->toContain("toggleTerminalModifier('ctrl')")
|
||||
->toContain("toggleTerminalModifier('alt')")
|
||||
->toContain("sendTerminalKey('/')")
|
||||
->toContain("sendTerminalKey('|')")
|
||||
->toContain("sendTerminalKey('~')")
|
||||
->toContain("sendTerminalKey('-')")
|
||||
->toContain("sendTerminalControl('ctrlC')")
|
||||
->toContain("sendTerminalControl('ctrlBackslash')")
|
||||
->toContain("sendTerminalControl('ctrlS')")
|
||||
->toContain("sendTerminalControl('ctrlZ')")
|
||||
->not->toContain("sendTerminalControl('arrowUp')")
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
|
||||
->toContain('data-terminal-mobile-toolbar')
|
||||
->and($appCss)
|
||||
->toContain('.terminal-mobile-key');
|
||||
->toContain('.terminal-mobile-key')
|
||||
->toContain('min-h-8')
|
||||
->toContain('rounded-full')
|
||||
->toContain('.terminal-key-row')
|
||||
->toContain('background: transparent;')
|
||||
->toContain('var(--terminal-scrollbar');
|
||||
});
|
||||
|
||||
it('shows the terminal key row outside fullscreen mode', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : 'relative z-[2] mt-2 shrink-0'")
|
||||
->not->toContain('class="sm:hidden" data-terminal-mobile-toolbar')
|
||||
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
|
||||
->and($terminalClient)
|
||||
->toContain("this.\$refs.terminalWrapper.style.removeProperty('display')")
|
||||
->not->toContain("this.\$refs.terminalWrapper.style.display = 'block'");
|
||||
});
|
||||
|
||||
it('sends terminal mobile toolbar controls through the websocket', function () {
|
||||
@@ -365,8 +384,15 @@ it('sends terminal mobile toolbar controls through the websocket', function () {
|
||||
->toContain("tab: '\\t'")
|
||||
->toContain("escape: '\\x1b'")
|
||||
->toContain("ctrlC: '\\x03'")
|
||||
->toContain("ctrlBackslash: '\\x1c'")
|
||||
->toContain("ctrlS: '\\x13'")
|
||||
->toContain("ctrlZ: '\\x1a'")
|
||||
->toContain('toggleTerminalModifier(modifier)')
|
||||
->toContain('sendTerminalKey(key)')
|
||||
->toContain('navigator.clipboard.readText()')
|
||||
->toContain('navigator.clipboard.writeText(selection)');
|
||||
->toContain('navigator.clipboard.writeText(selection)')
|
||||
->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.sendMessage({ message: data });")
|
||||
->not->toContain("sendTerminalInput(data) {\n if (!this.term || !this.terminalActive) {\n return;\n }\n\n this.term.focus();");
|
||||
});
|
||||
|
||||
it('uses terminal host dimensions when resizing so mobile controls do not cover terminal rows', function () {
|
||||
@@ -379,24 +405,40 @@ it('uses terminal host dimensions when resizing so mobile controls do not cover
|
||||
->not->toContain('const wrapperHeight = this.$refs.terminalWrapper.clientHeight;');
|
||||
});
|
||||
|
||||
it('uses simple fullscreen bottom margin based on mobile toolbar visibility', function () {
|
||||
it('keeps the fullscreen mobile toolbar above the software keyboard', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('updateFullscreenLayout()')
|
||||
->not->toContain('terminalFullscreenHeight')
|
||||
->not->toContain('window.visualViewport?.height')
|
||||
->toContain('keyboardInset: 0')
|
||||
->toContain('keyboardAnchorTop: 0')
|
||||
->toContain('keyboardViewportHeight: 0')
|
||||
->toContain('updateKeyboardInset()')
|
||||
->toContain('window.visualViewport')
|
||||
->toContain('viewport.height + viewport.offsetTop')
|
||||
->toContain('this.keyboardViewportHeight - visualBottom')
|
||||
->toContain('this.keyboardAnchorTop = Math.round(visualBottom)')
|
||||
->toContain('syncFullscreenShellWithKeyboard(viewport)')
|
||||
->toContain("wrapper.style.setProperty('bottom', 'auto', 'important')")
|
||||
->toContain("window.visualViewport?.addEventListener('resize', this.syncKeyboardInset)")
|
||||
->toContain("window.visualViewport?.addEventListener('scroll', this.syncKeyboardInset)")
|
||||
->toContain("window.addEventListener('resize', this.syncKeyboardInset)")
|
||||
->toContain("window.visualViewport?.removeEventListener('resize', this.syncKeyboardInset)")
|
||||
->toContain("window.visualViewport?.removeEventListener('scroll', this.syncKeyboardInset)")
|
||||
->toContain("window.removeEventListener('resize', this.syncKeyboardInset)")
|
||||
->and($terminalView)
|
||||
->toContain("mobileToolbarCollapsed\n ? 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-14'\n : 'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent max-sm:pb-24'")
|
||||
->toContain("fullscreen ? 'absolute inset-x-0 bottom-0 z-[2] px-2 pb-2'");
|
||||
->toContain("'terminal-host relative z-[1] min-h-0 flex-1 overflow-hidden px-1 py-[5px] bg-transparent'")
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2'")
|
||||
->toContain(':style="!fullscreen && keyboardInset > 0 ? `top: ${keyboardAnchorTop}px; transform: translateY(-100%)` : \'\'"')
|
||||
->toContain("fullscreen ? 'relative z-[2] shrink-0 px-2 pb-2' : (keyboardInset > 0 ? 'fixed inset-x-0 z-[100002] px-2 pb-2'");
|
||||
});
|
||||
|
||||
it('resizes after toggling the mobile terminal toolbar', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
it('resizes after the mobile keyboard viewport changes', function () {
|
||||
$terminalClient = file_get_contents(resource_path('js/terminal.js'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('$nextTick(() => resizeTerminal())');
|
||||
expect($terminalClient)
|
||||
->toContain('window.visualViewport')
|
||||
->toContain('this.$nextTick(() => this.resizeTerminal())');
|
||||
});
|
||||
|
||||
it('uses fixed viewport positioning for fullscreen terminal instead of inherited container size', function () {
|
||||
@@ -438,6 +480,13 @@ it('keeps enter and exit fullscreen controls the same size and chrome', function
|
||||
->toContain('color-mix(in srgb, var(--terminal-scrollbar');
|
||||
});
|
||||
|
||||
it('keeps the application terminal fullscreen control visible on mobile', function () {
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
expect($terminalView)
|
||||
->toContain('opacity-100 sm:opacity-0 sm:group-hover/terminal:opacity-100 sm:focus-visible:opacity-100');
|
||||
});
|
||||
|
||||
it('lets the selected theme show through the active terminal panel', function () {
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
$terminalView = file_get_contents(resource_path('views/livewire/project/shared/terminal.blade.php'));
|
||||
|
||||
@@ -113,17 +113,27 @@ it('places the account menu beside the desktop sidebar toggle while retaining it
|
||||
->toContain("'bottom-full! left-0! right-auto! top-auto! mb-1!' => \$sidebar");
|
||||
});
|
||||
|
||||
it('keeps application links next to advanced actions on the right', function () {
|
||||
it('keeps advanced operations in a separated section at the bottom of actions menus', function () {
|
||||
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
|
||||
|
||||
$desktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$advancedPosition = strpos($desktop, '<x-applications.advanced');
|
||||
$linksPosition = strpos($desktop, '<x-applications.links');
|
||||
$applicationDesktop = str($application)->after('resource-heading-actions flex')->toString();
|
||||
$serviceDesktop = str($service)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($advancedPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->not->toBeFalse()
|
||||
->and($linksPosition)->toBeGreaterThan($advancedPosition)
|
||||
expect($applicationDesktop)
|
||||
->not->toContain('<x-applications.advanced')
|
||||
->toContain('application-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Force deploy without cache')
|
||||
->and($serviceDesktop)
|
||||
->not->toContain('<x-services.advanced')
|
||||
->toContain('service-desktop-actions')
|
||||
->toContain('role="separator"')
|
||||
->toContain('Pull Latest Images & Restart')
|
||||
->toContain('Force Restart')
|
||||
->toContain('Force Deploy')
|
||||
->toContain('Force Cleanup Containers')
|
||||
->and($links)->toContain("'right-0! left-auto! min-w-60! max-w-96!' => !\$fullWidth")
|
||||
->and($links)->toContain('listbox-option justify-start! gap-2.5!')
|
||||
->and($links)->not->toContain('md:left-0 md:right-auto');
|
||||
@@ -146,15 +156,27 @@ it('groups application lifecycle controls in an actions dropdown', function () {
|
||||
->toContain('Deploy');
|
||||
});
|
||||
|
||||
it('shows deploy directly when it is the only available lifecycle action', function () {
|
||||
it('raises the desktop top bar while the service actions dropdown is open', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
|
||||
expect($layout)
|
||||
->toContain('resourceActionsOpen: false')
|
||||
->toContain("'z-[1000]': resourceActionsOpen")
|
||||
->toContain('@resource-actions-toggled.window="resourceActionsOpen = $event.detail.open"')
|
||||
->and($heading)
|
||||
->toContain("\$dispatch('resource-actions-toggled', { open })");
|
||||
});
|
||||
|
||||
it('keeps deploy in the actions menu alongside advanced operations', function () {
|
||||
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$desktop = str($heading)->after('resource-heading-actions flex')->toString();
|
||||
|
||||
expect($desktop)
|
||||
->toContain("@if (str(\$application->status)->startsWith('exited'))")
|
||||
->toContain('id="application-desktop-deploy"')
|
||||
->toContain('@else')
|
||||
->toContain('id="application-desktop-actions"');
|
||||
->toContain('id="application-desktop-actions"')
|
||||
->toContain('Deploy')
|
||||
->toContain('Force deploy without cache');
|
||||
});
|
||||
|
||||
it('moves application backups from the top tabs into the settings sidebar', function () {
|
||||
@@ -229,7 +251,24 @@ it('uses neutral icons for non-destructive resource actions', function () {
|
||||
->not->toContain('class="size-3.5 text-orange-500')
|
||||
->not->toContain('class="size-3.5 text-warning"')
|
||||
->not->toContain('class="size-4 text-warning"')
|
||||
->toContain('name="stop" class="size-3.5 text-error"');
|
||||
->toContain('name="stop-circle"');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a circular stop icon in application and service action menus', function () {
|
||||
$icons = file_get_contents(resource_path('views/components/reicon.blade.php'));
|
||||
|
||||
expect($icons)
|
||||
->toContain("'stop-circle' =>")
|
||||
->toContain('<circle cx="12" cy="12"')
|
||||
->toContain('<rect x="8.25" y="8.25"');
|
||||
|
||||
foreach (['application', 'service'] as $resource) {
|
||||
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
|
||||
|
||||
expect($heading)
|
||||
->toContain('name="stop-circle" class="size-3.5 text-error"')
|
||||
->not->toContain('name="stop" class="size-3.5 text-error"');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -52,3 +52,15 @@ test('searchable listbox keeps the helper outside the label association', functi
|
||||
->toContain('aria-label="More information"')
|
||||
->not->toMatch('/<label[^>]*for="tz-trigger"[^>]*>[\s\S]*aria-label="More information"[\s\S]*<\/label>/');
|
||||
});
|
||||
|
||||
test('searchable listbox serializes change handlers', function () {
|
||||
$listbox = file_get_contents(resource_path('views/components/forms/searchable-listbox.blade.php'));
|
||||
|
||||
expect($listbox)
|
||||
->toContain('saving: false')
|
||||
->toContain('async choose(option)')
|
||||
->toContain('if (this.saving || option.disabled)')
|
||||
->toContain('await this.$wire.')
|
||||
->toContain("'pointer-events-none opacity-70': saving")
|
||||
->toContain('@elseif ($live && ! $onChange) @entangle($id).live');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\SentinelSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('uses the configured development Sentinel URL for seeded servers', function () {
|
||||
DB::table('instance_settings')->insert(['id' => 0]);
|
||||
$user = User::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $user->teams()->first()->id,
|
||||
]);
|
||||
DB::table('server_settings')->where('id', $server->settings->id)->update([
|
||||
'sentinel_custom_url' => 'http://host.docker.internal:8000',
|
||||
]);
|
||||
|
||||
config()->set('app.env', 'local');
|
||||
config()->set('constants.sentinel.dev_url', 'https://coolify-dev.example.com:8000');
|
||||
|
||||
app(SentinelSeeder::class)->run();
|
||||
|
||||
expect($server->settings->fresh()->sentinel_custom_url)
|
||||
->toBe('https://coolify-dev.example.com:8000');
|
||||
});
|
||||
@@ -31,11 +31,36 @@ test('unsaved bar delays show and hides while loading to avoid instant-save flas
|
||||
|
||||
expect($contents)
|
||||
->toContain('wire:dirty.class="is-dirty"')
|
||||
->toContain('wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"')
|
||||
->toContain('wire:loading.class="is-saving"')
|
||||
->toContain('[&.is-dirty]:delay-300')
|
||||
->toContain('delay-0');
|
||||
});
|
||||
|
||||
test('unsaved bar uses a stable transition for its entrance', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('scale-95')
|
||||
->toContain('transition-[opacity,transform,scale]')
|
||||
->toContain('duration-300')
|
||||
->toContain('ease-[cubic-bezier(0.16,1,0.3,1)]')
|
||||
->toContain('[&.is-dirty]:scale-100')
|
||||
->not->toContain('[&.is-dirty]:animate-in');
|
||||
});
|
||||
|
||||
test('unsaved bar transitions out without restarting its entrance animation', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('[&.is-saving]:translate-y-6')
|
||||
->toContain('[&.is-saving]:scale-95')
|
||||
->toContain('[&.is-saving]:opacity-0')
|
||||
->toContain('[&.is-saving]:duration-200')
|
||||
->toContain('[&.is-saving]:ease-in')
|
||||
->toContain('[&.is-saving]:pointer-events-none')
|
||||
->not->toContain('[&.is-saving]:animate-out');
|
||||
});
|
||||
|
||||
test('unsaved bar saves with enter and shows the shortcut on the save button', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
|
||||
@@ -41,3 +41,20 @@ it('uses the branded input focus state for the server filter', function () {
|
||||
->toContain('<x-reicon name="check-circle"')
|
||||
->not->toContain('<x-reicon name="check"');
|
||||
});
|
||||
|
||||
it('groups all desktop proxy controls in the server actions dropdown', function () {
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
$desktopActions = str($navbar)->after('id="server-desktop-actions"')->before('@endteleport')->toString();
|
||||
|
||||
expect($desktopActions)
|
||||
->toContain('Actions')
|
||||
->toContain('Traefik Dashboard')
|
||||
->toContain('name="external-link" class="size-3! opacity-70"')
|
||||
->toContain('class="flex size-4 shrink-0 items-center justify-center"')
|
||||
->toContain('Restart Proxy')
|
||||
->toContain('Stop Proxy')
|
||||
->toContain('Start Proxy')
|
||||
->toContain('Refresh Proxy Status')
|
||||
->toContain('listbox-panel')
|
||||
->not->toContain('<x-modal-confirmation');
|
||||
});
|
||||
|
||||
@@ -14,9 +14,12 @@ test('server cards use icon borders instead of ready badges', function () {
|
||||
|
||||
expect(substr_count($serverIndex, '<x-status-badge'))->toBe(1)
|
||||
->and($serverIndex)
|
||||
->toContain("\$proxyNeedsAttention = \$isReady && \$server->proxySet() && \$server->proxy->status !== 'running'")
|
||||
->toContain('$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && ! $server->isSentinelLive()')
|
||||
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => 'warning'")
|
||||
->toContain("\$isReady => 'success'")
|
||||
->toContain("\$isTransferredAway || \$server->settings->force_disabled => 'error'")
|
||||
->toContain("default => 'warning'")
|
||||
->toContain("default => 'error'")
|
||||
->toContain("server.statusType === 'success' ? 'border-emerald-500/70'")
|
||||
->toContain("server.statusType === 'warning' ? 'border-amber-500/70'")
|
||||
->toContain("'border-red-500/70'")
|
||||
@@ -24,6 +27,15 @@ test('server cards use icon borders instead of ready badges', function () {
|
||||
->toContain(':aria-label="`Server status: ${server.status}`"');
|
||||
});
|
||||
|
||||
test('dashboard server cards warn when proxy or sentinel needs attention', function () {
|
||||
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
|
||||
|
||||
expect($dashboard)
|
||||
->toContain("\$proxyNeedsAttention = \$server->proxySet() && \$server->proxy->status !== 'running'")
|
||||
->toContain('$sentinelNeedsAttention = $server->isSentinelEnabled() && ! $server->isSentinelLive()')
|
||||
->toContain("\$proxyNeedsAttention || \$sentinelNeedsAttention => ['Attention required', 'warning']");
|
||||
});
|
||||
|
||||
test('server table keeps status text without a badge', function () {
|
||||
$serverIndex = file_get_contents(resource_path('views/livewire/server/index.blade.php'));
|
||||
|
||||
|
||||
@@ -107,13 +107,18 @@ it('groups configured domains and shows redirect settings in the table', functio
|
||||
->toContain("id=\"service-domain-direction-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain("id=\"service-domain-indexing-{$this->apiApp->id}-0-trigger\"")
|
||||
->toContain('src="https://api.example.com/favicon.ico"')
|
||||
->toContain('class="relative size-4 shrink-0"')
|
||||
->toContain('domain-favicon-fallback')
|
||||
->toContain('class="invisible absolute inset-0 size-4 rounded-sm"')
|
||||
->toContain('$el.previousElementSibling.classList.add(\'hidden\')')
|
||||
->toContain('x-on:error="$el.remove()"')
|
||||
->toContain('class="min-w-0 flex-1 text-[13px]')
|
||||
->toContain('class="listbox-trigger"')
|
||||
->toContain('application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-visible')
|
||||
->toContain('dark:bg-white/[0.04]')
|
||||
->toContain('<span>Domain</span>')
|
||||
->toContain('<span>DNS</span>')
|
||||
->toContain('<span>Last checked</span>')
|
||||
->toContain('<span>DNS Check</span>')
|
||||
->not->toContain('<span>Last checked</span>')
|
||||
->not->toContain("service-domain-group-{$this->webApp->id}")
|
||||
->and(substr_count($html, '2 domains'))->toBe(1)
|
||||
->and(strpos($html, '>API</span>'))->toBeLessThan(strpos($html, '<span>Domain</span>'))
|
||||
@@ -556,12 +561,16 @@ it('updates search engine indexing from the service domains view', function () {
|
||||
->assertSee('Direction')
|
||||
->assertSee('toggleNoindexDomain', false)
|
||||
->assertSee('updateServiceRedirect', false)
|
||||
->assertSee('wire:ignore', false)
|
||||
->assertDontSee('x-model="localIndexing"', false)
|
||||
->assertDontSee('x-model="localDirection"', false)
|
||||
->assertDontSee('@js(', false)
|
||||
->call('toggleNoindexDomain', $this->apiApp->id, 'https://api.example.com', 'noindex')
|
||||
->assertDispatched('configurationChanged')
|
||||
->assertDispatched('success');
|
||||
->assertDispatched('success')
|
||||
->assertSet('service', fn (Service $service): bool => $service->applications
|
||||
->firstWhere('id', $this->apiApp->id)
|
||||
?->isDomainNoindexed('https://api.example.com') === true);
|
||||
|
||||
expect($this->apiApp->refresh()->noindexDomains()->all())
|
||||
->toBe(['https://api.example.com']);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Team\Member;
|
||||
use App\Livewire\Team\Member\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
function createTeamMember(Team $team, string $role, bool $twoFactorEnabled = false): User
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'name' => fake()->unique()->userName(),
|
||||
'two_factor_confirmed_at' => $twoFactorEnabled ? now() : null,
|
||||
]);
|
||||
|
||||
$team->members()->attach($user->id, ['role' => $role]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function actAsTeamMember(User $user, Team $team): void
|
||||
{
|
||||
test()->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
}
|
||||
|
||||
test('admins see the two factor column with the status of every member', function () {
|
||||
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
|
||||
createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('2FA')
|
||||
->assertSee('Two-factor authentication is enabled')
|
||||
->assertSee('Two-factor authentication is disabled');
|
||||
});
|
||||
|
||||
test('owners see the two factor column and summary', function () {
|
||||
$owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true);
|
||||
createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($owner, $this->team);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('2FA')
|
||||
->assertSee('1 of 2 members does not have two-factor authentication enabled');
|
||||
});
|
||||
|
||||
test('members without member management rights do not see the two factor column', function () {
|
||||
createTeamMember($this->team, 'owner', twoFactorEnabled: true);
|
||||
$member = createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($member, $this->team);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertDontSee('2FA')
|
||||
->assertDontSee('Two-factor authentication is enabled')
|
||||
->assertDontSee('Two-factor authentication is disabled')
|
||||
->assertDontSee('two-factor authentication enabled');
|
||||
});
|
||||
|
||||
test('the member row renders an enabled badge when two factor is confirmed', function () {
|
||||
$admin = createTeamMember($this->team, 'admin');
|
||||
$memberWithTwoFactor = createTeamMember($this->team, 'member', twoFactorEnabled: true);
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $memberWithTwoFactor])
|
||||
->assertSee('Two-factor authentication is enabled')
|
||||
->assertDontSee('Two-factor authentication is disabled');
|
||||
});
|
||||
|
||||
test('the member row renders a disabled badge when two factor is not configured', function () {
|
||||
$admin = createTeamMember($this->team, 'admin');
|
||||
$memberWithoutTwoFactor = createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $memberWithoutTwoFactor])
|
||||
->assertSee('Two-factor authentication is disabled')
|
||||
->assertDontSee('Two-factor authentication is enabled');
|
||||
});
|
||||
|
||||
test('admins see the two factor status of every role, including owners and other admins', function (string $role, bool $twoFactorEnabled, string $expectedStatus) {
|
||||
$admin = createTeamMember($this->team, 'admin');
|
||||
$otherMember = createTeamMember($this->team, $role, twoFactorEnabled: $twoFactorEnabled);
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $otherMember])
|
||||
->assertSee($expectedStatus);
|
||||
})->with([
|
||||
'owner with two factor' => ['owner', true, 'Two-factor authentication is enabled'],
|
||||
'owner without two factor' => ['owner', false, 'Two-factor authentication is disabled'],
|
||||
'another admin with two factor' => ['admin', true, 'Two-factor authentication is enabled'],
|
||||
'another admin without two factor' => ['admin', false, 'Two-factor authentication is disabled'],
|
||||
'member with two factor' => ['member', true, 'Two-factor authentication is enabled'],
|
||||
'member without two factor' => ['member', false, 'Two-factor authentication is disabled'],
|
||||
]);
|
||||
|
||||
test('admins see the two factor status on their own row', function () {
|
||||
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Member::class, ['member' => $admin])
|
||||
->assertSee('You')
|
||||
->assertSee('Two-factor authentication is enabled');
|
||||
});
|
||||
|
||||
test('the summary counts the members that are missing two factor authentication', function () {
|
||||
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
|
||||
createTeamMember($this->team, 'member');
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('1 of 2')
|
||||
->assertSee('does not have two-factor authentication enabled');
|
||||
});
|
||||
|
||||
test('the summary confirms when every member has two factor authentication', function () {
|
||||
$admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true);
|
||||
createTeamMember($this->team, 'member', twoFactorEnabled: true);
|
||||
|
||||
actAsTeamMember($admin, $this->team);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('All members have two-factor authentication enabled')
|
||||
->assertDontSee('do not have two-factor authentication enabled');
|
||||
});
|
||||
@@ -80,6 +80,16 @@ it('opens the global terminal outside Livewire navigation like resource terminal
|
||||
->not->toMatch('/<a title="Terminal"[^>]*wireNavigate\(\)/s');
|
||||
});
|
||||
|
||||
it('keeps the server terminal navigation active during Livewire requests', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'active' => \$activeMenu === 'terminal'")
|
||||
->and($navbar)
|
||||
->toContain("'active' => \$currentRoute === 'server.command'");
|
||||
});
|
||||
|
||||
it('uses floating rounded controls instead of the legacy terminal header bar', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
test('toast messages default to the bottom right while supporting position overrides', function () {
|
||||
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
|
||||
|
||||
expect($toast)
|
||||
->toContain("position: options.position ?? 'bottom-right'")
|
||||
->toContain("position: 'bottom-right'")
|
||||
->toContain("this.position = event.detail.position || 'bottom-right'")
|
||||
->toContain("'right-4 bottom-4 flex-col-reverse': position === 'bottom-right'")
|
||||
->toContain("'left-1/2 top-4 -translate-x-1/2 flex-col': position === 'top-center'");
|
||||
});
|
||||
|
||||
test('toast copy button shows temporary success feedback', function () {
|
||||
$toast = file_get_contents(resource_path('views/components/toast.blade.php'));
|
||||
|
||||
expect($toast)
|
||||
->toContain('copied: false')
|
||||
->toContain('copyToast(toast)')
|
||||
->toContain('toast.copied = true')
|
||||
->toContain('toast.copied = false')
|
||||
->toContain('}, 2000)')
|
||||
->toContain('x-show="!toast.copied"')
|
||||
->toContain('x-show="toast.copied"')
|
||||
->toContain("'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400': toast.copied");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
test('shared tooltips render above toasts and notification banners', function () {
|
||||
$iconTooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
|
||||
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
expect($iconTooltip)->toContain('z-[10000]')
|
||||
->and($helper)->toContain('z-[10000]')
|
||||
->and($navbar)->toContain('z-[10000]')
|
||||
->and($utilities)->toContain('@utility auth-tooltip')
|
||||
->and($utilities)->toContain('@apply fixed z-[10000]');
|
||||
});
|
||||
@@ -1,55 +1,108 @@
|
||||
<?php
|
||||
|
||||
it('publishes v4 branch builds only under the commit sha', function () {
|
||||
it('publishes v4 branch builds under the commit sha with a traceable internal version', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-sha-build.yml');
|
||||
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile');
|
||||
$constants = file_get_contents(dirname(__DIR__, 2).'/config/constants.php');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('name: Build Coolify (SHA)')
|
||||
->toContain('branches: ["v4.x"]')
|
||||
->not->toContain('branches: ["v4.x", "main"]')
|
||||
->toContain('sha-${{ github.sha }}-${{ matrix.arch }}')
|
||||
->toContain('sha-${{ github.sha }}')
|
||||
->not->toContain('bootstrap/getVersion.php')
|
||||
->not->toContain('steps.version.outputs.VERSION')
|
||||
->not->toContain('IMAGE_NAME }}:latest');
|
||||
->toContain('php bootstrap/getVersion.php')
|
||||
->toContain('version=${BASE_VERSION}-dev.${GITHUB_SHA::9}')
|
||||
->toContain('COOLIFY_VERSION=${{ steps.version.outputs.version }}')
|
||||
->not->toContain('IMAGE_NAME }}:latest')
|
||||
->and($dockerfile)
|
||||
->toContain('ARG COOLIFY_VERSION')
|
||||
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
|
||||
->and($constants)
|
||||
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.0'");
|
||||
});
|
||||
|
||||
it('promotes the released commit image without rebuilding it', function () {
|
||||
it('orders a maintenance development build before its stable release', function () {
|
||||
expect(version_compare('4.3.0-dev.d64cbda3e', '4.3.0', '<'))->toBeTrue()
|
||||
->and(version_compare('4.3.0', '4.3.0-dev.d64cbda3e', '>'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('requires a reviewed draft release before building a stable version', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('release:')
|
||||
->toContain('types: [published]')
|
||||
->toContain('TAG_NAME: ${{ github.event.release.tag_name }}')
|
||||
->toContain('git rev-list -n 1 "${TAG_NAME}"')
|
||||
->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"')
|
||||
->toContain('name: Release Coolify Stable')
|
||||
->toContain('workflow_dispatch:')
|
||||
->toContain('tag:')
|
||||
->toContain('github.ref_name != \'v4.x\'')
|
||||
->toContain('github.paginate(github.rest.repos.listReleases')
|
||||
->toContain('release.draft')
|
||||
->toContain('release.prerelease')
|
||||
->toContain('release.body?.trim()')
|
||||
->toContain('bootstrap/getVersion.php')
|
||||
->toContain('--tag "${IMAGE}:${VERSION}"')
|
||||
->not->toContain('docker/build-push-action');
|
||||
->toContain('target_commitish: context.sha')
|
||||
->toContain('tag_name: process.env.TAG_NAME')
|
||||
->toContain('actions/github-script@v8')
|
||||
->not->toContain('actions/github-script@v7')
|
||||
->not->toContain('generate-notes');
|
||||
});
|
||||
|
||||
it('only promotes stable releases to latest', function () {
|
||||
it('keeps support image workflows ready for the production branch rename', function (string $workflowFile) {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2)."/.github/workflows/{$workflowFile}");
|
||||
|
||||
expect($workflow)->toContain('branches: [ "v4.x", "main" ]');
|
||||
})->with([
|
||||
'helper' => 'coolify-helper.yml',
|
||||
'realtime' => 'coolify-realtime.yml',
|
||||
]);
|
||||
|
||||
it('generates the production changelog only from v4.x', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('branches: [ v4.x ]')
|
||||
->not->toContain('main');
|
||||
});
|
||||
|
||||
it('excludes only active production branches from staging builds', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-staging-build.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain(' - v4.x')
|
||||
->not->toContain(' - main');
|
||||
});
|
||||
|
||||
it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () {
|
||||
$workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
|
||||
|
||||
expect($workflow)
|
||||
->toContain('if: ${{ ! github.event.release.prerelease }}')
|
||||
->toContain('--tag "${IMAGE}:latest"');
|
||||
->toContain('docker/build-push-action@v6')
|
||||
->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
|
||||
->toContain('release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}')
|
||||
->toContain('--tag "${IMAGE}:${VERSION}"')
|
||||
->toContain('--tag "${IMAGE}:latest"')
|
||||
->toContain('github.rest.repos.getRelease')
|
||||
->toContain('release.target_commitish !== context.sha')
|
||||
->toContain('release_id: Number(process.env.RELEASE_ID)')
|
||||
->toContain('draft: false')
|
||||
->toContain('permissions: {}')
|
||||
->not->toContain('sarisia/actions-status-discord@v1')
|
||||
->not->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"');
|
||||
});
|
||||
|
||||
it('documents the sha image release process', function () {
|
||||
it('documents the production, rc, and hotfix release flows', function () {
|
||||
$releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
|
||||
|
||||
expect($releaseGuide)
|
||||
->toContain('## Branch Strategy')
|
||||
->toContain('Fixes and release-ready patches')
|
||||
->toContain('open PRs against **`v4.x`**')
|
||||
->toContain('open PRs against **`next`**')
|
||||
->toContain('merge `v4.x` back into `next`')
|
||||
->toContain('Merge the release commit into `v4.x`')
|
||||
->toContain('`Build Coolify (SHA)`')
|
||||
->toContain('`sha-<commit-sha>`')
|
||||
->toContain('targeting the exact commit that produced the SHA image')
|
||||
->toContain('promotes the existing SHA image without rebuilding it')
|
||||
->toContain('Update the CDN')
|
||||
->toContain('Only commits on **`v4.x`** produce production SHA images')
|
||||
->not->toContain('Merging to `main`')
|
||||
->not->toContain('Production Build (v4)');
|
||||
->toContain('| `main` | Latest production source |')
|
||||
->toContain('| `next` | Feature integration and RC releases |')
|
||||
->toContain('| `hotfix/X.Y.Z` | Production fixes based on `main` |')
|
||||
->toContain('feature/* → next → RC')
|
||||
->toContain('next → main → stable release')
|
||||
->toContain('main → hotfix/X.Y.Z → main → next')
|
||||
->toContain('reviewed draft GitHub Release')
|
||||
->toContain('workflows never edit or commit versions')
|
||||
->toContain('Update the CDN only after the release is approved')
|
||||
->not->toContain('`edge`')
|
||||
->not->toContain('promotes the existing SHA image');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user