Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 999b3ddc39 | |||
| d82a386415 | |||
| b2dc2c2f5c | |||
| 6db2ca345a | |||
| 5f380216d4 | |||
| e7131115d5 | |||
| 320a44913c | |||
| de1795952c | |||
| 7f7f147783 | |||
| 645029fb11 | |||
| 0938981ebf | |||
| 87b382cc85 | |||
| be94af386c | |||
| e27c4dcd92 | |||
| c2c2b6f16f | |||
| 058f8f1f7c | |||
| 0ab54fd63a | |||
| 6ac0244960 | |||
| 6338be3b30 | |||
| 72d7b6567b | |||
| 342d2e4bed | |||
| 8bcfeab500 | |||
| 5224387c5a | |||
| 52af6eac68 | |||
| 8990a107a0 | |||
| 53ceea918b | |||
| 19d24075da | |||
| d017c27eaf | |||
| 0bab4a2042 | |||
| f3cd74d9d8 | |||
| e3b4099c9d | |||
| fa2481c650 | |||
| 2c0ffdd590 | |||
| e8fa04404f | |||
| f11a8086b0 | |||
| 95b3bc9c7a | |||
| 93b000776f | |||
| e6fbf501ac | |||
| d3036d569e |
@@ -0,0 +1,370 @@
|
||||
name: Deploy Branch
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_only:
|
||||
description: "Skip build and only run deploy (debug-only)."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.deploy_only }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
packages: write
|
||||
env:
|
||||
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Setup sqlc
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go mod download
|
||||
make gen/mark-fresh
|
||||
export DOCKER_IMAGE_NO_PREREQUISITES=true
|
||||
version="$(./scripts/version.sh)"
|
||||
CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
|
||||
export CODER_IMAGE_BUILD_BASE_TAG
|
||||
make -j build/coder_linux_amd64
|
||||
./scripts/build_docker.sh \
|
||||
--arch amd64 \
|
||||
--target "${CODER_IMAGE_TAG}" \
|
||||
--version "$version" \
|
||||
--push \
|
||||
build/coder_linux_amd64
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
if: ${{ always() && (needs.build.result == 'success' || (github.event_name == 'workflow_dispatch' && inputs.deploy_only && needs.build.result == 'skipped')) }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.ref_name }}
|
||||
DEPLOY_NAME: "pr${{ github.ref_name }}"
|
||||
TEST_DOMAIN_SUFFIX: "${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
|
||||
BRANCH_HOSTNAME: "${{ github.ref_name }}.${{ startsWith(secrets.PR_DEPLOYMENTS_DOMAIN, 'test.') && secrets.PR_DEPLOYMENTS_DOMAIN || format('test.{0}', secrets.PR_DEPLOYMENTS_DOMAIN) }}"
|
||||
CODER_IMAGE_TAG: "ghcr.io/coder/coder-preview:pr${{ github.ref_name }}"
|
||||
REPO: ghcr.io/coder/coder-preview
|
||||
EXPERIMENTS: "*"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up kubeconfig
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Verify cluster authentication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl auth can-i get namespaces > /dev/null
|
||||
|
||||
- name: Check if deployment exists
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
set +e
|
||||
helm_status_output="$(helm status "${DEPLOY_NAME}" --namespace "${DEPLOY_NAME}" 2>&1)"
|
||||
helm_status_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$helm_status_code" -eq 0 ]; then
|
||||
echo "new=false" >> "$GITHUB_OUTPUT"
|
||||
elif echo "$helm_status_output" | grep -qi "release: not found"; then
|
||||
echo "new=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$helm_status_output"
|
||||
exit "$helm_status_code"
|
||||
fi
|
||||
|
||||
# ---- Every push: ensure routing + TLS ----
|
||||
|
||||
- name: Ensure DNS records
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api_base_url="https://api.cloudflare.com/client/v4/zones/${{ secrets.PR_DEPLOYMENTS_ZONE_ID }}/dns_records"
|
||||
base_name="${BRANCH_HOSTNAME}"
|
||||
base_target="${TEST_DOMAIN_SUFFIX}"
|
||||
wildcard_name="*.${BRANCH_HOSTNAME}"
|
||||
|
||||
ensure_cname_record() {
|
||||
local record_name="$1"
|
||||
local record_content="$2"
|
||||
|
||||
echo "Ensuring CNAME ${record_name} -> ${record_content}."
|
||||
|
||||
set +e
|
||||
lookup_raw_response="$(
|
||||
curl -sS -G "${api_base_url}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data-urlencode "name=${record_name}" \
|
||||
--data-urlencode "per_page=100" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
lookup_exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$lookup_exit_code" -eq 0 ]; then
|
||||
lookup_response="${lookup_raw_response%$'\n'*}"
|
||||
lookup_http_code="${lookup_raw_response##*$'\n'}"
|
||||
|
||||
if [ "$lookup_http_code" = "200" ] && echo "$lookup_response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
if echo "$lookup_response" | jq -e '.result[]? | select(.type != "CNAME")' > /dev/null 2>&1; then
|
||||
echo "Conflicting non-CNAME DNS record exists for ${record_name}."
|
||||
echo "$lookup_response"
|
||||
return 1
|
||||
fi
|
||||
|
||||
existing_cname_id="$(echo "$lookup_response" | jq -r '.result[]? | select(.type == "CNAME") | .id' | head -n1)"
|
||||
if [ -n "$existing_cname_id" ]; then
|
||||
existing_content="$(echo "$lookup_response" | jq -r --arg id "$existing_cname_id" '.result[] | select(.id == $id) | .content')"
|
||||
if [ "$existing_content" = "$record_content" ]; then
|
||||
echo "CNAME already set for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Updating existing CNAME for ${record_name}."
|
||||
update_response="$(
|
||||
curl -sS -X PUT "${api_base_url}/${existing_cname_id}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}'
|
||||
)"
|
||||
|
||||
if echo "$update_response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
echo "Updated CNAME for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Cloudflare API error while updating ${record_name}:"
|
||||
echo "$update_response"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Could not query DNS record ${record_name}; attempting create."
|
||||
fi
|
||||
|
||||
max_attempts=6
|
||||
attempt=1
|
||||
last_response=""
|
||||
last_http_code=""
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
echo "Creating DNS record ${record_name} (attempt ${attempt}/${max_attempts})."
|
||||
|
||||
set +e
|
||||
raw_response="$(
|
||||
curl -sS -X POST "${api_base_url}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PR_DEPLOYMENTS_CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type:application/json" \
|
||||
--data '{"type":"CNAME","name":"'"${record_name}"'","content":"'"${record_content}"'","ttl":1,"proxied":false}' \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
curl_exit_code=$?
|
||||
set -e
|
||||
|
||||
curl_failed=false
|
||||
if [ "$curl_exit_code" -eq 0 ]; then
|
||||
response="${raw_response%$'\n'*}"
|
||||
http_code="${raw_response##*$'\n'}"
|
||||
else
|
||||
response="curl exited with code ${curl_exit_code}."
|
||||
http_code="000"
|
||||
curl_failed=true
|
||||
fi
|
||||
|
||||
last_response="$response"
|
||||
last_http_code="$http_code"
|
||||
|
||||
if echo "$response" | jq -e '.success == true' > /dev/null 2>&1; then
|
||||
echo "Created DNS record ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 81057: identical record exists. 81053: host record conflict.
|
||||
if echo "$response" | jq -e '.errors[]? | select(.code == 81057 or .code == 81053)' > /dev/null 2>&1; then
|
||||
echo "DNS record already exists for ${record_name}."
|
||||
return 0
|
||||
fi
|
||||
|
||||
transient_error=false
|
||||
if [ "$curl_failed" = true ] || [ "$http_code" = "429" ]; then
|
||||
transient_error=true
|
||||
elif [[ "$http_code" =~ ^[0-9]{3}$ ]] && [ "$http_code" -ge 500 ] && [ "$http_code" -lt 600 ]; then
|
||||
transient_error=true
|
||||
fi
|
||||
|
||||
if echo "$response" | jq -e '.errors[]? | select(.code == 10000 or .code == 10001)' > /dev/null 2>&1; then
|
||||
transient_error=true
|
||||
fi
|
||||
|
||||
if [ "$transient_error" = true ] && [ "$attempt" -lt "$max_attempts" ]; then
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Transient Cloudflare API error (HTTP ${http_code}). Retrying in ${sleep_seconds}s."
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
break
|
||||
done
|
||||
|
||||
echo "Cloudflare API error while creating DNS record ${record_name} after ${attempt} attempt(s):"
|
||||
echo "HTTP status: ${last_http_code}"
|
||||
echo "$last_response"
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_cname_record "${base_name}" "${base_target}"
|
||||
ensure_cname_record "${wildcard_name}" "${base_name}"
|
||||
|
||||
# ---- First deploy only ----
|
||||
|
||||
- name: Create namespace
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl delete namespace "${DEPLOY_NAME}" || true
|
||||
kubectl create namespace "${DEPLOY_NAME}"
|
||||
|
||||
# ---- Every push: ensure deployment certificate ----
|
||||
|
||||
- name: Ensure certificate
|
||||
env:
|
||||
PR_NUMBER: ${{ env.BRANCH_NAME }}
|
||||
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cert_secret_name="${DEPLOY_NAME}-tls"
|
||||
|
||||
envsubst < ./.github/pr-deployments/certificate.yaml | kubectl apply -f -
|
||||
|
||||
if ! kubectl -n pr-deployment-certs wait --for=condition=Ready "certificate/${cert_secret_name}" --timeout=10m; then
|
||||
echo "Timed out waiting for certificate ${cert_secret_name} to become Ready after 10 minutes."
|
||||
kubectl -n pr-deployment-certs describe certificate "${cert_secret_name}" || true
|
||||
kubectl -n pr-deployment-certs get certificaterequest,order,challenge -l "cert-manager.io/certificate-name=${cert_secret_name}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kubectl get secret "${cert_secret_name}" -n pr-deployment-certs -o json |
|
||||
jq 'del(.metadata.namespace,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.selfLink,.metadata.uid,.metadata.managedFields)' |
|
||||
kubectl -n "${DEPLOY_NAME}" apply -f -
|
||||
|
||||
- name: Set up PostgreSQL
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
||||
helm install coder-db bitnami/postgresql \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--set image.repository=bitnamilegacy/postgresql \
|
||||
--set auth.username=coder \
|
||||
--set auth.password=coder \
|
||||
--set auth.database=coder \
|
||||
--set persistence.size=10Gi
|
||||
kubectl create secret generic coder-db-url -n "${DEPLOY_NAME}" \
|
||||
--from-literal=url="postgres://coder:coder@coder-db-postgresql.${DEPLOY_NAME}.svc.cluster.local:5432/coder?sslmode=disable"
|
||||
|
||||
- name: Create RBAC
|
||||
if: steps.check.outputs.new == 'true'
|
||||
env:
|
||||
PR_NUMBER: ${{ env.BRANCH_NAME }}
|
||||
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
|
||||
run: envsubst < ./.github/pr-deployments/rbac.yaml | kubectl apply -f -
|
||||
|
||||
# ---- Every push ----
|
||||
|
||||
- name: Create values.yaml
|
||||
env:
|
||||
PR_NUMBER: ${{ env.BRANCH_NAME }}
|
||||
PR_HOSTNAME: ${{ env.BRANCH_HOSTNAME }}
|
||||
REPO: ${{ env.REPO }}
|
||||
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_ID }}
|
||||
PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PR_DEPLOYMENTS_GITHUB_OAUTH_CLIENT_SECRET }}
|
||||
run: envsubst < ./.github/pr-deployments/values.yaml > ./deploy-values.yaml
|
||||
|
||||
- name: Install/Upgrade Helm chart
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helm dependency update --skip-refresh ./helm/coder
|
||||
helm upgrade --install "${DEPLOY_NAME}" ./helm/coder \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--values ./deploy-values.yaml \
|
||||
--force
|
||||
|
||||
- name: Install coder-logstream-kube
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
helm repo add coder-logstream-kube https://helm.coder.com/logstream-kube
|
||||
helm upgrade --install coder-logstream-kube coder-logstream-kube/coder-logstream-kube \
|
||||
--namespace "${DEPLOY_NAME}" \
|
||||
--set url="https://${BRANCH_HOSTNAME}"
|
||||
|
||||
- name: Create first user and template
|
||||
if: steps.check.outputs.new == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
URL="https://${BRANCH_HOSTNAME}/bin/coder-linux-amd64"
|
||||
COUNT=0
|
||||
until curl --output /dev/null --silent --head --fail "$URL"; do
|
||||
sleep 5
|
||||
COUNT=$((COUNT+1))
|
||||
if [ "$COUNT" -ge 60 ]; then echo "Timed out"; exit 1; fi
|
||||
done
|
||||
curl -fsSL "$URL" -o /tmp/coder && chmod +x /tmp/coder
|
||||
|
||||
password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12)
|
||||
echo "::add-mask::$password"
|
||||
|
||||
/tmp/coder login \
|
||||
--first-user-username "${BRANCH_NAME}-admin" \
|
||||
--first-user-email "${BRANCH_NAME}@coder.com" \
|
||||
--first-user-password "$password" \
|
||||
--first-user-trial=false \
|
||||
--use-token-as-session \
|
||||
"https://${BRANCH_HOSTNAME}"
|
||||
|
||||
cd .github/pr-deployments/template
|
||||
/tmp/coder templates push -y --variable "namespace=${DEPLOY_NAME}" kubernetes
|
||||
/tmp/coder create --template="kubernetes" kube \
|
||||
--parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
|
||||
/tmp/coder stop kube -y
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -241,7 +241,9 @@ jobs:
|
||||
|
||||
lint-actions:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
# Only run this job if changes to CI workflow files are detected. This job
|
||||
# can flake as it reaches out to GitHub to check referenced actions.
|
||||
if: needs.changes.outputs.ci == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -1184,7 +1186,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1391,7 +1393,7 @@ jobs:
|
||||
id: attest_main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:main"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -1428,7 +1430,7 @@ jobs:
|
||||
id: attest_latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:latest"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -1465,7 +1467,7 @@ jobs:
|
||||
id: attest_version
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.5"
|
||||
|
||||
- uses: nix-community/cache-nix-action@106bba72ed8e29c8357661199511ef07790175e9 # v7.0.1
|
||||
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
|
||||
with:
|
||||
# restore and save a cache using this key
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -233,7 +233,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
id: attest_base
|
||||
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ steps.image-base-tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -564,7 +564,7 @@ jobs:
|
||||
id: attest_main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
@@ -608,7 +608,7 @@ jobs:
|
||||
id: attest_latest
|
||||
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
|
||||
uses: actions/attest@e59cbc1ad1ac2d59339667419eb8cdde6eb61e3d # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ steps.latest_tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
|
||||
+24
-19
@@ -2244,6 +2244,7 @@ type runServerOpts struct {
|
||||
waitForSnapshot bool
|
||||
telemetryDisabled bool
|
||||
waitForTelemetryDisabledCheck bool
|
||||
name string
|
||||
}
|
||||
|
||||
func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
@@ -2266,25 +2267,23 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
"--cache-dir", cacheDir,
|
||||
"--log-filter", ".*",
|
||||
)
|
||||
finished := make(chan bool, 2)
|
||||
inv.Logger = inv.Logger.Named(opts.name)
|
||||
|
||||
errChan := make(chan error, 1)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
pty := ptytest.New(t).Named(opts.name).Attach(inv)
|
||||
go func() {
|
||||
errChan <- inv.WithContext(ctx).Run()
|
||||
finished <- true
|
||||
// close the pty here so that we can start tearing down resources. This test creates multiple servers with
|
||||
// associated ptys. There is a `t.Cleanup()` that does this, but it waits until the whole test is complete.
|
||||
_ = pty.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer func() {
|
||||
finished <- true
|
||||
}()
|
||||
if opts.waitForSnapshot {
|
||||
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
|
||||
}
|
||||
if opts.waitForTelemetryDisabledCheck {
|
||||
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
|
||||
}
|
||||
}()
|
||||
<-finished
|
||||
|
||||
if opts.waitForSnapshot {
|
||||
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "submitted snapshot")
|
||||
}
|
||||
if opts.waitForTelemetryDisabledCheck {
|
||||
pty.ExpectMatchContext(testutil.Context(t, testutil.WaitLong), "finished telemetry status check")
|
||||
}
|
||||
return errChan, cancelFunc
|
||||
}
|
||||
waitForShutdown := func(t *testing.T, errChan chan error) error {
|
||||
@@ -2298,7 +2297,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
errChan, cancelFunc := runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
|
||||
errChan, cancelFunc := runServer(t, runServerOpts{
|
||||
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "0disabled",
|
||||
})
|
||||
cancelFunc()
|
||||
require.NoError(t, waitForShutdown(t, errChan))
|
||||
|
||||
@@ -2306,7 +2307,7 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
require.Empty(t, deployment)
|
||||
require.Empty(t, snapshot)
|
||||
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{waitForSnapshot: true})
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{waitForSnapshot: true, name: "1enabled"})
|
||||
cancelFunc()
|
||||
require.NoError(t, waitForShutdown(t, errChan))
|
||||
// we expect to see a deployment and a snapshot twice:
|
||||
@@ -2325,7 +2326,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{
|
||||
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "2disabled",
|
||||
})
|
||||
cancelFunc()
|
||||
require.NoError(t, waitForShutdown(t, errChan))
|
||||
|
||||
@@ -2341,7 +2344,9 @@ func TestServer_TelemetryDisabled_FinalReport(t *testing.T) {
|
||||
t.Fatalf("timed out waiting for snapshot")
|
||||
}
|
||||
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{telemetryDisabled: true, waitForTelemetryDisabledCheck: true})
|
||||
errChan, cancelFunc = runServer(t, runServerOpts{
|
||||
telemetryDisabled: true, waitForTelemetryDisabledCheck: true, name: "3disabled",
|
||||
})
|
||||
cancelFunc()
|
||||
require.NoError(t, waitForShutdown(t, errChan))
|
||||
// Since telemetry is disabled and we've already sent a snapshot, we expect no
|
||||
|
||||
-58
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/gofrs/flock"
|
||||
"github.com/google/uuid"
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
"github.com/spf13/afero"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
gosshagent "golang.org/x/crypto/ssh/agent"
|
||||
@@ -85,9 +84,6 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
|
||||
containerName string
|
||||
containerUser string
|
||||
|
||||
// Used in tests to simulate the parent exiting.
|
||||
testForcePPID int64
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -179,24 +175,6 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// When running as a ProxyCommand (stdio mode), monitor the parent process
|
||||
// and exit if it dies to avoid leaving orphaned processes. This is
|
||||
// particularly important when editors like VSCode/Cursor spawn SSH
|
||||
// connections and then crash or are killed - we don't want zombie
|
||||
// `coder ssh` processes accumulating.
|
||||
// Note: using gopsutil to check the parent process as this handles
|
||||
// windows processes as well in a standard way.
|
||||
if stdio {
|
||||
ppid := int32(os.Getppid()) // nolint:gosec
|
||||
checkParentInterval := 10 * time.Second // Arbitrary interval to not be too frequent
|
||||
if testForcePPID > 0 {
|
||||
ppid = int32(testForcePPID) // nolint:gosec
|
||||
checkParentInterval = 100 * time.Millisecond // Shorter interval for testing
|
||||
}
|
||||
ctx, cancel = watchParentContext(ctx, quartz.NewReal(), ppid, process.PidExistsWithContext, checkParentInterval)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// Prevent unnecessary logs from the stdlib from messing up the TTY.
|
||||
// See: https://github.com/coder/coder/issues/13144
|
||||
log.SetOutput(io.Discard)
|
||||
@@ -797,12 +775,6 @@ func (r *RootCmd) ssh() *serpent.Command {
|
||||
Value: serpent.BoolOf(&forceNewTunnel),
|
||||
Hidden: true,
|
||||
},
|
||||
{
|
||||
Flag: "test.force-ppid",
|
||||
Description: "Override the parent process ID to simulate a different parent process. ONLY USE THIS IN TESTS.",
|
||||
Value: serpent.Int64Of(&testForcePPID),
|
||||
Hidden: true,
|
||||
},
|
||||
sshDisableAutostartOption(serpent.BoolOf(&disableAutostart)),
|
||||
}
|
||||
return cmd
|
||||
@@ -1690,33 +1662,3 @@ func normalizeWorkspaceInput(input string) string {
|
||||
return input // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// watchParentContext returns a context that is canceled when the parent process
|
||||
// dies. It polls using the provided clock and checks if the parent is alive
|
||||
// using the provided pidExists function.
|
||||
func watchParentContext(ctx context.Context, clock quartz.Clock, originalPPID int32, pidExists func(context.Context, int32) (bool, error), interval time.Duration) (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(ctx) // intentionally shadowed
|
||||
|
||||
go func() {
|
||||
ticker := clock.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
alive, err := pidExists(ctx, originalPPID)
|
||||
// If we get an error checking the parent process (e.g., permission
|
||||
// denied, the process is in an unknown state), we assume the parent
|
||||
// is still alive to avoid disrupting the SSH connection. We only
|
||||
// cancel when we definitively know the parent is gone (alive=false, err=nil).
|
||||
if !alive && err == nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
@@ -312,102 +312,6 @@ type fakeCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func TestWatchParentContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("CancelsWhenParentDies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().NewTicker()
|
||||
defer trap.Close()
|
||||
|
||||
parentAlive := true
|
||||
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
|
||||
return parentAlive, nil
|
||||
}, testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// Wait for the ticker to be created
|
||||
trap.MustWait(ctx).MustRelease(ctx)
|
||||
|
||||
// When: we simulate parent death and advance the clock
|
||||
parentAlive = false
|
||||
mClock.AdvanceNext()
|
||||
|
||||
// Then: The context should be canceled
|
||||
_ = testutil.TryReceive(ctx, t, childCtx.Done())
|
||||
})
|
||||
|
||||
t.Run("DoesNotCancelWhenParentAlive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().NewTicker()
|
||||
defer trap.Close()
|
||||
|
||||
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
|
||||
return true, nil // Parent always alive
|
||||
}, testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// Wait for the ticker to be created
|
||||
trap.MustWait(ctx).MustRelease(ctx)
|
||||
|
||||
// When: we advance the clock several times with the parent alive
|
||||
for range 3 {
|
||||
mClock.AdvanceNext()
|
||||
}
|
||||
|
||||
// Then: context should not be canceled
|
||||
require.NoError(t, childCtx.Err())
|
||||
})
|
||||
|
||||
t.Run("RespectsParentContext", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelParent := context.WithCancel(context.Background())
|
||||
mClock := quartz.NewMock(t)
|
||||
|
||||
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
|
||||
return true, nil
|
||||
}, testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// When: we cancel the parent context
|
||||
cancelParent()
|
||||
|
||||
// Then: The context should be canceled
|
||||
require.ErrorIs(t, childCtx.Err(), context.Canceled)
|
||||
})
|
||||
|
||||
t.Run("DoesNotCancelOnError", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mClock := quartz.NewMock(t)
|
||||
trap := mClock.Trap().NewTicker()
|
||||
defer trap.Close()
|
||||
|
||||
// Simulate an error checking parent status (e.g., permission denied).
|
||||
// We should not cancel the context in this case to avoid disrupting
|
||||
// the SSH connection.
|
||||
childCtx, cancel := watchParentContext(ctx, mClock, 1234, func(context.Context, int32) (bool, error) {
|
||||
return false, xerrors.New("permission denied")
|
||||
}, testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
// Wait for the ticker to be created
|
||||
trap.MustWait(ctx).MustRelease(ctx)
|
||||
|
||||
// When: we advance clock several times
|
||||
for range 3 {
|
||||
mClock.AdvanceNext()
|
||||
}
|
||||
|
||||
// Context should NOT be canceled since we got an error (not a definitive "not alive")
|
||||
require.NoError(t, childCtx.Err(), "context was canceled even though pidExists returned an error")
|
||||
})
|
||||
}
|
||||
|
||||
func (c *fakeCloser) Close() error {
|
||||
*c.closes = append(*c.closes, c)
|
||||
return c.err
|
||||
|
||||
-101
@@ -1122,107 +1122,6 @@ func TestSSH(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// This test ensures that the SSH session exits when the parent process dies.
|
||||
t.Run("StdioExitOnParentDeath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
|
||||
defer cancel()
|
||||
|
||||
// sleepStart -> agentReady -> sessionStarted -> sleepKill -> sleepDone -> cmdDone
|
||||
sleepStart := make(chan int)
|
||||
agentReady := make(chan struct{})
|
||||
sessionStarted := make(chan struct{})
|
||||
sleepKill := make(chan struct{})
|
||||
sleepDone := make(chan struct{})
|
||||
|
||||
// Start a sleep process which we will pretend is the parent.
|
||||
go func() {
|
||||
sleepCmd := exec.Command("sleep", "infinity")
|
||||
if !assert.NoError(t, sleepCmd.Start(), "failed to start sleep command") {
|
||||
return
|
||||
}
|
||||
sleepStart <- sleepCmd.Process.Pid
|
||||
defer close(sleepDone)
|
||||
<-sleepKill
|
||||
sleepCmd.Process.Kill()
|
||||
_ = sleepCmd.Wait()
|
||||
}()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
go func() {
|
||||
defer close(agentReady)
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).WaitFor(coderdtest.AgentsReady)
|
||||
}()
|
||||
|
||||
clientOutput, clientInput := io.Pipe()
|
||||
serverOutput, serverInput := io.Pipe()
|
||||
defer func() {
|
||||
for _, c := range []io.Closer{clientOutput, clientInput, serverOutput, serverInput} {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Start a connection to the agent once it's ready
|
||||
go func() {
|
||||
<-agentReady
|
||||
conn, channels, requests, err := ssh.NewClientConn(&testutil.ReaderWriterConn{
|
||||
Reader: serverOutput,
|
||||
Writer: clientInput,
|
||||
}, "", &ssh.ClientConfig{
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if !assert.NoError(t, err, "failed to create SSH client connection") {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sshClient := ssh.NewClient(conn, channels, requests)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if !assert.NoError(t, err, "failed to create SSH session") {
|
||||
return
|
||||
}
|
||||
close(sessionStarted)
|
||||
<-sleepDone
|
||||
// Ref: https://github.com/coder/internal/issues/1289
|
||||
// This may return either a nil error or io.EOF.
|
||||
// There is an inherent race here:
|
||||
// 1. Sleep process is killed -> sleepDone is closed.
|
||||
// 2. watchParentContext detects parent death, cancels context,
|
||||
// causing SSH session teardown.
|
||||
// 3. We receive from sleepDone and attempt to call session.Close()
|
||||
// Now either:
|
||||
// a. Session teardown completes before we call Close(), resulting in io.EOF
|
||||
// b. We call Close() first, resulting in a nil error.
|
||||
_ = session.Close()
|
||||
}()
|
||||
|
||||
// Wait for our "parent" process to start
|
||||
sleepPid := testutil.RequireReceive(ctx, t, sleepStart)
|
||||
// Wait for the agent to be ready
|
||||
testutil.SoftTryReceive(ctx, t, agentReady)
|
||||
inv, root := clitest.New(t, "ssh", "--stdio", workspace.Name, "--test.force-ppid", fmt.Sprintf("%d", sleepPid))
|
||||
clitest.SetupConfig(t, client, root)
|
||||
inv.Stdin = clientOutput
|
||||
inv.Stdout = serverInput
|
||||
inv.Stderr = io.Discard
|
||||
|
||||
// Start the command
|
||||
clitest.Start(t, inv.WithContext(ctx))
|
||||
|
||||
// Wait for a session to be established
|
||||
testutil.SoftTryReceive(ctx, t, sessionStarted)
|
||||
// Now kill the fake "parent"
|
||||
close(sleepKill)
|
||||
// The sleep process should exit
|
||||
testutil.SoftTryReceive(ctx, t, sleepDone)
|
||||
// And then the command should exit. This is tracked by clitest.Start.
|
||||
})
|
||||
|
||||
t.Run("ForwardAgent", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Test not supported on windows")
|
||||
|
||||
@@ -1244,3 +1244,63 @@ func (api *API) postWorkspaceAgentTaskLogSnapshot(rw http.ResponseWriter, r *htt
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Pause task
|
||||
// @ID pause-task
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Tags Tasks
|
||||
// @Param user path string true "Username, user ID, or 'me' for the authenticated user"
|
||||
// @Param task path string true "Task ID" format(uuid)
|
||||
// @Success 202 {object} codersdk.PauseTaskResponse
|
||||
// @Router /tasks/{user}/{task}/pause [post]
|
||||
func (api *API) pauseTask(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
apiKey = httpmw.APIKey(r)
|
||||
task = httpmw.TaskParam(r)
|
||||
)
|
||||
|
||||
if !task.WorkspaceID.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Task does not have a workspace.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
workspace, err := api.Database.GetWorkspaceByID(ctx, task.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching task workspace.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
buildReq := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStop,
|
||||
Reason: codersdk.CreateWorkspaceBuildReasonTaskManualPause,
|
||||
}
|
||||
build, err := api.postWorkspaceBuildsInternal(
|
||||
ctx,
|
||||
apiKey,
|
||||
workspace,
|
||||
buildReq,
|
||||
func(action policy.Action, object rbac.Objecter) bool {
|
||||
return api.Authorize(r, action, object)
|
||||
},
|
||||
audit.WorkspaceBuildBaggageFromRequest(r),
|
||||
)
|
||||
if err != nil {
|
||||
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusAccepted, codersdk.PauseTaskResponse{
|
||||
WorkspaceBuild: &build,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
agentapisdk "github.com/coder/agentapi-sdk-go"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
@@ -26,11 +27,14 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/notifications"
|
||||
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -100,6 +104,36 @@ func createTaskInState(db database.Store, ownerSubject rbac.Subject, ownerOrgID,
|
||||
}
|
||||
}
|
||||
|
||||
type aiTaskStoreWrapper struct {
|
||||
database.Store
|
||||
getWorkspaceByID func(ctx context.Context, id uuid.UUID) (database.Workspace, error)
|
||||
insertWorkspaceBuild func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error
|
||||
}
|
||||
|
||||
func (s aiTaskStoreWrapper) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
if s.getWorkspaceByID != nil {
|
||||
return s.getWorkspaceByID(ctx, id)
|
||||
}
|
||||
return s.Store.GetWorkspaceByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s aiTaskStoreWrapper) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
|
||||
if s.insertWorkspaceBuild != nil {
|
||||
return s.insertWorkspaceBuild(ctx, arg)
|
||||
}
|
||||
return s.Store.InsertWorkspaceBuild(ctx, arg)
|
||||
}
|
||||
|
||||
func (s aiTaskStoreWrapper) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
|
||||
return s.Store.InTx(func(tx database.Store) error {
|
||||
return fn(aiTaskStoreWrapper{
|
||||
Store: tx,
|
||||
getWorkspaceByID: s.getWorkspaceByID,
|
||||
insertWorkspaceBuild: s.insertWorkspaceBuild,
|
||||
})
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func TestTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2422,3 +2456,328 @@ func TestPostWorkspaceAgentTaskSnapshot(t *testing.T) {
|
||||
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPauseTask(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setupClient := func(t *testing.T, db database.Store, ps pubsub.Pubsub, authorizer rbac.Authorizer) *codersdk.Client {
|
||||
t.Helper()
|
||||
client, _, _ := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: ps,
|
||||
Authorizer: authorizer,
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
setupWorkspaceTask := func(t *testing.T, db database.Store, user codersdk.CreateFirstUserResponse) (database.Task, uuid.UUID) {
|
||||
t.Helper()
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithTask(database.TaskTable{
|
||||
Prompt: "pause me",
|
||||
}, nil).Do()
|
||||
return workspaceBuild.Task, workspaceBuild.Workspace.ID
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ApplyComplete,
|
||||
ProvisionGraph: []*proto.Response{
|
||||
{Type: &proto.Response_Graph{Graph: &proto.GraphComplete{
|
||||
HasAiTasks: true,
|
||||
}}},
|
||||
},
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
task, err := client.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{
|
||||
TemplateVersionID: template.ActiveVersionID,
|
||||
Input: "pause me",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, task.WorkspaceID.Valid)
|
||||
|
||||
workspace, err := client.Workspace(ctx, task.WorkspaceID.UUID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
resp, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
require.NoError(t, err)
|
||||
build := *resp.WorkspaceBuild
|
||||
require.NotNil(t, build)
|
||||
require.Equal(t, codersdk.WorkspaceTransitionStop, build.Transition)
|
||||
require.Equal(t, task.WorkspaceID.UUID, build.WorkspaceID)
|
||||
require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber)
|
||||
require.Equal(t, string(codersdk.CreateWorkspaceBuildReasonTaskManualPause), string(build.Reason))
|
||||
})
|
||||
|
||||
t.Run("Non-owner role access", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
roles []rbac.RoleIdentifier
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "org_member",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "org_admin",
|
||||
roles: []rbac.RoleIdentifier{rbac.ScopedRoleOrgAdmin(owner.OrganizationID)},
|
||||
expectedStatus: http.StatusAccepted,
|
||||
},
|
||||
{
|
||||
name: "sitewide_member",
|
||||
roles: []rbac.RoleIdentifier{rbac.RoleMember()},
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "sitewide_admin",
|
||||
roles: []rbac.RoleIdentifier{rbac.RoleOwner()},
|
||||
expectedStatus: http.StatusAccepted,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task, _ := setupWorkspaceTask(t, db, owner)
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, tc.roles...)
|
||||
|
||||
resp, err := userClient.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
if tc.expectedStatus == http.StatusAccepted {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.WorkspaceBuild)
|
||||
require.NotEqual(t, uuid.Nil, resp.WorkspaceBuild.ID)
|
||||
return
|
||||
}
|
||||
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, tc.expectedStatus, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Task not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, uuid.New())
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Task lookup forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionRead && object.Type == rbac.ResourceTask.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Workspace lookup forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionRead && object.Type == rbac.ResourceWorkspace.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("No Workspace for Task", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).Do()
|
||||
task := dbgen.Task(t, db, database.TaskTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
TemplateVersionID: workspaceBuild.Build.TemplateVersionID,
|
||||
Prompt: "no workspace",
|
||||
})
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
require.Equal(t, "Task does not have a workspace.", apiErr.Message)
|
||||
})
|
||||
|
||||
t.Run("Workspace not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
var workspaceID uuid.UUID
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
if id == workspaceID && id != uuid.Nil {
|
||||
return database.Workspace{}, sql.ErrNoRows
|
||||
}
|
||||
return db.GetWorkspaceByID(ctx, id)
|
||||
},
|
||||
}
|
||||
client := setupClient(t, wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
|
||||
workspaceID = workspaceIDValue
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Workspace lookup internal error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
var workspaceID uuid.UUID
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
getWorkspaceByID: func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
|
||||
if id == workspaceID && id != uuid.Nil {
|
||||
return database.Workspace{}, xerrors.New("boom")
|
||||
}
|
||||
return db.GetWorkspaceByID(ctx, id)
|
||||
},
|
||||
}
|
||||
client := setupClient(t, wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, workspaceIDValue := setupWorkspaceTask(t, db, user)
|
||||
workspaceID = workspaceIDValue
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
require.Equal(t, "Internal error fetching task workspace.", apiErr.Message)
|
||||
})
|
||||
|
||||
t.Run("Build Forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auth := &coderdtest.FakeAuthorizer{
|
||||
ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error {
|
||||
if action == policy.ActionWorkspaceStop && object.Type == rbac.ResourceWorkspace.Type {
|
||||
return rbac.UnauthorizedError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
client := setupClient(t, db, ps, auth)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Job already in progress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
client := setupClient(t, db, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: "pause me",
|
||||
}, nil).
|
||||
Starting().
|
||||
Do()
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, workspaceBuild.Task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusConflict, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Build Internal Error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
wrapped := aiTaskStoreWrapper{
|
||||
Store: db,
|
||||
insertWorkspaceBuild: func(ctx context.Context, arg database.InsertWorkspaceBuildParams) error {
|
||||
return xerrors.New("insert failed")
|
||||
},
|
||||
}
|
||||
client := setupClient(t, wrapped, ps, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
task, _ := setupWorkspaceTask(t, db, user)
|
||||
|
||||
_, err := client.PauseTask(ctx, codersdk.Me, task.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusInternalServerError, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
Generated
+56
-3
@@ -5824,6 +5824,48 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/pause": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Tasks"
|
||||
],
|
||||
"summary": "Pause task",
|
||||
"operationId": "pause-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PauseTaskResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14102,14 +14144,16 @@ const docTemplate = `{
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CreateWorkspaceBuildReasonDashboard",
|
||||
"CreateWorkspaceBuildReasonCLI",
|
||||
"CreateWorkspaceBuildReasonSSHConnection",
|
||||
"CreateWorkspaceBuildReasonVSCodeConnection",
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection"
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection",
|
||||
"CreateWorkspaceBuildReasonTaskManualPause"
|
||||
]
|
||||
},
|
||||
"codersdk.CreateWorkspaceBuildRequest": {
|
||||
@@ -14143,7 +14187,8 @@ const docTemplate = `{
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
@@ -17014,6 +17059,14 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PauseTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace_build": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceBuild"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+52
-3
@@ -5147,6 +5147,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/pause": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"tags": ["Tasks"],
|
||||
"summary": "Pause task",
|
||||
"operationId": "pause-task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Username, user ID, or 'me' for the authenticated user",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Task ID",
|
||||
"name": "task",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.PauseTaskResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{user}/{task}/send": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -12662,14 +12700,16 @@
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CreateWorkspaceBuildReasonDashboard",
|
||||
"CreateWorkspaceBuildReasonCLI",
|
||||
"CreateWorkspaceBuildReasonSSHConnection",
|
||||
"CreateWorkspaceBuildReasonVSCodeConnection",
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection"
|
||||
"CreateWorkspaceBuildReasonJetbrainsConnection",
|
||||
"CreateWorkspaceBuildReasonTaskManualPause"
|
||||
]
|
||||
},
|
||||
"codersdk.CreateWorkspaceBuildRequest": {
|
||||
@@ -12699,7 +12739,8 @@
|
||||
"cli",
|
||||
"ssh_connection",
|
||||
"vscode_connection",
|
||||
"jetbrains_connection"
|
||||
"jetbrains_connection",
|
||||
"task_manual_pause"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
@@ -15477,6 +15518,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.PauseTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspace_build": {
|
||||
"$ref": "#/definitions/codersdk.WorkspaceBuild"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1078,6 +1078,7 @@ func New(options *Options) *API {
|
||||
r.Patch("/input", api.taskUpdateInput)
|
||||
r.Post("/send", api.taskSend)
|
||||
r.Get("/logs", api.taskLogs)
|
||||
r.Post("/pause", api.pauseTask)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,4 +17,6 @@ const (
|
||||
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
|
||||
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
|
||||
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
|
||||
CheckGroupAclIsObject CheckConstraint = "group_acl_is_object" // workspaces
|
||||
CheckUserAclIsObject CheckConstraint = "user_acl_is_object" // workspaces
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/apikey"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
@@ -30,7 +29,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/rbac/rolestore"
|
||||
"github.com/coder/coder/v2/coderd/taskname"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/provisionerd/proto"
|
||||
@@ -1664,13 +1662,12 @@ func Task(t testing.TB, db database.Store, orig database.TaskTable) database.Tas
|
||||
parameters = json.RawMessage([]byte("{}"))
|
||||
}
|
||||
|
||||
taskName := taskname.Generate(genCtx, slog.Make(), orig.Prompt)
|
||||
task, err := db.InsertTask(genCtx, database.InsertTaskParams{
|
||||
ID: takeFirst(orig.ID, uuid.New()),
|
||||
OrganizationID: orig.OrganizationID,
|
||||
OwnerID: orig.OwnerID,
|
||||
Name: takeFirst(orig.Name, taskName.Name),
|
||||
DisplayName: takeFirst(orig.DisplayName, taskName.DisplayName),
|
||||
Name: takeFirst(orig.Name, testutil.GetRandomNameHyphenated(t)),
|
||||
DisplayName: takeFirst(orig.DisplayName, testutil.GetRandomNameHyphenated(t)),
|
||||
WorkspaceID: orig.WorkspaceID,
|
||||
TemplateVersionID: orig.TemplateVersionID,
|
||||
TemplateParameters: parameters,
|
||||
|
||||
Generated
+3
-1
@@ -2736,7 +2736,9 @@ CREATE TABLE workspaces (
|
||||
favorite boolean DEFAULT false NOT NULL,
|
||||
next_start_at timestamp with time zone,
|
||||
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
CONSTRAINT group_acl_is_object CHECK ((jsonb_typeof(group_acl) = 'object'::text)),
|
||||
CONSTRAINT user_acl_is_object CHECK ((jsonb_typeof(user_acl) = 'object'::text))
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE workspaces
|
||||
DROP CONSTRAINT IF EXISTS group_acl_is_object,
|
||||
DROP CONSTRAINT IF EXISTS user_acl_is_object;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add constraints that reject 'null'::jsonb for group and user ACLs
|
||||
-- because they would break the new workspace_expanded view.
|
||||
|
||||
UPDATE workspaces SET group_acl = '{}'::jsonb WHERE group_acl = 'null'::jsonb;
|
||||
UPDATE workspaces SET user_acl = '{}'::jsonb WHERE user_acl = 'null'::jsonb;
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD CONSTRAINT group_acl_is_object CHECK (jsonb_typeof(group_acl) = 'object'),
|
||||
ADD CONSTRAINT user_acl_is_object CHECK (jsonb_typeof(user_acl) = 'object');
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
-- Fixture for migration 000417_workspace_acl_object_constraint.
|
||||
-- Inserts a workspace with 'null'::json ACLs to ensure the migration
|
||||
-- correctly normalizes such values.
|
||||
|
||||
INSERT INTO workspaces (
|
||||
id,
|
||||
created_at,
|
||||
updated_at,
|
||||
owner_id,
|
||||
organization_id,
|
||||
template_id,
|
||||
deleted,
|
||||
name,
|
||||
last_used_at,
|
||||
automatic_updates,
|
||||
favorite,
|
||||
group_acl,
|
||||
user_acl
|
||||
)
|
||||
VALUES (
|
||||
'6f6fdbee-4c18-4a5c-8a8d-9b811c9f0a28',
|
||||
'2024-02-10 00:00:00+00',
|
||||
'2024-02-10 00:00:00+00',
|
||||
'30095c71-380b-457a-8995-97b8ee6e5307',
|
||||
'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1',
|
||||
'4cc1f466-f326-477e-8762-9d0c6781fc56',
|
||||
false,
|
||||
'acl-null-workspace',
|
||||
'0001-01-01 00:00:00+00',
|
||||
'never',
|
||||
false,
|
||||
'null'::jsonb,
|
||||
'null'::jsonb
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -6765,6 +6765,65 @@ func TestWorkspaceBuildDeadlineConstraint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceACLObjectConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
template := dbgen.Template(t, db, database.Template{
|
||||
CreatedBy: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
})
|
||||
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: user.ID,
|
||||
TemplateID: template.ID,
|
||||
Deleted: false,
|
||||
})
|
||||
|
||||
t.Run("GroupACLNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilACL database.WorkspaceACL
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: nilACL,
|
||||
UserACL: database.WorkspaceACL{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckGroupAclIsObject))
|
||||
})
|
||||
|
||||
t.Run("UserACLNull", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var nilACL database.WorkspaceACL
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: database.WorkspaceACL{},
|
||||
UserACL: nilACL,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, database.IsCheckViolation(err, database.CheckUserAclIsObject))
|
||||
})
|
||||
|
||||
t.Run("ValidEmptyObjects", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := db.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||||
ID: workspace.ID,
|
||||
GroupACL: database.WorkspaceACL{},
|
||||
UserACL: database.WorkspaceACL{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetLatestWorkspaceBuildsByWorkspaceIDs populates the database with
|
||||
// workspaces and builds. It then tests that
|
||||
// GetLatestWorkspaceBuildsByWorkspaceIDs returns the latest build for some
|
||||
|
||||
@@ -384,7 +384,7 @@ func (api *API) postWorkspaceBuildsInternal(
|
||||
Experiments(api.Experiments).
|
||||
TemplateVersionPresetID(createBuild.TemplateVersionPresetID)
|
||||
|
||||
if transition == database.WorkspaceTransitionStart && createBuild.Reason != "" {
|
||||
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
|
||||
builder = builder.Reason(database.BuildReason(createBuild.Reason))
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,31 @@ func (c *Client) UpdateTaskInput(ctx context.Context, user string, id uuid.UUID,
|
||||
return nil
|
||||
}
|
||||
|
||||
// PauseTaskResponse represents the response from pausing a task.
|
||||
type PauseTaskResponse struct {
|
||||
WorkspaceBuild *WorkspaceBuild `json:"workspace_build"`
|
||||
}
|
||||
|
||||
// PauseTask pauses a task by stopping its workspace.
|
||||
// Experimental: uses the /api/experimental endpoint.
|
||||
func (c *Client) PauseTask(ctx context.Context, user string, id uuid.UUID) (PauseTaskResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/tasks/%s/%s/pause", user, id.String()), nil)
|
||||
if err != nil {
|
||||
return PauseTaskResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusAccepted {
|
||||
return PauseTaskResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp PauseTaskResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
return PauseTaskResponse{}, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TaskLogType indicates the source of a task log entry.
|
||||
type TaskLogType string
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ const (
|
||||
CreateWorkspaceBuildReasonSSHConnection CreateWorkspaceBuildReason = "ssh_connection"
|
||||
CreateWorkspaceBuildReasonVSCodeConnection CreateWorkspaceBuildReason = "vscode_connection"
|
||||
CreateWorkspaceBuildReasonJetbrainsConnection CreateWorkspaceBuildReason = "jetbrains_connection"
|
||||
CreateWorkspaceBuildReasonTaskManualPause CreateWorkspaceBuildReason = "task_manual_pause"
|
||||
)
|
||||
|
||||
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
|
||||
@@ -129,7 +130,7 @@ type CreateWorkspaceBuildRequest struct {
|
||||
// TemplateVersionPresetID is the ID of the template version preset to use for the build.
|
||||
TemplateVersionPresetID uuid.UUID `json:"template_version_preset_id,omitempty" format:"uuid"`
|
||||
// Reason sets the reason for the workspace build.
|
||||
Reason CreateWorkspaceBuildReason `json:"reason,omitempty" validate:"omitempty,oneof=dashboard cli ssh_connection vscode_connection jetbrains_connection"`
|
||||
Reason CreateWorkspaceBuildReason `json:"reason,omitempty" validate:"omitempty,oneof=dashboard cli ssh_connection vscode_connection jetbrains_connection task_manual_pause"`
|
||||
}
|
||||
|
||||
type WorkspaceOptions struct {
|
||||
|
||||
@@ -119,9 +119,7 @@ this:
|
||||
- Run `./scripts/deploy-pr.sh`
|
||||
- Manually trigger the
|
||||
[`pr-deploy.yaml`](https://github.com/coder/coder/actions/workflows/pr-deploy.yaml)
|
||||
GitHub Action workflow:
|
||||
|
||||
<Image src="./images/deploy-pr-manually.png" alt="Deploy PR manually" height="348px" align="center" />
|
||||
GitHub Action workflow.
|
||||
|
||||
#### Available options
|
||||
|
||||
|
||||
@@ -220,16 +220,12 @@ screen-readers; a placeholder text value is not enough for all users.
|
||||
When possible, make sure that all image/graphic elements have accompanying text
|
||||
that describes the image. `<img />` elements should have an `alt` text value. In
|
||||
other situations, it might make sense to place invisible, descriptive text
|
||||
inside the component itself using MUI's `visuallyHidden` utility function.
|
||||
inside the component itself using Tailwind's `sr-only` class.
|
||||
|
||||
```tsx
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
|
||||
<Button>
|
||||
<GearIcon />
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
Settings
|
||||
</Box>
|
||||
<span className="sr-only">Settings</span>
|
||||
</Button>;
|
||||
```
|
||||
|
||||
|
||||
Generated
+227
-8
@@ -2184,9 +2184,9 @@ This is required on creation to enable a user-flow of validating a template work
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value(s) |
|
||||
|-----------------------------------------------------------------------------------|
|
||||
| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `vscode_connection` |
|
||||
| Value(s) |
|
||||
|--------------------------------------------------------------------------------------------------------|
|
||||
| `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `vscode_connection` |
|
||||
|
||||
## codersdk.CreateWorkspaceBuildRequest
|
||||
|
||||
@@ -2227,11 +2227,11 @@ This is required on creation to enable a user-flow of validating a template work
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value(s) |
|
||||
|--------------|-----------------------------------------------------------------------------------|
|
||||
| `log_level` | `debug` |
|
||||
| `reason` | `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `vscode_connection` |
|
||||
| `transition` | `delete`, `start`, `stop` |
|
||||
| Property | Value(s) |
|
||||
|--------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `log_level` | `debug` |
|
||||
| `reason` | `cli`, `dashboard`, `jetbrains_connection`, `ssh_connection`, `task_manual_pause`, `vscode_connection` |
|
||||
| `transition` | `delete`, `start`, `stop` |
|
||||
|
||||
## codersdk.CreateWorkspaceProxyRequest
|
||||
|
||||
@@ -6178,6 +6178,225 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
|
||||
| `name` | string | true | | |
|
||||
| `regenerate_token` | boolean | false | | |
|
||||
|
||||
## codersdk.PauseTaskResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace_build": {
|
||||
"build_number": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"daily_cost": 0,
|
||||
"deadline": "2019-08-24T14:15:22Z",
|
||||
"has_ai_task": true,
|
||||
"has_external_agent": true,
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
|
||||
"initiator_name": "string",
|
||||
"job": {
|
||||
"available_workers": [
|
||||
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
|
||||
],
|
||||
"canceled_at": "2019-08-24T14:15:22Z",
|
||||
"completed_at": "2019-08-24T14:15:22Z",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"error": "string",
|
||||
"error_code": "REQUIRED_TEMPLATE_VARIABLES",
|
||||
"file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
|
||||
"input": {
|
||||
"error": "string",
|
||||
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
|
||||
"workspace_build_id": "badaf2eb-96c5-4050-9f1d-db2d39ca5478"
|
||||
},
|
||||
"logs_overflowed": true,
|
||||
"metadata": {
|
||||
"template_display_name": "string",
|
||||
"template_icon": "string",
|
||||
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
|
||||
"template_name": "string",
|
||||
"template_version_name": "string",
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
|
||||
"workspace_name": "string"
|
||||
},
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"queue_position": 0,
|
||||
"queue_size": 0,
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"status": "pending",
|
||||
"tags": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"type": "template_version_import",
|
||||
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b",
|
||||
"worker_name": "string"
|
||||
},
|
||||
"matched_provisioners": {
|
||||
"available": 0,
|
||||
"count": 0,
|
||||
"most_recently_seen": "2019-08-24T14:15:22Z"
|
||||
},
|
||||
"max_deadline": "2019-08-24T14:15:22Z",
|
||||
"reason": "initiator",
|
||||
"resources": [
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"api_version": "string",
|
||||
"apps": [
|
||||
{
|
||||
"command": "string",
|
||||
"display_name": "string",
|
||||
"external": true,
|
||||
"group": "string",
|
||||
"health": "disabled",
|
||||
"healthcheck": {
|
||||
"interval": 0,
|
||||
"threshold": 0,
|
||||
"url": "string"
|
||||
},
|
||||
"hidden": true,
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"open_in": "slim-window",
|
||||
"sharing_level": "owner",
|
||||
"slug": "string",
|
||||
"statuses": [
|
||||
{
|
||||
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
|
||||
"app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"message": "string",
|
||||
"needs_user_attention": true,
|
||||
"state": "working",
|
||||
"uri": "string",
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9"
|
||||
}
|
||||
],
|
||||
"subdomain": true,
|
||||
"subdomain_name": "string",
|
||||
"tooltip": "string",
|
||||
"url": "string"
|
||||
}
|
||||
],
|
||||
"architecture": "string",
|
||||
"connection_timeout_seconds": 0,
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"directory": "string",
|
||||
"disconnected_at": "2019-08-24T14:15:22Z",
|
||||
"display_apps": [
|
||||
"vscode"
|
||||
],
|
||||
"environment_variables": {
|
||||
"property1": "string",
|
||||
"property2": "string"
|
||||
},
|
||||
"expanded_directory": "string",
|
||||
"first_connected_at": "2019-08-24T14:15:22Z",
|
||||
"health": {
|
||||
"healthy": false,
|
||||
"reason": "agent has lost connection"
|
||||
},
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"instance_id": "string",
|
||||
"last_connected_at": "2019-08-24T14:15:22Z",
|
||||
"latency": {
|
||||
"property1": {
|
||||
"latency_ms": 0,
|
||||
"preferred": true
|
||||
},
|
||||
"property2": {
|
||||
"latency_ms": 0,
|
||||
"preferred": true
|
||||
}
|
||||
},
|
||||
"lifecycle_state": "created",
|
||||
"log_sources": [
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"display_name": "string",
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"workspace_agent_id": "7ad2e618-fea7-4c1a-b70a-f501566a72f1"
|
||||
}
|
||||
],
|
||||
"logs_length": 0,
|
||||
"logs_overflowed": true,
|
||||
"name": "string",
|
||||
"operating_system": "string",
|
||||
"parent_id": {
|
||||
"uuid": "string",
|
||||
"valid": true
|
||||
},
|
||||
"ready_at": "2019-08-24T14:15:22Z",
|
||||
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
|
||||
"scripts": [
|
||||
{
|
||||
"cron": "string",
|
||||
"display_name": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"log_path": "string",
|
||||
"log_source_id": "4197ab25-95cf-4b91-9c78-f7f2af5d353a",
|
||||
"run_on_start": true,
|
||||
"run_on_stop": true,
|
||||
"script": "string",
|
||||
"start_blocks_login": true,
|
||||
"timeout": 0
|
||||
}
|
||||
],
|
||||
"started_at": "2019-08-24T14:15:22Z",
|
||||
"startup_script_behavior": "blocking",
|
||||
"status": "connecting",
|
||||
"subsystems": [
|
||||
"envbox"
|
||||
],
|
||||
"troubleshooting_url": "string",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"version": "string"
|
||||
}
|
||||
],
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"daily_cost": 0,
|
||||
"hide": true,
|
||||
"icon": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f",
|
||||
"metadata": [
|
||||
{
|
||||
"key": "string",
|
||||
"sensitive": true,
|
||||
"value": "string"
|
||||
}
|
||||
],
|
||||
"name": "string",
|
||||
"type": "string",
|
||||
"workspace_transition": "start"
|
||||
}
|
||||
],
|
||||
"status": "pending",
|
||||
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
|
||||
"template_version_name": "string",
|
||||
"template_version_preset_id": "512a53a7-30da-446e-a1fc-713c630baff1",
|
||||
"transition": "start",
|
||||
"updated_at": "2019-08-24T14:15:22Z",
|
||||
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
|
||||
"workspace_name": "string",
|
||||
"workspace_owner_avatar_url": "string",
|
||||
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
|
||||
"workspace_owner_name": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-------------------|----------------------------------------------------|----------|--------------|-------------|
|
||||
| `workspace_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
|
||||
|
||||
## codersdk.Permission
|
||||
|
||||
```json
|
||||
|
||||
Generated
+32
@@ -365,6 +365,38 @@ curl -X GET http://coder-server:8080/api/v2/tasks/{user}/{task}/logs \
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Pause task
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/tasks/{user}/{task}/pause \
|
||||
-H 'Accept: */*' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /tasks/{user}/{task}/pause`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|--------------|----------|-------------------------------------------------------|
|
||||
| `user` | path | string | true | Username, user ID, or 'me' for the authenticated user |
|
||||
| `task` | path | string(uuid) | true | Task ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 202 Response
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------------|-------------|--------------------------------------------------------------------|
|
||||
| 202 | [Accepted](https://tools.ietf.org/html/rfc7231#section-6.3.3) | Accepted | [codersdk.PauseTaskResponse](schemas.md#codersdkpausetaskresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Send input to AI task
|
||||
|
||||
### Code samples
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 1.86.0
|
||||
FROM rust:slim@sha256:df6ca8f96d338697ccdbe3ccac57a85d2172e03a2429c2d243e74f3bb83ba2f5 AS rust-utils
|
||||
FROM rust:slim@sha256:760ad1d638d70ebbd0c61e06210e1289cbe45ff6425e3ea6e01241de3e14d08e AS rust-utils
|
||||
# Install rust helper programs
|
||||
ENV CARGO_INSTALL_ROOT=/tmp/
|
||||
# Use more reliable mirrors for Debian packages
|
||||
|
||||
@@ -2,8 +2,8 @@ package aibridged_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -105,10 +105,65 @@ func TestPool(t *testing.T) {
|
||||
require.EqualValues(t, 2, cacheMetrics.KeysEvicted())
|
||||
require.EqualValues(t, 1, cacheMetrics.Hits())
|
||||
require.EqualValues(t, 3, cacheMetrics.Misses())
|
||||
}
|
||||
|
||||
// TODO: add test for expiry.
|
||||
// This requires Go 1.25's [synctest](https://pkg.go.dev/testing/synctest) since the
|
||||
// internal cache lib cannot be tested using coder/quartz.
|
||||
func TestPool_Expiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
logger := slogtest.Make(t, nil)
|
||||
ctrl := gomock.NewController(t)
|
||||
client := mock.NewMockDRPCClient(ctrl)
|
||||
mcpProxy := mcpmock.NewMockServerProxier(ctrl)
|
||||
mcpProxy.EXPECT().Init(gomock.Any()).AnyTimes().Return(nil)
|
||||
mcpProxy.EXPECT().Shutdown(gomock.Any()).AnyTimes().Return(nil)
|
||||
|
||||
const ttl = time.Second
|
||||
opts := aibridged.PoolOptions{MaxItems: 1, TTL: ttl}
|
||||
pool, err := aibridged.NewCachedBridgePool(opts, nil, logger, nil, testTracer)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { pool.Shutdown(context.Background()) })
|
||||
|
||||
req := aibridged.Request{
|
||||
SessionKey: "key",
|
||||
InitiatorID: uuid.New(),
|
||||
APIKeyID: uuid.New().String(),
|
||||
}
|
||||
clientFn := func() (aibridged.DRPCClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// First acquire is a cache miss.
|
||||
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second acquire is a cache hit.
|
||||
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
|
||||
require.NoError(t, err)
|
||||
|
||||
metrics := pool.CacheMetrics()
|
||||
require.EqualValues(t, 1, metrics.Misses())
|
||||
require.EqualValues(t, 1, metrics.Hits())
|
||||
|
||||
// TTL expires
|
||||
time.Sleep(ttl + time.Millisecond)
|
||||
|
||||
// Third acquire is a cache miss because the entry expired.
|
||||
_, err = pool.Acquire(ctx, req, clientFn, newMockMCPFactory(mcpProxy))
|
||||
require.NoError(t, err)
|
||||
|
||||
metrics = pool.CacheMetrics()
|
||||
require.EqualValues(t, 2, metrics.Misses())
|
||||
require.EqualValues(t, 1, metrics.Hits())
|
||||
|
||||
// Wait for all eviction goroutines to complete before gomock's ctrl.Finish()
|
||||
// runs in test cleanup. ristretto's OnEvict callback spawns goroutines that
|
||||
// need to finish calling mcpProxy.Shutdown() before ctrl.finish clears the
|
||||
// expectations.
|
||||
synctest.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
var _ aibridged.MCPProxyBuilder = &mockMCPFactory{}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# AI Bridge Proxy
|
||||
|
||||
A MITM (Man-in-the-Middle) proxy server for intercepting and decrypting HTTPS requests to AI providers.
|
||||
|
||||
## Overview
|
||||
|
||||
The AI Bridge Proxy intercepts HTTPS traffic, decrypts it using a configured CA certificate, and forwards requests to AI Bridge for processing.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Certificate Setup
|
||||
|
||||
Generate a CA key pair for MITM:
|
||||
|
||||
#### 1. Generate a new private key
|
||||
|
||||
```sh
|
||||
openssl genrsa -out mitm.key 2048
|
||||
chmod 400 mitm.key
|
||||
```
|
||||
|
||||
#### 2. Create a self-signed CA certificate
|
||||
|
||||
```sh
|
||||
openssl req -new -x509 -days 365 \
|
||||
-key mitm.key \
|
||||
-out mitm.crt \
|
||||
-subj "/CN=Coder AI Bridge Proxy CA"
|
||||
```
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|------------------------------------|---------------------------------|---------|
|
||||
| `CODER_AIBRIDGE_PROXY_ENABLED` | Enable the AI Bridge Proxy | `false` |
|
||||
| `CODER_AIBRIDGE_PROXY_LISTEN_ADDR` | Address the proxy listens on | `:8888` |
|
||||
| `CODER_AIBRIDGE_PROXY_CERT_FILE` | Path to the CA certificate file | - |
|
||||
| `CODER_AIBRIDGE_PROXY_KEY_FILE` | Path to the CA private key file | - |
|
||||
|
||||
### Client Configuration
|
||||
|
||||
Clients must trust the proxy's CA certificate and authenticate with their Coder session token.
|
||||
|
||||
#### CA Certificate
|
||||
|
||||
Clients need to trust the MITM CA certificate:
|
||||
|
||||
```sh
|
||||
# Node.js
|
||||
export NODE_EXTRA_CA_CERTS="/path/to/mitm.crt"
|
||||
|
||||
# Python (requests, httpx)
|
||||
export REQUESTS_CA_BUNDLE="/path/to/mitm.crt"
|
||||
export SSL_CERT_FILE="/path/to/mitm.crt"
|
||||
|
||||
# Go
|
||||
export SSL_CERT_FILE="/path/to/mitm.crt"
|
||||
```
|
||||
|
||||
#### Proxy Authentication
|
||||
|
||||
Clients authenticate with the proxy using their Coder session token in the `Proxy-Authorization` header via HTTP Basic Auth.
|
||||
The token is passed as the password (username is ignored):
|
||||
|
||||
```sh
|
||||
export HTTP_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
|
||||
export HTTPS_PROXY="http://ignored:<coder-session-token>@<proxy-host>:<proxy-port>"
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
export HTTP_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
|
||||
export HTTPS_PROXY="http://coder:${CODER_SESSION_TOKEN}@localhost:8888"
|
||||
```
|
||||
|
||||
Most HTTP clients and AI SDKs will automatically use these environment variables.
|
||||
@@ -163,7 +163,7 @@ require (
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e
|
||||
github.com/pkg/sftp v1.13.7
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus-community/pro-bing v0.8.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/prometheus/common v0.67.4
|
||||
@@ -198,14 +198,14 @@ require (
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/tools v0.41.0
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
|
||||
google.golang.org/api v0.264.0
|
||||
google.golang.org/api v0.265.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.74.0
|
||||
@@ -450,7 +450,7 @@ require (
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
howett.net/plist v1.0.0 // indirect
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
|
||||
@@ -473,7 +473,7 @@ require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.19.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
|
||||
github.com/coder/aibridge v1.0.2
|
||||
github.com/coder/aibridge v1.0.3
|
||||
github.com/coder/aisdk-go v0.0.9
|
||||
github.com/coder/boundary v0.8.0
|
||||
github.com/coder/preview v1.0.4
|
||||
@@ -481,7 +481,7 @@ require (
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0
|
||||
github.com/elazarl/goproxy v1.8.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
github.com/icholy/replace v0.6.0
|
||||
github.com/mark3labs/mcp-go v0.38.0
|
||||
gonum.org/v1/gonum v0.17.0
|
||||
|
||||
@@ -927,8 +927,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
|
||||
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
|
||||
github.com/coder/aibridge v1.0.2 h1:cVPr9+TFLIzULpKPGI/1lnL14+DruedR7KnjZHklIEU=
|
||||
github.com/coder/aibridge v1.0.2/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
|
||||
github.com/coder/aibridge v1.0.3 h1:gt3XKbnFBJ/jyls/yanU/iWZO5yhd6LVYuTQbEZ/SxQ=
|
||||
github.com/coder/aibridge v1.0.3/go.mod h1:c7Of2xfAksZUrPWN180Eh60fiKgzs7dyOjniTjft6AE=
|
||||
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
|
||||
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
|
||||
github.com/coder/boundary v0.8.0 h1:g/H6VIGY4IoWeKkbvao7zhO1BAQe7upSHfHzoAZxdik=
|
||||
@@ -1149,8 +1149,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
|
||||
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
|
||||
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@@ -1743,8 +1743,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -2264,8 +2264,8 @@ golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -2385,8 +2385,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
@@ -2591,8 +2591,8 @@ google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/
|
||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||
google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
|
||||
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
|
||||
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
|
||||
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
|
||||
google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU=
|
||||
google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
@@ -2737,8 +2737,8 @@ google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb
|
||||
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
|
||||
+13
-3
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/acarl005/stripansi"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/pty"
|
||||
@@ -78,7 +79,7 @@ func newExpecter(t *testing.T, r io.Reader, name string) outExpecter {
|
||||
ex := outExpecter{
|
||||
t: t,
|
||||
out: out,
|
||||
name: name,
|
||||
name: atomic.NewString(name),
|
||||
|
||||
runeReader: bufio.NewReaderSize(out, utf8.UTFMax),
|
||||
}
|
||||
@@ -140,7 +141,7 @@ type outExpecter struct {
|
||||
t *testing.T
|
||||
close func(reason string) error
|
||||
out *stdbuf
|
||||
name string
|
||||
name *atomic.String
|
||||
|
||||
runeReader *bufio.Reader
|
||||
}
|
||||
@@ -361,7 +362,7 @@ func (e *outExpecter) logf(format string, args ...interface{}) {
|
||||
|
||||
// Match regular logger timestamp format, we seem to be logging in
|
||||
// UTC in other places as well, so match here.
|
||||
e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name, fmt.Sprintf(format, args...))
|
||||
e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (e *outExpecter) fatalf(reason string, format string, args ...interface{}) {
|
||||
@@ -430,6 +431,15 @@ func (p *PTY) WriteLine(str string) {
|
||||
require.NoError(p.t, err, "write line failed")
|
||||
}
|
||||
|
||||
// Named sets the PTY name in the logs. Defaults to "cmd". Make sure you set this before anything starts writing to the
|
||||
// pty, or it may not be named consistently. E.g.
|
||||
//
|
||||
// p := New(t).Named("myCmd")
|
||||
func (p *PTY) Named(name string) *PTY {
|
||||
p.name.Store(name)
|
||||
return p
|
||||
}
|
||||
|
||||
type PTYCmd struct {
|
||||
outExpecter
|
||||
pty.PTYCmd
|
||||
|
||||
@@ -62,7 +62,7 @@ test("app", async ({ context, page }) => {
|
||||
const agent = await startAgent(page, token);
|
||||
|
||||
// Wait for the web terminal to open in a new tab
|
||||
const pagePromise = context.waitForEvent("page");
|
||||
const pagePromise = context.waitForEvent("page", { timeout: 10_000 });
|
||||
await page.getByText(appName).click({ timeout: 10_000 });
|
||||
const app = await pagePromise;
|
||||
await app.waitForLoadState("domcontentloaded");
|
||||
|
||||
+1
-2
@@ -32,7 +32,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:watch-jest": "jest --watch",
|
||||
"stats": "STATS=true pnpm build && npx http-server ./stats -p 8081 -c-1",
|
||||
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis"
|
||||
"update-emojis": "cp -rf ./node_modules/emoji-datasource-apple/img/apple/64/* ./static/emojis && cp -f ./node_modules/emoji-datasource-apple/img/apple/sheets-256/64.png ./static/emojis/spritesheet.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
@@ -49,7 +49,6 @@
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@mui/material": "5.18.0",
|
||||
"@mui/system": "5.18.0",
|
||||
"@mui/utils": "5.17.1",
|
||||
"@mui/x-tree-view": "7.29.10",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
|
||||
Generated
-3
@@ -61,9 +61,6 @@ importers:
|
||||
'@mui/system':
|
||||
specifier: 5.18.0
|
||||
version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2)
|
||||
'@mui/utils':
|
||||
specifier: 5.17.1
|
||||
version: 5.17.1(@types/react@19.2.7)(react@19.2.2)
|
||||
'@mui/x-tree-view':
|
||||
specifier: 7.29.10
|
||||
version: 7.29.10(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2))(@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react@19.2.2))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
|
||||
Vendored
+1
@@ -36,6 +36,7 @@ declare module "@emoji-mart/react" {
|
||||
emojiButtonSize?: number;
|
||||
emojiSize?: number;
|
||||
emojiVersion?: string;
|
||||
getSpritesheetURL?: (set: string) => string;
|
||||
onEmojiSelect: (emoji: EmojiData) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { API } from "api/api";
|
||||
import type { Task } from "api/typesGenerated";
|
||||
import type { QueryClient } from "react-query";
|
||||
|
||||
export const pauseTask = (task: Task, queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.stopWorkspace(task.workspace_id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const resumeTask = (task: Task, queryClient: QueryClient) => {
|
||||
return {
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.startWorkspace(
|
||||
task.workspace_id,
|
||||
task.template_version_id,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
};
|
||||
};
|
||||
Generated
+10
@@ -1425,6 +1425,7 @@ export type CreateWorkspaceBuildReason =
|
||||
| "dashboard"
|
||||
| "jetbrains_connection"
|
||||
| "ssh_connection"
|
||||
| "task_manual_pause"
|
||||
| "vscode_connection";
|
||||
|
||||
export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [
|
||||
@@ -1432,6 +1433,7 @@ export const CreateWorkspaceBuildReasons: CreateWorkspaceBuildReason[] = [
|
||||
"dashboard",
|
||||
"jetbrains_connection",
|
||||
"ssh_connection",
|
||||
"task_manual_pause",
|
||||
"vscode_connection",
|
||||
];
|
||||
|
||||
@@ -3583,6 +3585,14 @@ export interface PatchWorkspaceProxy {
|
||||
*/
|
||||
export const PathAppSessionTokenCookie = "coder_path_app_session_token";
|
||||
|
||||
// From codersdk/aitasks.go
|
||||
/**
|
||||
* PauseTaskResponse represents the response from pausing a task.
|
||||
*/
|
||||
export interface PauseTaskResponse {
|
||||
readonly workspace_build: WorkspaceBuild | null;
|
||||
}
|
||||
|
||||
// From codersdk/roles.go
|
||||
/**
|
||||
* Permission is the format passed into the rego.
|
||||
|
||||
@@ -7,13 +7,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const alertVariants = cva(
|
||||
@@ -131,7 +125,9 @@ export const Alert: FC<AlertProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
|
||||
export const AlertDetail: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<span className="m-0 text-sm" data-chromatic="ignore">
|
||||
{children}
|
||||
@@ -139,13 +135,11 @@ export const AlertDetail: FC<PropsWithChildren> = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertTitle = forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h1
|
||||
ref={ref}
|
||||
className={cn("m-0 mb-1 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
export const AlertTitle: React.FC<React.ComponentPropsWithRef<"h1">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<h1 className={cn("m-0 mb-1 text-sm font-medium", className)} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { getExternalImageStylesFromUrl } from "theme/externalImages";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
@@ -58,17 +57,22 @@ export type AvatarProps = AvatarPrimitive.AvatarProps &
|
||||
VariantProps<typeof avatarVariants> & {
|
||||
src?: string;
|
||||
fallback?: string;
|
||||
ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Root>>;
|
||||
};
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
AvatarProps
|
||||
>(({ className, size, variant, src, fallback, children, ...props }, ref) => {
|
||||
export const Avatar: React.FC<AvatarProps> = ({
|
||||
className,
|
||||
size,
|
||||
variant,
|
||||
src,
|
||||
fallback,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(avatarVariants({ size, variant, className }))}
|
||||
{...props}
|
||||
>
|
||||
@@ -85,7 +89,4 @@ const Avatar = React.forwardRef<
|
||||
{children}
|
||||
</AvatarPrimitive.Root>
|
||||
);
|
||||
});
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
export { Avatar };
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const badgeVariants = cva(
|
||||
@@ -58,28 +57,26 @@ const badgeVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
type BadgeProps = React.ComponentPropsWithRef<"div"> &
|
||||
VariantProps<typeof badgeVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
|
||||
(
|
||||
{ className, variant, size, border, hover, asChild = false, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
border,
|
||||
hover,
|
||||
asChild = false,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
badgeVariants({ variant, size, border, hover }),
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
return (
|
||||
<Comp
|
||||
{...props}
|
||||
className={cn(badgeVariants({ variant, size, border, hover }), className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
type FC,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
|
||||
export const EnabledBadge: FC = () => {
|
||||
export const EnabledBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge className="option-enabled" variant="green" border="solid">
|
||||
Enabled
|
||||
@@ -15,25 +9,25 @@ export const EnabledBadge: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const EntitledBadge: FC = () => {
|
||||
export const EntitledBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge border="solid" variant="green">
|
||||
Entitled
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
export const DisabledBadge: FC = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement>
|
||||
>((props, ref) => {
|
||||
|
||||
export const DisabledBadge: React.FC<React.ComponentPropsWithRef<"div">> = ({
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Badge ref={ref} {...props} className="option-disabled">
|
||||
<Badge {...props} className="option-disabled">
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const EnterpriseBadge: FC = () => {
|
||||
export const EnterpriseBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="info" border="solid">
|
||||
Enterprise
|
||||
@@ -45,7 +39,7 @@ interface PremiumBadgeProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PremiumBadge: FC<PremiumBadgeProps> = ({
|
||||
export const PremiumBadge: React.FC<PremiumBadgeProps> = ({
|
||||
children = "Premium",
|
||||
}) => {
|
||||
return (
|
||||
@@ -55,7 +49,7 @@ export const PremiumBadge: FC<PremiumBadgeProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const PreviewBadge: FC = () => {
|
||||
export const PreviewBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="purple" border="solid">
|
||||
Preview
|
||||
@@ -63,7 +57,7 @@ export const PreviewBadge: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const AlphaBadge: FC = () => {
|
||||
export const AlphaBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="purple" border="solid">
|
||||
Alpha
|
||||
@@ -71,7 +65,7 @@ export const AlphaBadge: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const DeprecatedBadge: FC = () => {
|
||||
export const DeprecatedBadge: React.FC = () => {
|
||||
return (
|
||||
<Badge variant="warning" border="solid">
|
||||
Deprecated
|
||||
@@ -79,7 +73,7 @@ export const DeprecatedBadge: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Badges: FC<PropsWithChildren> = ({ children }) => {
|
||||
export const Badges: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<Stack
|
||||
css={{ margin: "0 0 16px" }}
|
||||
|
||||
@@ -4,62 +4,59 @@
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ComponentPropsWithoutRef,
|
||||
type FC,
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Breadcrumb = forwardRef<
|
||||
HTMLElement,
|
||||
ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
type BreadcrumbProps = React.ComponentPropsWithRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BreadcrumbList = forwardRef<
|
||||
HTMLOListElement,
|
||||
ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
export const Breadcrumb: React.FC<BreadcrumbProps> = ({ ...props }) => {
|
||||
return <nav aria-label="breadcrumb" {...props} />;
|
||||
};
|
||||
|
||||
export const BreadcrumbItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-content-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
export const BreadcrumbList: React.FC<React.ComponentPropsWithRef<"ol">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ol
|
||||
className={cn(
|
||||
"flex flex-wrap items-center text-sm pl-6 my-4 gap-1.5 break-words font-medium list-none sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BreadcrumbLink = forwardRef<
|
||||
HTMLAnchorElement,
|
||||
ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
export const BreadcrumbItem: React.FC<React.ComponentPropsWithRef<"li">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-content-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type BreadcrumbLinkProps = React.ComponentPropsWithRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export const BreadcrumbLink: React.FC<BreadcrumbLinkProps> = ({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-content-secondary transition-colors hover:text-content-primary no-underline hover:underline",
|
||||
className,
|
||||
@@ -67,49 +64,54 @@ export const BreadcrumbLink = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const BreadcrumbPage = forwardRef<
|
||||
HTMLSpanElement,
|
||||
ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
aria-current="page"
|
||||
className={cn("flex items-center gap-2 text-content-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
export const BreadcrumbSeparator: FC<ComponentProps<"li">> = ({
|
||||
children,
|
||||
export const BreadcrumbPage: React.FC<React.ComponentPropsWithRef<"span">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
/
|
||||
</li>
|
||||
);
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
aria-current="page"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-content-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BreadcrumbEllipsis: FC<ComponentProps<"span">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
export const BreadcrumbSeparator: React.FC<
|
||||
Omit<React.ComponentPropsWithRef<"li">, "children">
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-content-disabled [&>svg]:w-3.5 [&>svg]:h-3.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
/
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const BreadcrumbEllipsis: React.FC<
|
||||
Omit<React.ComponentPropsWithRef<"span">, "children">
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
// Be careful when changing the child styles from the button such as images
|
||||
@@ -58,31 +57,34 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
export type ButtonProps = React.ComponentPropsWithRef<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
// Adding default button type to make sure that buttons don't
|
||||
// accidentally trigger form actions when clicked. But because
|
||||
// this Button component is so polymorphic (it's also used to
|
||||
// make <a> elements look like buttons), we can only safely
|
||||
// default to adding the prop when we know that we're rendering
|
||||
// a real HTML button instead of an arbitrary Slot. Adding the
|
||||
// type attribute to any non-buttons will produce invalid HTML
|
||||
type={
|
||||
props.type === undefined && Comp === "button" ? "button" : props.type
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
// We want `type` to default to `"button"` when the component is not being
|
||||
// used as a `Slot`. The default behavior of any given `<button>` element is
|
||||
// to submit the closest parent `<form>` because Web Platform reasons. This
|
||||
// prevents that. However, we don't want to set it on non-`<button>`s when
|
||||
// `asChild` is set.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#type
|
||||
if (!asChild && !props.type) {
|
||||
props.type = "button";
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
{...props}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,41 +4,40 @@
|
||||
*/
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
/**
|
||||
* To allow for an indeterminate state the checkbox must be controlled, otherwise the checked prop would remain undefined
|
||||
*/
|
||||
export const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
|
||||
export const Checkbox: React.FC<
|
||||
React.ComponentPropsWithRef<typeof CheckboxPrimitive.Root>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
`peer size-[18px] shrink-0 rounded-sm border border-border border-solid
|
||||
focus-visible:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-content-link focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
|
||||
disabled:cursor-not-allowed disabled:bg-surface-primary disabled:data-[state=checked]:bg-surface-tertiary
|
||||
data-[state=unchecked]:bg-surface-primary
|
||||
data-[state=checked]:bg-surface-invert-primary data-[state=checked]:text-content-invert
|
||||
hover:enabled:border-border-hover hover:data-[state=checked]:bg-surface-invert-secondary`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current relative")}
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex">
|
||||
{(props.checked === true || props.defaultChecked === true) && (
|
||||
<Check className="w-4 h-4" strokeWidth={2.5} />
|
||||
)}
|
||||
{props.checked === "indeterminate" && (
|
||||
<Minus className="w-4 h-4" strokeWidth={2.5} />
|
||||
)}
|
||||
</div>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current relative")}
|
||||
>
|
||||
<div className="flex">
|
||||
{(props.checked === true || props.defaultChecked === true) && (
|
||||
<Check className="w-4 h-4" strokeWidth={2.5} />
|
||||
)}
|
||||
{props.checked === "indeterminate" && (
|
||||
<Minus className="w-4 h-4" strokeWidth={2.5} />
|
||||
)}
|
||||
</div>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { forwardRef, type ImgHTMLAttributes } from "react";
|
||||
import { getExternalImageStylesFromUrl } from "theme/externalImages";
|
||||
|
||||
export const ExternalImage = forwardRef<
|
||||
HTMLImageElement,
|
||||
ImgHTMLAttributes<HTMLImageElement>
|
||||
>((props, ref) => {
|
||||
export const ExternalImage: React.FC<React.ComponentPropsWithRef<"img">> = ({
|
||||
...props
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useAltText: alt should be passed in as a prop
|
||||
<img
|
||||
ref={ref}
|
||||
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FC,
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
@@ -76,53 +75,50 @@ interface FormSectionProps {
|
||||
};
|
||||
alpha?: boolean;
|
||||
deprecated?: boolean;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
classes = {},
|
||||
alpha = false,
|
||||
deprecated = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { direction } = useContext(FormContext);
|
||||
export const FormSection: FC<FormSectionProps> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
classes = {},
|
||||
alpha = false,
|
||||
deprecated = false,
|
||||
ref,
|
||||
}) => {
|
||||
const { direction } = useContext(FormContext);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
css={[
|
||||
styles.formSection,
|
||||
direction === "horizontal" && styles.formSectionHorizontal,
|
||||
]}
|
||||
className={classes.root}
|
||||
>
|
||||
<div
|
||||
css={[
|
||||
styles.formSection,
|
||||
direction === "horizontal" && styles.formSectionHorizontal,
|
||||
styles.formSectionInfo,
|
||||
direction === "horizontal" && styles.formSectionInfoHorizontal,
|
||||
]}
|
||||
className={classes.root}
|
||||
className={classes.sectionInfo}
|
||||
>
|
||||
<div
|
||||
css={[
|
||||
styles.formSectionInfo,
|
||||
direction === "horizontal" && styles.formSectionInfoHorizontal,
|
||||
]}
|
||||
className={classes.sectionInfo}
|
||||
>
|
||||
<header className="flex items-center gap-4">
|
||||
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
{alpha && <AlphaBadge />}
|
||||
{deprecated && <DeprecatedBadge />}
|
||||
</header>
|
||||
<div css={styles.formSectionInfoDescription}>{description}</div>
|
||||
</div>
|
||||
<header className="flex items-center gap-4">
|
||||
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
{alpha && <AlphaBadge />}
|
||||
{deprecated && <DeprecatedBadge />}
|
||||
</header>
|
||||
<div css={styles.formSectionInfoDescription}>{description}</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
|
||||
return (
|
||||
|
||||
@@ -25,7 +25,7 @@ const custom = [
|
||||
|
||||
type EmojiPickerProps = Omit<
|
||||
ComponentProps<typeof EmojiMart>,
|
||||
"custom" | "data" | "set" | "theme"
|
||||
"custom" | "data" | "set" | "theme" | "getSpritesheetURL"
|
||||
>;
|
||||
|
||||
const EmojiPicker: FC<EmojiPickerProps> = (props) => {
|
||||
@@ -53,6 +53,7 @@ const EmojiPicker: FC<EmojiPickerProps> = (props) => {
|
||||
emojiVersion="15"
|
||||
data={data}
|
||||
custom={custom}
|
||||
getSpritesheetURL={() => "/emojis/spritesheet.png"}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css, Global, useTheme } from "@emotion/react";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import TextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
@@ -116,7 +115,7 @@ export const IconField: FC<IconFieldProps> = ({
|
||||
- Except we don't do it when running tests, because Jest doesn't define
|
||||
`IntersectionObserver`, and it would make them slower anyway. */}
|
||||
{process.env.NODE_ENV !== "test" && (
|
||||
<div css={{ ...visuallyHidden }}>
|
||||
<div className="sr-only" aria-hidden="true">
|
||||
<Suspense>
|
||||
<EmojiPicker onEmojiSelect={() => {}} />
|
||||
</Suspense>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/input}
|
||||
*/
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Input = forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, type, ...props }, ref) => {
|
||||
export const Input: React.FC<React.ComponentPropsWithRef<"input">> = ({
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
@@ -23,8 +23,7 @@ export const Input = forwardRef<
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Button, type ButtonProps } from "components/Button/Button";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { type FC, forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const InputGroup: FC<React.ComponentProps<"div">> = ({
|
||||
export const InputGroup: React.FC<React.ComponentProps<"div">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
@@ -42,7 +41,7 @@ const inputGroupAddonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const InputGroupAddon: FC<
|
||||
export const InputGroupAddon: React.FC<
|
||||
React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>
|
||||
> = ({ className, align = "inline-start", ...props }) => {
|
||||
return (
|
||||
@@ -63,13 +62,11 @@ const InputGroupAddon: FC<
|
||||
);
|
||||
};
|
||||
|
||||
const InputGroupInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
export const InputGroupInput: React.FC<
|
||||
React.ComponentPropsWithRef<typeof Input>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Reset Input's default styles that conflict with group
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0",
|
||||
@@ -81,9 +78,9 @@ const InputGroupInput = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const InputGroupButton: FC<ButtonProps> = ({
|
||||
export const InputGroupButton: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "subtle",
|
||||
@@ -102,5 +99,3 @@ const InputGroupButton: FC<ButtonProps> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton };
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
*/
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
export const Label = forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
type LabelProps = React.ComponentPropsWithRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>;
|
||||
|
||||
export const Label: React.FC<LabelProps> = ({ className, ...props }) => {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const linkVariants = cva(
|
||||
@@ -23,28 +22,26 @@ const linkVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
interface LinkProps
|
||||
extends React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
VariantProps<typeof linkVariants> {
|
||||
asChild?: boolean;
|
||||
showExternalIcon?: boolean;
|
||||
}
|
||||
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
VariantProps<typeof linkVariants> & {
|
||||
asChild?: boolean;
|
||||
showExternalIcon?: boolean;
|
||||
ref?: React.Ref<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
(
|
||||
{ className, children, size, asChild, showExternalIcon = true, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(linkVariants({ size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<Slottable>{children}</Slottable>
|
||||
{showExternalIcon && <SquareArrowOutUpRightIcon aria-hidden="true" />}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Link: React.FC<LinkProps> = ({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
asChild,
|
||||
showExternalIcon = true,
|
||||
...props
|
||||
}) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
return (
|
||||
<Comp className={cn(linkVariants({ size }), className)} {...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
{showExternalIcon && <SquareArrowOutUpRightIcon />}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,10 @@ import type { Interpolation, Theme } from "@emotion/react";
|
||||
import CircularProgress, {
|
||||
type CircularProgressProps,
|
||||
} from "@mui/material/CircularProgress";
|
||||
import {
|
||||
type FC,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type FC, type ReactNode, useMemo } from "react";
|
||||
import type { ThemeRole } from "theme/roles";
|
||||
|
||||
type PillProps = HTMLAttributes<HTMLDivElement> & {
|
||||
type PillProps = React.ComponentPropsWithRef<"div"> & {
|
||||
icon?: ReactNode;
|
||||
type?: ThemeRole;
|
||||
size?: "md" | "lg";
|
||||
@@ -29,35 +23,31 @@ const PILL_HEIGHT = 24;
|
||||
const PILL_ICON_SIZE = 14;
|
||||
const PILL_ICON_SPACING = (PILL_HEIGHT - PILL_ICON_SIZE) / 2;
|
||||
|
||||
export const Pill: FC<PillProps> = forwardRef<HTMLDivElement, PillProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
icon,
|
||||
type = "inactive",
|
||||
children,
|
||||
size = "md",
|
||||
...divProps
|
||||
} = props;
|
||||
const typeStyles = useMemo(() => themeStyles(type), [type]);
|
||||
export const Pill: FC<PillProps> = ({
|
||||
icon,
|
||||
type = "inactive",
|
||||
children,
|
||||
size = "md",
|
||||
...divProps
|
||||
}) => {
|
||||
const typeStyles = useMemo(() => themeStyles(type), [type]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
css={[
|
||||
styles.pill,
|
||||
Boolean(icon) && size === "md" && styles.pillWithIcon,
|
||||
size === "lg" && styles.pillLg,
|
||||
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
|
||||
typeStyles,
|
||||
]}
|
||||
{...divProps}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div
|
||||
css={[
|
||||
styles.pill,
|
||||
Boolean(icon) && size === "md" && styles.pillWithIcon,
|
||||
size === "lg" && styles.pillLg,
|
||||
Boolean(icon) && size === "lg" && styles.pillLgWithIcon,
|
||||
typeStyles,
|
||||
]}
|
||||
{...divProps}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PillSpinner: FC<CircularProgressProps> = (props) => {
|
||||
return (
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
* @see {@link https://ui.shadcn.com/docs/components/popover}
|
||||
*/
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export type PopoverContentProps = PopoverPrimitive.PopoverContentProps;
|
||||
@@ -18,28 +13,28 @@ export const Popover = PopoverPrimitive.Root;
|
||||
|
||||
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
export const PopoverContent = forwardRef<
|
||||
ElementRef<typeof PopoverPrimitive.Content>,
|
||||
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={16}
|
||||
className={cn(
|
||||
`z-50 w-72 rounded-md border border-solid bg-surface-primary
|
||||
text-content-primary shadow-md outline-none
|
||||
max-h-[var(--radix-popper-available-height)] overflow-y-auto
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
|
||||
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
export const PopoverContent: React.FC<
|
||||
React.ComponentPropsWithRef<typeof PopoverPrimitive.Content>
|
||||
> = ({ className, align = "center", sideOffset = 4, ...props }) => {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={16}
|
||||
className={cn(
|
||||
`z-50 w-72 rounded-md border border-solid bg-surface-primary
|
||||
text-content-primary shadow-md outline-none
|
||||
max-h-[var(--radix-popper-available-height)] overflow-y-auto
|
||||
data-[state=open]:animate-in data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
||||
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
|
||||
data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2
|
||||
data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,36 +4,30 @@
|
||||
*/
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
export const RadioGroup: React.FC<
|
||||
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Root>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
};
|
||||
|
||||
export const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
export const RadioGroupItem: React.FC<
|
||||
React.ComponentPropsWithRef<typeof RadioGroupPrimitive.Item>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
`relative aspect-square h-4 w-4 rounded-full border border-solid border-border text-content-primary bg-surface-primary
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
||||
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
|
||||
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
|
||||
hover:border-border-hover data-[state=checked]:border-border-hover`,
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
||||
focus-visible:ring-offset-4 focus-visible:ring-offset-surface-primary
|
||||
disabled:cursor-not-allowed disabled:opacity-25 disabled:border-surface-invert-primary
|
||||
hover:border-border-hover data-[state=checked]:border-border-hover`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,4 +37,4 @@ export const RadioGroupItem = React.forwardRef<
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,44 +3,42 @@
|
||||
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
|
||||
*/
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar className="z-10" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
export const ScrollArea: React.FC<
|
||||
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root>
|
||||
> = ({ className, children, ...props }) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar className="z-10" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"border-0 border-solid border-border flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
export const ScrollBar: React.FC<
|
||||
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
> = ({ className, orientation = "vertical", ...props }) => {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"border-0 border-solid border-border flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-surface-quaternary" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Select = SelectPrimitive.Root;
|
||||
@@ -13,17 +12,16 @@ export const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
export const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export type SelectTriggerProps = React.ComponentPropsWithoutRef<
|
||||
export type SelectTriggerProps = React.ComponentPropsWithRef<
|
||||
typeof SelectPrimitive.Trigger
|
||||
>;
|
||||
|
||||
export const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
SelectTriggerProps
|
||||
>(({ className, children, id, ...props }, ref) => (
|
||||
export const SelectTrigger: React.FC<SelectTriggerProps> = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(
|
||||
`flex h-10 w-full font-medium items-center justify-between whitespace-nowrap rounded-md
|
||||
border border-border border-solid bg-transparent px-3 py-2 text-sm shadow-sm
|
||||
@@ -39,15 +37,12 @@ export const SelectTrigger = React.forwardRef<
|
||||
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
);
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
const SelectScrollUpButton: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
> = ({ className, ...props }) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
@@ -56,34 +51,29 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
>
|
||||
<ChevronUp className="size-icon-sm" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
);
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
const SelectScrollDownButton: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-icon-sm cursor-pointer text-content-secondary hover:text-content-primary" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
export const SelectContent: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SelectPrimitive.Content>
|
||||
> = ({ className, children, position = "popper", ...props }) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border ",
|
||||
"border-border border-solid bg-surface-primary text-content-primary shadow-md ",
|
||||
@@ -112,27 +102,23 @@ export const SelectContent = React.forwardRef<
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
);
|
||||
|
||||
export const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
export const SelectLabel: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SelectPrimitive.Label>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
export const SelectItem: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SelectPrimitive.Item>
|
||||
> = ({ className, children, ...props }) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ",
|
||||
"pl-2 pr-8 text-sm text-content-secondary outline-none focus:bg-surface-secondary ",
|
||||
@@ -148,17 +134,4 @@ export const SelectItem = React.forwardRef<
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import {
|
||||
Children,
|
||||
type FC,
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
@@ -46,15 +45,16 @@ type SelectMenuButtonProps = ButtonProps & {
|
||||
startIcon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SelectMenuButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SelectMenuButtonProps
|
||||
>(({ className, startIcon, children, ...props }, ref) => {
|
||||
export const SelectMenuButton: React.FC<SelectMenuButtonProps> = ({
|
||||
className,
|
||||
startIcon,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
ref={ref}
|
||||
// Shrink padding right slightly to account for visual weight of
|
||||
// the chevron
|
||||
className={cn("flex flex-row gap-2 pr-1.5", className)}
|
||||
@@ -67,7 +67,7 @@ export const SelectMenuButton = forwardRef<
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const SelectMenuSearch: FC<SearchFieldProps> = ({
|
||||
className,
|
||||
|
||||
@@ -3,36 +3,35 @@
|
||||
* @see {@link https://ui.shadcn.com/docs/components/slider}
|
||||
*/
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full items-center h-1.5",
|
||||
className,
|
||||
"touch-none select-none",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
|
||||
export const Slider: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SliderPrimitive.Root>
|
||||
> = ({ className, ...props }) => {
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn(
|
||||
"relative flex w-full items-center h-1.5",
|
||||
className,
|
||||
"touch-none select-none",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
|
||||
focus-visible:outline-none hover:border-content-primary
|
||||
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
|
||||
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
|
||||
/>
|
||||
<SliderPrimitive.Thumb
|
||||
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
|
||||
/>
|
||||
<SliderPrimitive.Thumb
|
||||
className="block h-4 w-4 rounded-full border border-solid border-surface-invert-secondary bg-surface-primary shadow transition-colors
|
||||
focus-visible:outline-none hover:border-content-primary
|
||||
focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary
|
||||
disabled:pointer-events-none data-[disabled]:opacity-100 data-[disabled]:border-border"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import type { CSSObject } from "@emotion/react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
/**
|
||||
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
|
||||
*/
|
||||
type StackProps = {
|
||||
type StackProps = React.ComponentPropsWithRef<"div"> & {
|
||||
className?: string;
|
||||
direction?: "column" | "row";
|
||||
spacing?: number;
|
||||
alignItems?: CSSObject["alignItems"];
|
||||
justifyContent?: CSSObject["justifyContent"];
|
||||
wrap?: CSSObject["flexWrap"];
|
||||
} & React.HTMLProps<HTMLDivElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Stack component is deprecated. Use Tailwind flex utilities instead.
|
||||
*/
|
||||
export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
|
||||
export const Stack: React.FC<StackProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
direction = "column",
|
||||
@@ -30,7 +26,6 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
ref={ref}
|
||||
css={{
|
||||
display: "flex",
|
||||
flexDirection: direction,
|
||||
@@ -44,4 +39,4 @@ export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { createContext, type FC, forwardRef, useContext } from "react";
|
||||
import { createContext, type FC, useContext } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
const statusIndicatorVariants = cva(
|
||||
@@ -34,23 +34,24 @@ type StatusIndicatorContextValue = VariantProps<typeof statusIndicatorVariants>;
|
||||
|
||||
const StatusIndicatorContext = createContext<StatusIndicatorContextValue>({});
|
||||
|
||||
export interface StatusIndicatorProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
StatusIndicatorContextValue {}
|
||||
export type StatusIndicatorProps = React.ComponentPropsWithRef<"div"> &
|
||||
StatusIndicatorContextValue;
|
||||
|
||||
export const StatusIndicator = forwardRef<HTMLDivElement, StatusIndicatorProps>(
|
||||
({ size, variant, className, ...props }, ref) => {
|
||||
return (
|
||||
<StatusIndicatorContext.Provider value={{ size, variant }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(statusIndicatorVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
</StatusIndicatorContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
size,
|
||||
variant,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StatusIndicatorContext.Provider value={{ size, variant }}>
|
||||
<div
|
||||
className={cn(statusIndicatorVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
</StatusIndicatorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const dotVariants = cva("rounded-full inline-block border-4 border-solid", {
|
||||
variants: {
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
* @see {@link https://ui.shadcn.com/docs/components/switch}
|
||||
*/
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Switch = forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
export const Switch: React.FC<
|
||||
React.ComponentPropsWithRef<typeof SwitchPrimitives.Root>
|
||||
> = ({ className, ...props }) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full shadow-sm transition-colors
|
||||
@@ -23,7 +21,6 @@ export const Switch = forwardRef<
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
@@ -32,4 +29,4 @@ export const Switch = forwardRef<
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
);
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Copied from shadc/ui on 04/18/2025
|
||||
* Copied from shadc/ui on 11/13/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/textarea}
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
export const Textarea: React.FC<React.ComponentPropsWithRef<"textarea">> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
@@ -18,8 +17,7 @@ export const Textarea = React.forwardRef<
|
||||
disabled:cursor-not-allowed disabled:opacity-50 disabled:text-content-disabled md:text-sm`,
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { TableRow, type TableRowProps } from "components/Table/Table";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface TimelineEntryProps extends TableRowProps {
|
||||
ref?: React.Ref<HTMLTableRowElement>;
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
export const TimelineEntry = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
TimelineEntryProps
|
||||
>(({ children, clickable = true, className, ...props }, ref) => {
|
||||
export const TimelineEntry: React.FC<TimelineEntryProps> = ({
|
||||
children,
|
||||
clickable = true,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:outline focus:-outline-offset-1 focus:outline-2 focus:outline-content-primary ",
|
||||
"[&_td]:relative [&_td]:overflow-hidden",
|
||||
@@ -25,4 +26,4 @@ export const TimelineEntry = forwardRef<
|
||||
{children}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
/**
|
||||
* Copied from shadc/ui on 02/05/2025
|
||||
* @see {@link https://ui.shadcn.com/docs/components/tooltip}
|
||||
*/
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
@@ -16,19 +15,20 @@ export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const TooltipArrow = TooltipPrimitive.Arrow;
|
||||
|
||||
export type TooltipContentProps = React.ComponentPropsWithoutRef<
|
||||
export type TooltipContentProps = React.ComponentPropsWithRef<
|
||||
typeof TooltipPrimitive.Content
|
||||
> & {
|
||||
disablePortal?: boolean;
|
||||
};
|
||||
|
||||
export const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
TooltipContentProps
|
||||
>(({ className, sideOffset = 4, disablePortal, ...props }, ref) => {
|
||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
disablePortal,
|
||||
...props
|
||||
}) => {
|
||||
const content = (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-surface-primary px-3 py-2 text-xs font-medium text-content-secondary",
|
||||
@@ -47,4 +47,4 @@ export const TooltipContent = React.forwardRef<
|
||||
) : (
|
||||
<TooltipPrimitive.Portal>{content}</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Abbr } from "components/Abbr/Abbr";
|
||||
import { Button } from "components/Button/Button";
|
||||
@@ -74,7 +73,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="lg">
|
||||
<span css={{ ...visuallyHidden }}>
|
||||
<span className="sr-only">
|
||||
Latency for {selectedProxy?.display_name ?? "your region"}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import { Button, type ButtonProps } from "components/Button/Button";
|
||||
import { BellIcon } from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { UnreadBadge } from "./UnreadBadge";
|
||||
|
||||
type InboxButtonProps = {
|
||||
type InboxButtonProps = ButtonProps & {
|
||||
unreadCount: number;
|
||||
} & ButtonProps;
|
||||
};
|
||||
|
||||
export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
|
||||
({ unreadCount, ...props }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
className="relative"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
<UnreadBadge
|
||||
count={unreadCount}
|
||||
className={cn([
|
||||
"[--offset:calc(var(--unread-badge-size)/2)]",
|
||||
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
|
||||
"animate-in fade-in zoom-in duration-200",
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const InboxButton: React.FC<InboxButtonProps> = ({
|
||||
unreadCount,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button size="icon-lg" variant="outline" className="relative" {...props}>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
<UnreadBadge
|
||||
count={unreadCount}
|
||||
className={cn([
|
||||
"[--offset:calc(var(--unread-badge-size)/2)]",
|
||||
"absolute top-0 right-0 -mr-[--offset] -mt-[--offset]",
|
||||
"animate-in fade-in zoom-in duration-200",
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button, type ButtonProps } from "components/Button/Button";
|
||||
import { forwardRef } from "react";
|
||||
export const AgentButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(props, ref) => {
|
||||
return <Button variant="outline" ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const AgentButton: React.FC<ButtonProps> = ({ ...props }) => {
|
||||
return <Button variant="outline" {...props} />;
|
||||
};
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { TaskActionButton } from "./TaskActionButton";
|
||||
|
||||
const meta: Meta<typeof TaskActionButton> = {
|
||||
title: "pages/TasksPage/TaskActionButton",
|
||||
title: "modules/tasks/TaskActionButton",
|
||||
component: TaskActionButton,
|
||||
args: {
|
||||
onClick: fn(),
|
||||
@@ -18,6 +18,7 @@ type PromptSelectTriggerProps = SelectTriggerProps & {
|
||||
export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
|
||||
className,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
@@ -27,12 +28,19 @@ export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
|
||||
<SelectTrigger
|
||||
{...props}
|
||||
className={cn([
|
||||
className,
|
||||
`w-auto border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-3
|
||||
`w-full md:w-auto max-w-full overflow-hidden border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-4 md:px-3
|
||||
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
|
||||
h-8 data-[state=open]:bg-surface-tertiary`,
|
||||
h-10 md:h-8 data-[state=open]:bg-surface-tertiary`,
|
||||
className,
|
||||
])}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
data-slot="value"
|
||||
className="overflow-hidden min-w-0 flex items-center gap-2"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -473,6 +473,28 @@ export const CheckExternalAuthOnChangingVersions: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Regression test introduced in https://github.com/coder/coder/pull/22032
|
||||
// A change was introduced that cause the focused selector to be mostly
|
||||
// hidden due to an introduced `overflow-hidden`. The change wasn't spotted
|
||||
// in the PR it was introduced as no stories triggered that behavior, so we
|
||||
// have added one to ensure the behavior doesn't regress.
|
||||
export const PresetSelectorFocused: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplateVersionPresets").mockResolvedValue(
|
||||
MockPresets.map((preset, i) => ({
|
||||
...preset,
|
||||
Icon: i === 0 ? "/icon/code.svg" : i === 1 ? "/icon/database.svg" : "",
|
||||
Description: i === 0 ? "For everyday development work" : "",
|
||||
})),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const presetSelect = await canvas.findByLabelText(/preset/i);
|
||||
presetSelect.focus();
|
||||
},
|
||||
};
|
||||
|
||||
export const CheckPresetsWhenChangingTemplate: Story = {
|
||||
args: {
|
||||
templates: [
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TemplateVersionExternalAuth,
|
||||
} from "api/typesGenerated";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
@@ -235,7 +236,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
{externalAuthError && <ErrorAlert error={externalAuthError} />}
|
||||
|
||||
<fieldset
|
||||
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary"
|
||||
className="border border-border border-solid rounded-3xl p-3 bg-surface-secondary min-w-0"
|
||||
disabled={createTaskMutation.isPending}
|
||||
>
|
||||
<label htmlFor="prompt" className="sr-only">
|
||||
@@ -248,9 +249,9 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
isSubmitting={createTaskMutation.isPending}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div>
|
||||
<div className="flex items-center justify-between pt-2 gap-2">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<div className="min-w-0 max-w-[33.3%]">
|
||||
<label htmlFor="templateID" className="sr-only">
|
||||
Select template
|
||||
</label>
|
||||
@@ -292,7 +293,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
{permissions.updateTemplates && (
|
||||
<div>
|
||||
<div className="min-w-0 max-w-[33.3%]">
|
||||
<label htmlFor="versionId" className="sr-only">
|
||||
Template version
|
||||
</label>
|
||||
@@ -305,7 +306,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="presetID" className="sr-only">
|
||||
Preset
|
||||
</label>
|
||||
@@ -324,40 +325,47 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
||||
<PromptSelectTrigger
|
||||
id="presetID"
|
||||
tooltip="Preset"
|
||||
className="w-full max-w-full [&_span]:flex [&_span]:items-center [&_span]:gap-2 [&_span]:min-w-0 [&_span]:overflow-hidden [&_span>span]:truncate [&_svg[data-slot='preset-description']]:hidden"
|
||||
className="max-w-full [&_[data-slot=preset-name]]:truncate [&_[data-slot=preset-name]]:min-w-0 [&_[data-slot=preset-description]]:hidden"
|
||||
>
|
||||
<SelectValue placeholder="Select a preset" />
|
||||
</PromptSelectTrigger>
|
||||
<SelectContent>
|
||||
{presets?.toSorted(sortByDefault).map((preset) => (
|
||||
<SelectItem
|
||||
value={preset.ID}
|
||||
key={preset.ID}
|
||||
className="[&_span]:flex [&_span]:items-center [&_span]:gap-2"
|
||||
>
|
||||
{preset.Icon && (
|
||||
<img
|
||||
src={preset.Icon}
|
||||
alt={preset.Name}
|
||||
className="size-icon-sm flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
{preset.Name} {preset.Default && "(Default)"}
|
||||
</span>
|
||||
{preset.Description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon
|
||||
className="size-4"
|
||||
data-slot="preset-description"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{preset.Description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<SelectItem value={preset.ID} key={preset.ID}>
|
||||
<div className="flex items-center gap-2">
|
||||
{preset.Icon && (
|
||||
<img
|
||||
data-slot="preset-icon"
|
||||
src={preset.Icon}
|
||||
alt={preset.Name}
|
||||
className="size-icon-sm shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
data-slot="preset-name"
|
||||
className="truncate min-w-0"
|
||||
>
|
||||
{preset.Name}
|
||||
</span>
|
||||
{preset.Default && (
|
||||
<Badge size="xs" className="shrink-0">
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{preset.Description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon
|
||||
className="size-4"
|
||||
data-slot="preset-description"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{preset.Description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -48,10 +48,10 @@ export const TemplateVersionSelect: FC<TemplateVersionSelectProps> = ({
|
||||
{versions.map((version) => {
|
||||
return (
|
||||
<SelectItem value={version.id} key={version.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
{version.name}
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{version.name}</span>
|
||||
{activeVersionId === version.id && (
|
||||
<Badge size="xs" variant="green">
|
||||
<Badge size="xs" variant="green" className="shrink-0">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MockDisplayNameTasks,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockUserOwner,
|
||||
mockApiError,
|
||||
@@ -131,3 +132,69 @@ export const OpenDeleteDialog: Story = {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const PauseMenuOpen: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
await userEvent.click(optionButtons[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const ResumeMenuOpen: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTasks").mockResolvedValue([
|
||||
{ ...MockTask, status: "paused" },
|
||||
...MockTasks.slice(1),
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
await userEvent.click(optionButtons[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedStatuses: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTasks").mockResolvedValue([
|
||||
MockTask,
|
||||
{
|
||||
...MockTask,
|
||||
id: "paused-task",
|
||||
name: "paused-task",
|
||||
display_name: "Paused task",
|
||||
status: "paused",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "error-task",
|
||||
name: "error-task",
|
||||
display_name: "Error task",
|
||||
status: "error",
|
||||
},
|
||||
{
|
||||
...MockTask,
|
||||
id: "init-task",
|
||||
name: "init-task",
|
||||
display_name: "Initializing task",
|
||||
status: "initializing",
|
||||
},
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const optionButtons = await canvas.findAllByRole("button", {
|
||||
name: /task options/i,
|
||||
});
|
||||
// Open menu on the error task (third item) to show both Pause and Resume.
|
||||
await userEvent.click(optionButtons[2]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { pauseTask, resumeTask } from "api/queries/tasks";
|
||||
import type { Task, TasksFilter } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
@@ -7,11 +8,14 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { StatusIndicatorDot } from "components/StatusIndicator/StatusIndicator";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -21,13 +25,21 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
EditIcon,
|
||||
EllipsisIcon,
|
||||
PanelLeftIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import { cn } from "utils/cn";
|
||||
import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog";
|
||||
import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus";
|
||||
import { canPauseTask, canResumeTask, isPauseDisabled } from "../taskActions";
|
||||
import { UserCombobox } from "./UserCombobox";
|
||||
|
||||
export const TasksSidebar: FC = () => {
|
||||
@@ -180,6 +192,23 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
|
||||
const isActive = task.id === taskId;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const pauseMutation = useMutation({
|
||||
...pauseTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to pause task."));
|
||||
},
|
||||
});
|
||||
const resumeMutation = useMutation({
|
||||
...resumeTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to resume task."));
|
||||
},
|
||||
});
|
||||
|
||||
const showPause = canPauseTask(task.status) && task.workspace_id;
|
||||
const pauseDisabled = isPauseDisabled(task.status);
|
||||
const showResume = canResumeTask(task.status) && task.workspace_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -227,6 +256,35 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
{showPause && (
|
||||
<DropdownMenuItem
|
||||
disabled={pauseDisabled || pauseMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
pauseMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Spinner loading={pauseMutation.isPending}>
|
||||
<PauseIcon />
|
||||
</Spinner>
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showResume && (
|
||||
<DropdownMenuItem
|
||||
disabled={resumeMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
resumeMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<Spinner loading={resumeMutation.isPending}>
|
||||
<PlayIcon />
|
||||
</Spinner>
|
||||
Resume
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(showPause || showResume) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { TaskStatus } from "api/typesGenerated";
|
||||
|
||||
/**
|
||||
* Task statuses that allow pausing.
|
||||
*/
|
||||
const PAUSABLE_STATUSES: TaskStatus[] = [
|
||||
"active",
|
||||
"initializing",
|
||||
"pending",
|
||||
"error",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
/**
|
||||
* Task statuses where the pause button should be disabled (in transition).
|
||||
*/
|
||||
const PAUSE_DISABLED_STATUSES: TaskStatus[] = ["pending", "initializing"];
|
||||
|
||||
/**
|
||||
* Task statuses that allow resuming.
|
||||
*/
|
||||
const RESUMABLE_STATUSES: TaskStatus[] = ["paused", "error", "unknown"];
|
||||
|
||||
/**
|
||||
* Checks if a task can be paused based on its status.
|
||||
*/
|
||||
export function canPauseTask(status: TaskStatus): boolean {
|
||||
return PAUSABLE_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the pause action should be disabled for a task status.
|
||||
*/
|
||||
export function isPauseDisabled(status: TaskStatus): boolean {
|
||||
return PAUSE_DISABLED_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a task can be resumed based on its status.
|
||||
*/
|
||||
export function canResumeTask(status: TaskStatus): boolean {
|
||||
return RESUMABLE_STATUSES.includes(status);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import type { AuditLog } from "api/typesGenerated";
|
||||
import { Link } from "components/Link/Link";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { BuildAuditDescription } from "./BuildAuditDescription";
|
||||
@@ -52,8 +52,10 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
|
||||
<span>
|
||||
{truncatedDescription}
|
||||
{auditLog.resource_link ? (
|
||||
<Link component={RouterLink} to={auditLog.resource_link}>
|
||||
<strong>{target}</strong>
|
||||
<Link asChild showExternalIcon={false} className="text-base px-0">
|
||||
<RouterLink to={auditLog.resource_link}>
|
||||
<strong>{target}</strong>
|
||||
</RouterLink>
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{target}</strong>
|
||||
@@ -70,8 +72,10 @@ function AppSessionAuditLogDescription({ auditLog }: AuditLogDescriptionProps) {
|
||||
return (
|
||||
<>
|
||||
{connection_type} session to {workspace_owner}'s{" "}
|
||||
<Link component={RouterLink} to={`${auditLog.resource_link}`}>
|
||||
<strong>{workspace_name}</strong>
|
||||
<Link asChild showExternalIcon={false} className="text-base px-0">
|
||||
<RouterLink to={`${auditLog.resource_link}`}>
|
||||
<strong>{workspace_name}</strong>
|
||||
</RouterLink>
|
||||
</Link>{" "}
|
||||
workspace{" "}
|
||||
<strong>{auditLog.action === "disconnect" ? "closed" : "opened"}</strong>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import type { AuditLog } from "api/typesGenerated";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { systemBuildReasons } from "utils/workspace";
|
||||
@@ -38,8 +38,10 @@ export const BuildAuditDescription: FC<BuildAuditDescriptionProps> = ({
|
||||
<span>
|
||||
{user} <strong>{action}</strong> workspace{" "}
|
||||
{auditLog.resource_link ? (
|
||||
<Link component={RouterLink} to={auditLog.resource_link}>
|
||||
<strong>{workspaceName}</strong>
|
||||
<Link asChild showExternalIcon={false} className="text-base px-0">
|
||||
<RouterLink to={auditLog.resource_link}>
|
||||
<strong>{workspaceName}</strong>
|
||||
</RouterLink>
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{workspaceName}</strong>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Link from "@mui/material/Link";
|
||||
import type { AuditLog, BuildReason } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { StatusPill } from "components/StatusPill/StatusPill";
|
||||
import { TableCell } from "components/Table/Table";
|
||||
import { TimelineEntry } from "components/Timeline/TimelineEntry";
|
||||
@@ -71,246 +72,186 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
|
||||
data-testid={`audit-log-row-${auditLog.id}`}
|
||||
clickable={shouldDisplayDiff}
|
||||
>
|
||||
<TableCell css={styles.auditLogCell}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
css={styles.auditLogHeader}
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
css={styles.auditLogHeaderInfo}
|
||||
<TableCell className="!p-0 border-0 border-b text-base">
|
||||
<Collapsible open={isDiffOpen} onOpenChange={setIsDiffOpen}>
|
||||
<div
|
||||
className="flex flex-row items-center gap-4 py-4 px-8"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" css={styles.fullWidth}>
|
||||
{/*
|
||||
* Session logs don't have an associated user to the log,
|
||||
* so when it happens we display a default icon to represent non user actions
|
||||
*/}
|
||||
{auditLog.user ? (
|
||||
<Avatar
|
||||
fallback={auditLog.user.username}
|
||||
src={auditLog.user.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
<Avatar>
|
||||
<NetworkIcon className="h-full w-full p-1" />
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-4 flex-1">
|
||||
<div className="flex flex-row items-center gap-4 w-full">
|
||||
{/*
|
||||
* Session logs don't have an associated user to the log,
|
||||
* so when it happens we display a default icon to represent non user actions
|
||||
*/}
|
||||
{auditLog.user ? (
|
||||
<Avatar
|
||||
fallback={auditLog.user.username}
|
||||
src={auditLog.user.avatar_url}
|
||||
/>
|
||||
) : (
|
||||
<Avatar>
|
||||
<NetworkIcon className="h-full w-full p-1" />
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
css={styles.fullWidth}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
>
|
||||
<Stack
|
||||
css={styles.auditLogSummary}
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
spacing={1}
|
||||
>
|
||||
<AuditLogDescription auditLog={auditLog} />
|
||||
{auditLog.is_deleted && (
|
||||
<span css={styles.deletedLabel}>(deleted)</span>
|
||||
)}
|
||||
<span css={styles.auditLogTime}>
|
||||
{new Date(auditLog.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</Stack>
|
||||
<div className="flex flex-row items-baseline justify-between w-full font-normal gap-4">
|
||||
<div className="flex flex-row items-baseline gap-2">
|
||||
<AuditLogDescription auditLog={auditLog} />
|
||||
{auditLog.is_deleted && (
|
||||
<span className="text-xs text-content-secondary">
|
||||
(deleted)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-secondary text-xs">
|
||||
{new Date(auditLog.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Stack direction="row" alignItems="center">
|
||||
<StatusPill isHttpCode={true} code={auditLog.status_code} />
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<StatusPill isHttpCode={true} code={auditLog.status_code} />
|
||||
|
||||
{/* With multi-org, there is not enough space so show
|
||||
{/* With multi-org, there is not enough space so show
|
||||
everything in a tooltip. */}
|
||||
{showOrgDetails ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon
|
||||
css={(theme) => ({
|
||||
color: theme.palette.info.light,
|
||||
})}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div css={styles.auditLogInfoTooltip}>
|
||||
{auditLog.ip && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>IP:</h4>
|
||||
<div>{auditLog.ip}</div>
|
||||
</div>
|
||||
)}
|
||||
{userAgent?.os.name && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>OS:</h4>
|
||||
<div>{userAgent.os.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{userAgent?.browser.name && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>Browser:</h4>
|
||||
{showOrgDetails ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="text-content-link" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="flex flex-col gap-2">
|
||||
{auditLog.ip && (
|
||||
<div>
|
||||
{userAgent.browser.name}{" "}
|
||||
{userAgent.browser.version}
|
||||
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
|
||||
IP:
|
||||
</h4>
|
||||
<div>{auditLog.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auditLog.organization && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>
|
||||
Organization:
|
||||
</h4>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/organizations/${auditLog.organization.name}`}
|
||||
>
|
||||
{auditLog.organization.display_name ||
|
||||
auditLog.organization.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{auditLog.additional_fields?.build_reason &&
|
||||
auditLog.action === "start" && (
|
||||
)}
|
||||
{userAgent?.os.name && (
|
||||
<div>
|
||||
<h4 css={styles.auditLogInfoHeader}>Reason:</h4>
|
||||
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
|
||||
OS:
|
||||
</h4>
|
||||
<div>{userAgent.os.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{userAgent?.browser.name && (
|
||||
<div>
|
||||
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
|
||||
Browser:
|
||||
</h4>
|
||||
<div>
|
||||
{
|
||||
buildReasonLabels[
|
||||
auditLog.additional_fields
|
||||
.build_reason as BuildReason
|
||||
]
|
||||
}
|
||||
{userAgent.browser.name}{" "}
|
||||
{userAgent.browser.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
{auditLog.ip && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>IP: </span>
|
||||
<strong>{auditLog.ip}</strong>
|
||||
</span>
|
||||
)}
|
||||
{userAgent?.os.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>OS: </span>
|
||||
<strong>{userAgent.os.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{userAgent?.browser.name && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>Browser: </span>
|
||||
<strong>
|
||||
{userAgent.browser.name} {userAgent.browser.version}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{auditLog.additional_fields?.build_reason &&
|
||||
auditLog.action === "start" && (
|
||||
<span css={styles.auditLogInfo}>
|
||||
<span>Reason: </span>
|
||||
{auditLog.organization && (
|
||||
<div>
|
||||
<h4 className="m-0 text-content-primary leading-[150%] font-semibold">
|
||||
Organization:
|
||||
</h4>
|
||||
<Link
|
||||
asChild
|
||||
showExternalIcon={false}
|
||||
className="px-0"
|
||||
>
|
||||
<RouterLink
|
||||
to={`/organizations/${auditLog.organization.name}`}
|
||||
>
|
||||
{auditLog.organization.display_name ||
|
||||
auditLog.organization.name}
|
||||
</RouterLink>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{auditLog.additional_fields?.build_reason &&
|
||||
auditLog.action === "start" && (
|
||||
<div>
|
||||
<h4 className="m-0 text-content-primary leading-normal font-semibold">
|
||||
Reason:
|
||||
</h4>
|
||||
<div>
|
||||
{
|
||||
buildReasonLabels[
|
||||
auditLog.additional_fields
|
||||
.build_reason as BuildReason
|
||||
]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex flex-row items-baseline gap-2">
|
||||
{auditLog.ip && (
|
||||
<span className="text-xs text-content-secondary block">
|
||||
<span>IP: </span>
|
||||
<strong>{auditLog.ip}</strong>
|
||||
</span>
|
||||
)}
|
||||
{userAgent?.os.name && (
|
||||
<span className="text-xs text-content-secondary block">
|
||||
<span>OS: </span>
|
||||
<strong>{userAgent.os.name}</strong>
|
||||
</span>
|
||||
)}
|
||||
{userAgent?.browser.name && (
|
||||
<span className="text-xs text-content-secondary block">
|
||||
<span>Browser: </span>
|
||||
<strong>
|
||||
{
|
||||
buildReasonLabels[
|
||||
auditLog.additional_fields
|
||||
.build_reason as BuildReason
|
||||
]
|
||||
}
|
||||
{userAgent.browser.name}{" "}
|
||||
{userAgent.browser.version}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{auditLog.additional_fields?.build_reason &&
|
||||
auditLog.action === "start" && (
|
||||
<span className="text-xs text-content-secondary block">
|
||||
<span>Reason: </span>
|
||||
<strong>
|
||||
{
|
||||
buildReasonLabels[
|
||||
auditLog.additional_fields
|
||||
.build_reason as BuildReason
|
||||
]
|
||||
}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldDisplayDiff ? (
|
||||
<div> {<DropdownArrow close={isDiffOpen} />}</div>
|
||||
) : (
|
||||
<div css={styles.columnWithoutDiff} />
|
||||
{shouldDisplayDiff ? (
|
||||
<div>
|
||||
<DropdownArrow close={isDiffOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-6" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldDisplayDiff && (
|
||||
<CollapsibleContent>
|
||||
<AuditLogDiff diff={auditDiff} />
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{shouldDisplayDiff && (
|
||||
<Collapse in={isDiffOpen}>
|
||||
<AuditLogDiff diff={auditDiff} />
|
||||
</Collapse>
|
||||
)}
|
||||
</Collapsible>
|
||||
</TableCell>
|
||||
</TimelineEntry>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
auditLogCell: {
|
||||
padding: "0 !important",
|
||||
border: 0,
|
||||
},
|
||||
|
||||
auditLogHeader: {
|
||||
padding: "16px 32px",
|
||||
},
|
||||
|
||||
auditLogHeaderInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
auditLogSummary: (theme) => ({
|
||||
...(theme.typography.body1 as CSSObject),
|
||||
fontFamily: "inherit",
|
||||
}),
|
||||
|
||||
auditLogTime: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
}),
|
||||
|
||||
auditLogInfo: (theme) => ({
|
||||
...(theme.typography.body2 as CSSObject),
|
||||
fontSize: 12,
|
||||
fontFamily: "inherit",
|
||||
color: theme.palette.text.secondary,
|
||||
display: "block",
|
||||
}),
|
||||
|
||||
auditLogInfoHeader: (theme) => ({
|
||||
margin: 0,
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: 14,
|
||||
lineHeight: "150%",
|
||||
fontWeight: 600,
|
||||
}),
|
||||
|
||||
auditLogInfoTooltip: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
// offset the absence of the arrow icon on diff-less logs
|
||||
columnWithoutDiff: {
|
||||
marginLeft: "24px",
|
||||
},
|
||||
|
||||
fullWidth: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
deletedLabel: (theme) => ({
|
||||
...(theme.typography.caption as CSSObject),
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
+9
-11
@@ -1,5 +1,5 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import type { ConnectionLog } from "api/typesGenerated";
|
||||
import { Link } from "components/Link/Link";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { connectionTypeToFriendlyName } from "utils/connection";
|
||||
@@ -62,11 +62,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
|
||||
<span>
|
||||
{user ? user.username : "Unauthenticated user"} {actionText} in{" "}
|
||||
{isOwnWorkspace ? "their" : `${workspace_owner_username}'s`}{" "}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/@${workspace_owner_username}/${workspace_name}`}
|
||||
>
|
||||
<strong>{workspace_name}</strong>
|
||||
<Link asChild showExternalIcon={false} className="text-base">
|
||||
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
|
||||
<strong>{workspace_name}</strong>
|
||||
</RouterLink>
|
||||
</Link>{" "}
|
||||
workspace
|
||||
</span>
|
||||
@@ -81,11 +80,10 @@ export const ConnectionLogDescription: FC<ConnectionLogDescriptionProps> = ({
|
||||
return (
|
||||
<span>
|
||||
{friendlyType} session to {workspace_owner_username}'s{" "}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/@${workspace_owner_username}/${workspace_name}`}
|
||||
>
|
||||
<strong>{workspace_name}</strong>
|
||||
<Link asChild showExternalIcon={false} className="text-base">
|
||||
<RouterLink to={`/@${workspace_owner_username}/${workspace_name}`}>
|
||||
<strong>{workspace_name}</strong>
|
||||
</RouterLink>
|
||||
</Link>{" "}
|
||||
workspace{" "}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import type { ConnectionLog } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { StatusPill } from "components/StatusPill/StatusPill";
|
||||
import { TableCell } from "components/Table/Table";
|
||||
import { TimelineEntry } from "components/Timeline/TimelineEntry";
|
||||
@@ -38,18 +36,9 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
|
||||
data-testid={`connection-log-row-${connectionLog.id}`}
|
||||
clickable={false}
|
||||
>
|
||||
<TableCell css={styles.connectionLogCell}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
css={styles.connectionLogHeader}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
css={styles.connectionLogHeaderInfo}
|
||||
>
|
||||
<TableCell className="!p-0 border-0">
|
||||
<div className="flex flex-row items-center gap-4 py-4 px-8">
|
||||
<div className="flex flex-row items-center gap-4 flex-1">
|
||||
{/* Non-web logs don't have an associated user, so we
|
||||
* display a default network icon instead */}
|
||||
{connectionLog.web_info?.user ? (
|
||||
@@ -63,27 +52,17 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
alignItems="center"
|
||||
css={styles.fullWidth}
|
||||
justifyContent="space-between"
|
||||
direction="row"
|
||||
>
|
||||
<Stack
|
||||
css={styles.connectionLogSummary}
|
||||
direction="row"
|
||||
alignItems="baseline"
|
||||
spacing={1}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full">
|
||||
<div className="flex flex-row items-baseline gap-2 text-base">
|
||||
<ConnectionLogDescription connectionLog={connectionLog} />
|
||||
<span css={styles.connectionLogTime}>
|
||||
<span className="text-content-secondary text-xs">
|
||||
{new Date(connectionLog.connect_time).toLocaleTimeString()}
|
||||
{connectionLog.ssh_info?.disconnect_time &&
|
||||
` → ${new Date(connectionLog.ssh_info.disconnect_time).toLocaleTimeString()}`}
|
||||
</span>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Stack direction="row" alignItems="center">
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{code !== undefined && (
|
||||
<StatusPill
|
||||
code={code}
|
||||
@@ -93,29 +72,31 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon
|
||||
css={(theme) => ({
|
||||
color: theme.palette.info.light,
|
||||
})}
|
||||
/>
|
||||
<InfoIcon className="text-content-link" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div css={styles.connectionLogInfoTooltip}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{connectionLog.ip && (
|
||||
<div>
|
||||
<h4 css={styles.connectionLogInfoheader}>IP:</h4>
|
||||
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
|
||||
IP:
|
||||
</h4>
|
||||
<div>{connectionLog.ip}</div>
|
||||
</div>
|
||||
)}
|
||||
{userAgent?.os.name && (
|
||||
<div>
|
||||
<h4 css={styles.connectionLogInfoheader}>OS:</h4>
|
||||
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
|
||||
OS:
|
||||
</h4>
|
||||
<div>{userAgent.os.name}</div>
|
||||
</div>
|
||||
)}
|
||||
{userAgent?.browser.name && (
|
||||
<div>
|
||||
<h4 css={styles.connectionLogInfoheader}>Browser:</h4>
|
||||
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
|
||||
Browser:
|
||||
</h4>
|
||||
<div>
|
||||
{userAgent.browser.name} {userAgent.browser.version}
|
||||
</div>
|
||||
@@ -123,21 +104,26 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
|
||||
)}
|
||||
{connectionLog.organization && (
|
||||
<div>
|
||||
<h4 css={styles.connectionLogInfoheader}>
|
||||
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
|
||||
Organization:
|
||||
</h4>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/organizations/${connectionLog.organization.name}`}
|
||||
asChild
|
||||
showExternalIcon={false}
|
||||
className="px-0 text-xs"
|
||||
>
|
||||
{connectionLog.organization.display_name ||
|
||||
connectionLog.organization.name}
|
||||
<RouterLink
|
||||
to={`/organizations/${connectionLog.organization.name}`}
|
||||
>
|
||||
{connectionLog.organization.display_name ||
|
||||
connectionLog.organization.name}
|
||||
</RouterLink>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{connectionLog.ssh_info?.disconnect_reason && (
|
||||
<div>
|
||||
<h4 css={styles.connectionLogInfoheader}>
|
||||
<h4 className="m-0 text-content-primary text-sm leading-[150%] font-semibold">
|
||||
Close Reason:
|
||||
</h4>
|
||||
<div>{connectionLog.ssh_info?.disconnect_reason}</div>
|
||||
@@ -146,54 +132,11 @@ export const ConnectionLogRow: FC<ConnectionLogRowProps> = ({
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TimelineEntry>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
connectionLogCell: {
|
||||
padding: "0 !important",
|
||||
border: 0,
|
||||
},
|
||||
|
||||
connectionLogHeader: {
|
||||
padding: "16px 32px",
|
||||
},
|
||||
|
||||
connectionLogHeaderInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
connectionLogSummary: (theme) => ({
|
||||
...(theme.typography.body1 as CSSObject),
|
||||
fontFamily: "inherit",
|
||||
}),
|
||||
|
||||
connectionLogTime: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
}),
|
||||
|
||||
connectionLogInfoheader: (theme) => ({
|
||||
margin: 0,
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: 14,
|
||||
lineHeight: "150%",
|
||||
fontWeight: 600,
|
||||
}),
|
||||
|
||||
connectionLogInfoTooltip: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
},
|
||||
|
||||
fullWidth: {
|
||||
width: "100%",
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { JobError } from "api/queries/templates";
|
||||
import type { TemplateVersion } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
@@ -46,7 +45,7 @@ export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
<h3 css={styles.title}>Creating template...</h3>
|
||||
<IconButton size="small" onClick={drawerProps.onClose}>
|
||||
<XIcon className="size-icon-sm" />
|
||||
<span style={visuallyHidden}>Close build logs</span>
|
||||
<span className="sr-only">Close build logs</span>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -86,24 +86,14 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
</span>
|
||||
</Stack>
|
||||
{license.claims.nbf && (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={0}
|
||||
alignItems="center"
|
||||
width="134px" // standardize width of date column
|
||||
>
|
||||
<Stack direction="column" spacing={0} alignItems="center">
|
||||
<span css={styles.secondaryMaincolor}>Valid From</span>
|
||||
<span css={styles.licenseExpires} className="license-valid-from">
|
||||
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
|
||||
</span>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={0}
|
||||
alignItems="center"
|
||||
width="134px" // standardize width of date column
|
||||
>
|
||||
<Stack direction="column" spacing={0} alignItems="center">
|
||||
{dayjs(license.claims.license_expires * 1000).isBefore(dayjs()) ? (
|
||||
<Pill css={styles.expiredBadge} type="error">
|
||||
Expired
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type ComponentProps,
|
||||
cloneElement,
|
||||
type FC,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
@@ -155,17 +154,15 @@ export const SectionLabel: FC<HTMLAttributes<HTMLHeadingElement>> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
type PillProps = HTMLAttributes<HTMLDivElement> & {
|
||||
type PillProps = React.ComponentPropsWithRef<"div"> & {
|
||||
icon: ReactElement<HTMLAttributes<HTMLElement>>;
|
||||
};
|
||||
|
||||
export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
|
||||
export const Pill: React.FC<PillProps> = ({ icon, children, ...divProps }) => {
|
||||
const theme = useTheme();
|
||||
const { icon, children, ...divProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
css={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -184,7 +181,7 @@ export const Pill = forwardRef<HTMLDivElement, PillProps>((props, ref) => {
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
type BooleanPillProps = Omit<ComponentProps<typeof Pill>, "icon" | "value"> & {
|
||||
value: boolean | null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import type { AuthMethods } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||
@@ -80,7 +79,7 @@ const OidcIcon: FC<OidcIconProps> = ({ iconUrl }) => {
|
||||
return (
|
||||
<>
|
||||
<img alt="" src={iconUrl} aria-labelledby={oidcId} />
|
||||
<div id={oidcId} css={{ ...visuallyHidden }}>
|
||||
<div id={oidcId} className="sr-only">
|
||||
Open ID Connect
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
MockCanceledWorkspace,
|
||||
MockCancelingWorkspace,
|
||||
MockDeletedWorkspace,
|
||||
MockDeletingWorkspace,
|
||||
MockDisplayNameTasks,
|
||||
MockFailedWorkspace,
|
||||
MockStartingWorkspace,
|
||||
MockStoppedWorkspace,
|
||||
MockStoppingWorkspace,
|
||||
MockTask,
|
||||
MockTasks,
|
||||
MockUserOwner,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
MockWorkspaceAgentStarting,
|
||||
MockWorkspaceApp,
|
||||
MockWorkspaceAppStatus,
|
||||
MockWorkspaceBuildStop,
|
||||
MockWorkspaceResource,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
@@ -180,6 +185,77 @@ export const DeletedWorkspace: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausing: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "active",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppingWorkspace,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPaused: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskPausedTimeout: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue({
|
||||
...MockStoppedWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspaceBuildStop,
|
||||
status: "stopped",
|
||||
reason: "autostop",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskCanceled: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockCanceledWorkspace,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskCanceling: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockCancelingWorkspace,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const TaskDeleting: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockDeletingWorkspace,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WaitingStartupScripts: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
@@ -403,7 +479,7 @@ export const MainAppHealthy: Story = mainAppHealthStory("healthy");
|
||||
export const MainAppInitializing: Story = mainAppHealthStory("initializing");
|
||||
export const MainAppUnhealthy: Story = mainAppHealthStory("unhealthy");
|
||||
|
||||
export const OutdatedWorkspace: Story = {
|
||||
export const TaskPausedOutdated: Story = {
|
||||
// Given: an 'outdated' workspace (that is, the latest build does not use template's active version)
|
||||
parameters: {
|
||||
queries: [
|
||||
@@ -487,10 +563,13 @@ export const ActivePreview: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStarting: Story = {
|
||||
export const TaskResuming: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
@@ -514,10 +593,10 @@ export const WorkspaceStarting: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const startButton = await canvas.findByText("Start workspace");
|
||||
expect(startButton).toBeInTheDocument();
|
||||
const resumeButton = await canvas.findByText("Resume");
|
||||
expect(resumeButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(startButton);
|
||||
await userEvent.click(resumeButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(API.startWorkspace).toBeCalled();
|
||||
@@ -525,10 +604,13 @@ export const WorkspaceStarting: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStartFailure: Story = {
|
||||
export const TaskResumeFailure: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getTask").mockResolvedValue({
|
||||
...MockTask,
|
||||
status: "paused",
|
||||
});
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
MockStoppedWorkspace,
|
||||
);
|
||||
@@ -552,10 +634,10 @@ export const WorkspaceStartFailure: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const startButton = await canvas.findByText("Start workspace");
|
||||
expect(startButton).toBeInTheDocument();
|
||||
const resumeButton = await canvas.findByText("Resume");
|
||||
expect(resumeButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(startButton);
|
||||
await userEvent.click(resumeButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const errorMessage = await canvas.findByText("Some unexpected error");
|
||||
@@ -564,7 +646,7 @@ export const WorkspaceStartFailure: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WorkspaceStartFailureWithDialog: Story = {
|
||||
export const TaskResumeFailureWithDialog: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTask").mockResolvedValue(MockTask);
|
||||
spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(
|
||||
@@ -594,10 +676,10 @@ export const WorkspaceStartFailureWithDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const startButton = await canvas.findByText("Start workspace");
|
||||
expect(startButton).toBeInTheDocument();
|
||||
const resumeButton = await canvas.findByText("Resume");
|
||||
expect(resumeButton).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(startButton);
|
||||
await userEvent.click(resumeButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
|
||||
import { pauseTask, resumeTask } from "api/queries/tasks";
|
||||
import { template as templateQueryOptions } from "api/queries/templates";
|
||||
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
|
||||
import {
|
||||
startWorkspace,
|
||||
workspaceByOwnerAndName,
|
||||
} from "api/queries/workspaces";
|
||||
import { workspaceByOwnerAndName } from "api/queries/workspaces";
|
||||
import type {
|
||||
Task,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceStatus,
|
||||
@@ -19,11 +17,17 @@ import { Margins } from "components/Margins/Margins";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PauseIcon,
|
||||
RotateCcwIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
|
||||
import { useAgentLogs } from "modules/resources/useAgentLogs";
|
||||
import { getAllAppsWithAgent } from "modules/tasks/apps";
|
||||
import { TasksSidebar } from "modules/tasks/TasksSidebar/TasksSidebar";
|
||||
import { isPauseDisabled } from "modules/tasks/taskActions";
|
||||
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
|
||||
@@ -134,33 +138,48 @@ const TaskPage = () => {
|
||||
);
|
||||
} else if (workspace.latest_build.status === "failed") {
|
||||
content = (
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Task build failed
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Please check the logs for more details.
|
||||
</span>
|
||||
<Button size="sm" variant="outline" asChild className="mt-4">
|
||||
<RouterLink
|
||||
to={`/@${workspace.owner_name}/${workspace.name}/builds/${workspace.latest_build.build_number}`}
|
||||
>
|
||||
View logs
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TaskBuildFailed
|
||||
workspaceOwner={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
buildNumber={workspace.latest_build.build_number}
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status !== "running") {
|
||||
} else if (workspace.latest_build.status === "stopping") {
|
||||
content = (
|
||||
<WorkspaceNotRunning
|
||||
<TaskTransitioning
|
||||
title="Pausing task"
|
||||
subtitle="Your task is being paused..."
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
workspace.latest_build.status === "stopped" ||
|
||||
workspace.latest_build.status === "canceled"
|
||||
) {
|
||||
content = (
|
||||
<TaskPaused
|
||||
task={task}
|
||||
workspace={workspace}
|
||||
onEditPrompt={() => setIsModifyDialogOpen(true)}
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status === "canceling") {
|
||||
content = (
|
||||
<TaskTransitioning
|
||||
title="Canceling task"
|
||||
subtitle="Your task is being canceled..."
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status === "deleting") {
|
||||
content = (
|
||||
<TaskTransitioning
|
||||
title="Deleting task"
|
||||
subtitle="Your task workspace is being deleted..."
|
||||
/>
|
||||
);
|
||||
} else if (workspace.latest_build.status === "deleted") {
|
||||
content = <TaskDeleted />;
|
||||
} else if (agent && ["created", "starting"].includes(agent.lifecycle_state)) {
|
||||
content = <TaskStartingAgent agent={agent} />;
|
||||
content = <TaskStartingAgent task={task} agent={agent} />;
|
||||
} else {
|
||||
const chatApp = getAllAppsWithAgent(workspace).find(
|
||||
(app) => app.id === task.workspace_app_id,
|
||||
@@ -213,111 +232,187 @@ const TaskPage = () => {
|
||||
|
||||
export default TaskPage;
|
||||
|
||||
type WorkspaceNotRunningProps = {
|
||||
/**
|
||||
* Common component for task state messages (paused, deleted, transitioning, etc.)
|
||||
* Similar to EmptyState but styled for task states.
|
||||
*/
|
||||
type TaskStateMessageProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
};
|
||||
|
||||
const TaskStateMessage: FC<TaskStateMessageProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions,
|
||||
detail,
|
||||
}) => {
|
||||
return (
|
||||
<Margins>
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<span className="text-content-secondary text-sm">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{detail}
|
||||
{actions && <div className="mt-4">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Margins>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskTransitioningProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
const TaskTransitioning: FC<TaskTransitioningProps> = ({ title, subtitle }) => {
|
||||
return (
|
||||
<TaskStateMessage
|
||||
title={title}
|
||||
description={subtitle}
|
||||
icon={<Spinner loading />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskDeleted: FC = () => {
|
||||
return (
|
||||
<TaskStateMessage
|
||||
title="Task was deleted"
|
||||
description="This task cannot be resumed. Create a new task to continue."
|
||||
actions={
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<RouterLink to="/tasks" data-testid="task-create-new">
|
||||
Create a new task
|
||||
</RouterLink>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskBuildFailedProps = {
|
||||
workspaceOwner: string;
|
||||
workspaceName: string;
|
||||
buildNumber: number;
|
||||
};
|
||||
|
||||
const TaskBuildFailed: FC<TaskBuildFailedProps> = ({
|
||||
workspaceOwner,
|
||||
workspaceName,
|
||||
buildNumber,
|
||||
}) => {
|
||||
return (
|
||||
<TaskStateMessage
|
||||
title="Task build failed"
|
||||
description="Please check the logs for more details."
|
||||
icon={<TriangleAlertIcon className="size-4 text-content-destructive" />}
|
||||
actions={
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<RouterLink
|
||||
to={`/@${workspaceOwner}/${workspaceName}/builds/${buildNumber}`}
|
||||
>
|
||||
View full logs
|
||||
</RouterLink>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TaskPausedProps = {
|
||||
task: Task;
|
||||
workspace: Workspace;
|
||||
onEditPrompt: () => void;
|
||||
};
|
||||
|
||||
const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({
|
||||
workspace,
|
||||
onEditPrompt,
|
||||
}) => {
|
||||
const TaskPaused: FC<TaskPausedProps> = ({ task, workspace, onEditPrompt }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: buildParameters } = useQuery(
|
||||
workspaceBuildParameters(workspace.latest_build.id),
|
||||
);
|
||||
|
||||
const mutateStartWorkspace = useMutation({
|
||||
...startWorkspace(workspace, queryClient),
|
||||
// Use mutation config directly to customize error handling:
|
||||
// API errors are shown in a dialog, other errors show a toast.
|
||||
const resumeMutation = useMutation({
|
||||
...resumeTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
if (!isApiError(error)) {
|
||||
displayError(getErrorMessage(error, "Failed to build workspace."));
|
||||
displayError(getErrorMessage(error, "Failed to resume task."));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// After requesting a workspace start, it may take a while to become ready.
|
||||
// Show a loading state in the meantime.
|
||||
// After requesting a task resume, it may take a while to become ready.
|
||||
const isWaitingForStart =
|
||||
mutateStartWorkspace.isPending || mutateStartWorkspace.isSuccess;
|
||||
resumeMutation.isPending || resumeMutation.isSuccess;
|
||||
|
||||
const apiError = isApiError(mutateStartWorkspace.error)
|
||||
? mutateStartWorkspace.error
|
||||
// Determine if this was a timeout (autostop) or manual pause.
|
||||
const isTimeout = workspace.latest_build.reason === "autostop";
|
||||
|
||||
const apiError = isApiError(resumeMutation.error)
|
||||
? resumeMutation.error
|
||||
: undefined;
|
||||
|
||||
const deleted = workspace.latest_build?.transition === ("delete" as const);
|
||||
|
||||
return deleted ? (
|
||||
<Margins>
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Task workspace was deleted.
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This task cannot be resumed. Delete this task and create a new one.
|
||||
</span>
|
||||
<Button size="sm" variant="outline" asChild className="mt-4">
|
||||
<RouterLink to="/tasks" data-testid="task-create-new">
|
||||
Create a new task
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Margins>
|
||||
) : (
|
||||
<Margins>
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Workspace is not running
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
Apps and previous statuses are not available
|
||||
</span>
|
||||
{workspace.outdated && (
|
||||
return (
|
||||
<>
|
||||
<TaskStateMessage
|
||||
title="Task paused"
|
||||
description={
|
||||
isTimeout
|
||||
? "Your task timed out. Resume it to continue."
|
||||
: "Resume the task to continue."
|
||||
}
|
||||
icon={<PauseIcon className="size-4" />}
|
||||
detail={
|
||||
workspace.outdated && (
|
||||
<div
|
||||
data-testid="workspace-outdated-tooltip"
|
||||
className="flex items-center gap-1.5 mt-1 text-content-secondary text-sm"
|
||||
>
|
||||
<WorkspaceOutdatedTooltip workspace={workspace}>
|
||||
You can update your task workspace to a newer version
|
||||
A newer template version is available
|
||||
</WorkspaceOutdatedTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row mt-4 gap-4">
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="task-start-workspace"
|
||||
disabled={isWaitingForStart}
|
||||
onClick={() => {
|
||||
mutateStartWorkspace.mutate({
|
||||
buildParameters,
|
||||
});
|
||||
}}
|
||||
onClick={() => resumeMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={isWaitingForStart} />
|
||||
Start workspace
|
||||
Resume
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEditPrompt} variant="outline">
|
||||
Edit Prompt
|
||||
Edit prompt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<WorkspaceErrorDialog
|
||||
open={apiError !== undefined}
|
||||
error={apiError}
|
||||
onClose={mutateStartWorkspace.reset}
|
||||
onClose={resumeMutation.reset}
|
||||
showDetail={true}
|
||||
workspaceOwner={workspace.owner_name}
|
||||
workspaceName={workspace.name}
|
||||
templateVersionId={workspace.latest_build.template_version_id}
|
||||
isDeleting={false}
|
||||
/>
|
||||
</Margins>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -405,12 +500,21 @@ const BuildingWorkspace: FC<BuildingWorkspaceProps> = ({
|
||||
};
|
||||
|
||||
type TaskStartingAgentProps = {
|
||||
task: Task;
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
|
||||
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ task, agent }) => {
|
||||
const logs = useAgentLogs({ agentId: agent.id });
|
||||
const listRef = useRef<FixedSizeList>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const pauseMutation = useMutation({
|
||||
...pauseTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to pause task."));
|
||||
},
|
||||
});
|
||||
const pauseDisabled = isPauseDisabled(task.status);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) {
|
||||
@@ -422,13 +526,26 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
|
||||
<section className="p-16 overflow-y-auto">
|
||||
<div className="flex justify-center items-center w-full">
|
||||
<div className="flex flex-col gap-8 items-center w-full">
|
||||
<header className="flex flex-col items-center text-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-xl">
|
||||
Running startup scripts
|
||||
</h3>
|
||||
<p className="text-content-secondary m-0">
|
||||
Your task will be running in a few moments
|
||||
</p>
|
||||
<header className="flex flex-col items-center text-center gap-3">
|
||||
<div>
|
||||
<h3 className="m-0 font-medium text-content-primary text-xl">
|
||||
Running startup scripts
|
||||
</h3>
|
||||
<p className="text-content-secondary m-0">
|
||||
Your task will be running in a few moments
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pauseDisabled || pauseMutation.isPending}
|
||||
onClick={() => pauseMutation.mutate()}
|
||||
>
|
||||
<Spinner loading={pauseMutation.isPending}>
|
||||
<PauseIcon className="size-4" />
|
||||
</Spinner>
|
||||
Pause
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import type { Task, TaskStatus as TaskStatusType } from "api/typesGenerated";
|
||||
import { pauseTask, resumeTask } from "api/queries/tasks";
|
||||
import type { Task } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
@@ -28,13 +28,18 @@ import {
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import { useClickableTableRow } from "hooks";
|
||||
import { EllipsisVertical, RotateCcwIcon, TrashIcon } from "lucide-react";
|
||||
import { TaskActionButton } from "modules/tasks/TaskActionButton";
|
||||
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
|
||||
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
|
||||
import {
|
||||
canPauseTask,
|
||||
canResumeTask,
|
||||
isPauseDisabled,
|
||||
} from "modules/tasks/taskActions";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { relativeTime } from "utils/time";
|
||||
import { TaskActionButton } from "./TaskActionButton";
|
||||
|
||||
type TasksTableProps = {
|
||||
tasks: readonly Task[] | undefined;
|
||||
@@ -173,16 +178,6 @@ const TasksEmpty: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const pauseStatuses: TaskStatusType[] = [
|
||||
"active",
|
||||
"initializing",
|
||||
"pending",
|
||||
"error",
|
||||
"unknown",
|
||||
];
|
||||
const pauseDisabledStatuses: TaskStatusType[] = ["pending", "initializing"];
|
||||
const resumeStatuses: TaskStatusType[] = ["paused", "error", "unknown"];
|
||||
|
||||
type TaskRowProps = {
|
||||
task: Task;
|
||||
checked: boolean;
|
||||
@@ -199,42 +194,20 @@ const TaskRow: FC<TaskRowProps> = ({
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const templateDisplayName = task.template_display_name ?? task.template_name;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showPause = canPauseTask(task.status) && task.workspace_id;
|
||||
const pauseDisabled = isPauseDisabled(task.status);
|
||||
const showResume = canResumeTask(task.status) && task.workspace_id;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const showPause = pauseStatuses.includes(task.status);
|
||||
const pauseDisabled = pauseDisabledStatuses.includes(task.status);
|
||||
const showResume = resumeStatuses.includes(task.status);
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.stopWorkspace(task.workspace_id);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
...pauseTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to pause task."));
|
||||
},
|
||||
});
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!task.workspace_id) {
|
||||
throw new Error("Task has no workspace");
|
||||
}
|
||||
return API.startWorkspace(
|
||||
task.workspace_id,
|
||||
task.template_version_id,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
...resumeTask(task, queryClient),
|
||||
onError: (error: unknown) => {
|
||||
displayError(getErrorMessage(error, "Failed to resume task."));
|
||||
},
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export const TemplateScheduleAutostart: FC<TemplateScheduleAutostartProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack width="100%" alignItems="start" spacing={1}>
|
||||
<Stack alignItems="start" spacing={1}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from "@mui/material/Link";
|
||||
import type { WorkspaceAgent } from "api/typesGenerated";
|
||||
import {
|
||||
Alert,
|
||||
@@ -6,6 +5,8 @@ import {
|
||||
type AlertProps,
|
||||
} from "components/Alert/Alert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { RefreshCcwIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
@@ -205,6 +206,7 @@ const RefreshSessionButton: FC = () => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCcwIcon />
|
||||
{isRefreshing ? "Refreshing session..." : "Refresh session"}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import visuallyHidden from "@mui/utils/visuallyHidden";
|
||||
import { richParameters } from "api/queries/templates";
|
||||
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
|
||||
import type {
|
||||
@@ -69,7 +68,7 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
|
||||
className="min-w-fit"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
<span css={{ ...visuallyHidden }}>{label}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</TopbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import Link, { type LinkProps } from "@mui/material/Link";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import {
|
||||
updateDeadline,
|
||||
@@ -218,7 +217,7 @@ const AutostopDisplay: FC<AutostopDisplayProps> = ({
|
||||
}}
|
||||
>
|
||||
<MinusIcon />
|
||||
<span style={visuallyHidden}>Subtract 1 hour from deadline</span>
|
||||
<span className="sr-only">Subtract 1 hour from deadline</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
@@ -236,7 +235,7 @@ const AutostopDisplay: FC<AutostopDisplayProps> = ({
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
<span style={visuallyHidden}>Add 1 hour to deadline</span>
|
||||
<span className="sr-only">Add 1 hour to deadline</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Add 1 hour to deadline</TooltipContent>
|
||||
|
||||
+11
-4
@@ -96,11 +96,18 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
|
||||
sendInitialParameters();
|
||||
// Skip stale responses. If we've already sent a newer request,
|
||||
// this response contains outdated parameter values that would
|
||||
// overwrite the user's more recent input.
|
||||
if (response.id < wsResponseId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLatestResponse(response);
|
||||
|
||||
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
|
||||
sendInitialParameters();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -197,7 +204,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
|
||||
if (
|
||||
latestBuildParametersLoading ||
|
||||
!latestResponse ||
|
||||
(!latestResponse && !wsError) ||
|
||||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return <Loader />;
|
||||
@@ -244,7 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
|
||||
autofillParameters={autofillParameters}
|
||||
canChangeVersions={canChangeVersions}
|
||||
parameters={sortedParams}
|
||||
diagnostics={latestResponse.diagnostics}
|
||||
diagnostics={latestResponse?.diagnostics ?? []}
|
||||
isSubmitting={updateParameters.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() =>
|
||||
|
||||
+41
-23
@@ -9,6 +9,7 @@ import { Label } from "components/Label/Label";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useFormik } from "formik";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters";
|
||||
import {
|
||||
DynamicParameter,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
useValidationSchemaForDynamicParameters,
|
||||
} from "modules/workspaces/DynamicParameter/DynamicParameter";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
import type { AutofillBuildParameter } from "utils/richParameters";
|
||||
|
||||
@@ -67,6 +69,23 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
workspace.template_require_active_version &&
|
||||
!canChangeVersions;
|
||||
|
||||
// Debounce websocket sends to avoid stale responses overwriting
|
||||
// the form while the user is still typing.
|
||||
const { debounced: sendDynamicParamsRequest } = useDebouncedFunction(
|
||||
(parameter: PreviewParameter, value: string) => {
|
||||
const formInputs: Record<string, string> = {};
|
||||
const formParameters = form.values.rich_parameter_values ?? [];
|
||||
for (const param of formParameters) {
|
||||
if (param?.name && param?.value) {
|
||||
formInputs[param.name] = param.value;
|
||||
}
|
||||
}
|
||||
formInputs[parameter.name] = value;
|
||||
sendMessage(formInputs);
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
const handleChange = async (
|
||||
parameter: PreviewParameter,
|
||||
parameterField: string,
|
||||
@@ -79,29 +98,27 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
sendDynamicParamsRequest(parameter, value);
|
||||
};
|
||||
|
||||
const sendDynamicParamsRequest = (
|
||||
parameter: PreviewParameter,
|
||||
value: string,
|
||||
) => {
|
||||
const formInputs: Record<string, string> = {};
|
||||
const parameters = form.values.rich_parameter_values ?? [];
|
||||
for (const param of parameters) {
|
||||
if (param?.name && param?.value) {
|
||||
formInputs[param.name] = param.value;
|
||||
}
|
||||
}
|
||||
|
||||
formInputs[parameter.name] = value;
|
||||
|
||||
sendMessage(formInputs);
|
||||
};
|
||||
|
||||
useSyncFormParameters({
|
||||
parameters,
|
||||
formValues: form.values.rich_parameter_values ?? [],
|
||||
setFieldValue: form.setFieldValue,
|
||||
});
|
||||
|
||||
// True when the form holds values the backend hasn't evaluated
|
||||
// yet (debounce pending or WS round-trip in flight).
|
||||
const hasUnsyncedParameters = (form.values.rich_parameter_values ?? []).some(
|
||||
(formParam) => {
|
||||
const responseParam = parameters.find((p) => p.name === formParam.name);
|
||||
if (!responseParam) {
|
||||
return true;
|
||||
}
|
||||
const responseValue = responseParam.value.valid
|
||||
? responseParam.value.value
|
||||
: "";
|
||||
return formParam.value !== responseValue;
|
||||
},
|
||||
);
|
||||
|
||||
const hasIncompatibleParameters = parameters.some((parameter) => {
|
||||
if (!parameter.mutable && parameter.diagnostics.length > 0) {
|
||||
return true;
|
||||
@@ -155,12 +172,12 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<div
|
||||
key={`diagnostic-${diagnostic.summary}-${index}`}
|
||||
className={`text-xs flex flex-col rounded-md border px-4 pb-3 border-solid
|
||||
${
|
||||
diagnostic.severity === "error"
|
||||
? " text-content-destructive border-border-destructive"
|
||||
: " text-content-warning border-border-warning"
|
||||
}`}
|
||||
className={cn(
|
||||
"text-xs flex flex-col rounded-md border px-4 pb-3 border-solid",
|
||||
diagnostic.severity === "error"
|
||||
? " text-content-destructive border-border-destructive"
|
||||
: " text-content-warning border-border-warning",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center m-0">
|
||||
<p className="font-medium">{diagnostic.summary}</p>
|
||||
@@ -248,6 +265,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
disabled ||
|
||||
hasUnsyncedParameters ||
|
||||
diagnostics.some(
|
||||
(diagnostic) => diagnostic.severity === "error",
|
||||
) ||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user