Compare commits
17 Commits
9a593b8b7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86fe7a8bf1 | |||
| dd41bb5a6a | |||
| 8c8536f668 | |||
| db3a86404a | |||
| d900d905de | |||
| 3d07301992 | |||
| f5be8d856d | |||
| 3bda68282e | |||
| 968dc74555 | |||
| eb2745dd5a | |||
| 1ff69f9328 | |||
| ef24af3302 | |||
| 7dff5454a0 | |||
| c2c188f09f | |||
| 254b7710d7 | |||
| dd063d5ac5 | |||
| 2885990ac6 |
57
.gitea/workflows/docker-publish-dev.yaml
Normal file
57
.gitea/workflows/docker-publish-dev.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Build and Push Docker Image (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'client/**'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.dev.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- '.gitea/workflows/docker-publish-dev.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.app.flexinit.nl
|
||||||
|
IMAGE_NAME: oussamadouhou/ai-stack-deployer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-dev:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: oussamadouhou
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=dev
|
||||||
|
type=sha,prefix=dev-
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
57
.gitea/workflows/docker-publish-main.yaml
Normal file
57
.gitea/workflows/docker-publish-main.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Build and Push Docker Image (Production)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'src/**'
|
||||||
|
- 'client/**'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.prod.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- '.gitea/workflows/docker-publish-main.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.app.flexinit.nl
|
||||||
|
IMAGE_NAME: oussamadouhou/ai-stack-deployer
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-main:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: oussamadouhou
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix=main-
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image (Staging)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
|
||||||
- staging
|
- staging
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- 'src/**'
|
- 'src/**'
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'Dockerfile'
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.staging.yml'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- '.gitea/workflows/**'
|
- '.gitea/workflows/docker-publish-staging.yaml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -19,7 +18,7 @@ env:
|
|||||||
IMAGE_NAME: oussamadouhou/ai-stack-deployer
|
IMAGE_NAME: oussamadouhou/ai-stack-deployer
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push-staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -45,10 +44,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=staging
|
||||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
type=sha,prefix=staging-
|
||||||
type=raw,value=staging,enable=${{ github.ref == 'refs/heads/staging' }}
|
|
||||||
type=sha,prefix={{branch}}-
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -36,7 +36,7 @@ export const translations = {
|
|||||||
title: 'AI Stack Deployer',
|
title: 'AI Stack Deployer',
|
||||||
subtitle: 'Implementeer je persoonlijke AI in seconden',
|
subtitle: 'Implementeer je persoonlijke AI in seconden',
|
||||||
chooseStackName: 'Kies Je Stack Naam',
|
chooseStackName: 'Kies Je Stack Naam',
|
||||||
availableAt: 'Je zal AI-assistenten beschikbaar zijn op',
|
availableAt: 'Je AI-assistenten zal beschikbaar zijn op',
|
||||||
stackName: 'Stack Naam',
|
stackName: 'Stack Naam',
|
||||||
placeholder: 'bijv., Oussama',
|
placeholder: 'bijv., Oussama',
|
||||||
inputHint: '3-20 tekens, kleine letters, cijfers en koppeltekens',
|
inputHint: '3-20 tekens, kleine letters, cijfers en koppeltekens',
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function DeployPage() {
|
|||||||
const response = await fetch('/api/deploy', {
|
const response = await fetch('/api/deploy', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name, lang }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -118,14 +118,14 @@ export default function DeployPage() {
|
|||||||
dotSize={2}
|
dotSize={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(0,0,0,1)_0%,_transparent_100%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(0,0,0,1)_0%,transparent_100%)]" />
|
||||||
<div className="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black to-transparent" />
|
<div className="absolute top-0 left-0 right-0 h-1/3 bg-linear-to-b from-black to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LanguageSelector currentLang={lang} onLangChange={setLang} />
|
<LanguageSelector currentLang={lang} onLangChange={setLang} />
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-[640px] p-4 md:p-8">
|
<div className="relative z-10 w-full max-w-w160 p-4 md:p-8">
|
||||||
<header className="text-center mb-12">
|
<header className="text-center mb-12 mt-25 md:mt-0">
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ai-stack-deployer:
|
ai-stack-deployer:
|
||||||
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:dev
|
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:dev
|
||||||
container_name: ai-stack-deployer-dev
|
container_name: ai-stack-deployer-dev
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
@@ -15,8 +11,8 @@ services:
|
|||||||
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
||||||
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
||||||
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
||||||
- SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}}
|
- SHARED_PROJECT_ID=${SHARED_PROJECT_ID}
|
||||||
- SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
- SHARED_ENVIRONMENT_ID=${SHARED_ENVIRONMENT_ID}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ai-stack-deployer-local
|
container_name: ai-stack-deployer-local
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ services:
|
|||||||
ai-stack-deployer:
|
ai-stack-deployer:
|
||||||
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest
|
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest
|
||||||
container_name: ai-stack-deployer
|
container_name: ai-stack-deployer
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
@@ -15,8 +13,8 @@ services:
|
|||||||
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
||||||
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
||||||
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
||||||
- SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}}
|
- SHARED_PROJECT_ID=${SHARED_PROJECT_ID}
|
||||||
- SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
- SHARED_ENVIRONMENT_ID=${SHARED_ENVIRONMENT_ID}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ services:
|
|||||||
ai-stack-deployer:
|
ai-stack-deployer:
|
||||||
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:staging
|
image: git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:staging
|
||||||
container_name: ai-stack-deployer-staging
|
container_name: ai-stack-deployer-staging
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=staging
|
- NODE_ENV=staging
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
@@ -15,8 +13,8 @@ services:
|
|||||||
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
- STACK_DOMAIN_SUFFIX=${STACK_DOMAIN_SUFFIX:-ai.flexinit.nl}
|
||||||
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
- STACK_IMAGE=${STACK_IMAGE:-git.app.flexinit.nl/flexinit/agent-stack:latest}
|
||||||
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
- RESERVED_NAMES=${RESERVED_NAMES:-admin,api,www,root,system,test,demo,portal}
|
||||||
- SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}}
|
- SHARED_PROJECT_ID=${SHARED_PROJECT_ID}
|
||||||
- SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
- SHARED_ENVIRONMENT_ID=${SHARED_ENVIRONMENT_ID}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -143,13 +143,15 @@ In the shared project (`ai-stack-portal`), add these **project-level environment
|
|||||||
|
|
||||||
The portal's docker-compose files use Dokploy's variable syntax to reference these:
|
The portal's docker-compose files use Dokploy's variable syntax to reference these:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}}
|
- SHARED_PROJECT_ID=$${{project.SHARED_PROJECT_ID}}
|
||||||
- SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
- SHARED_ENVIRONMENT_ID=$${{project.SHARED_ENVIRONMENT_ID}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**This syntax `${{project.VARIABLE}}` tells Dokploy**: "Get this value from the project-level environment variables"
|
**This syntax `$${{project.VARIABLE}}` tells Dokploy**: "Get this value from the project-level environment variables"
|
||||||
|
|
||||||
|
**Note**: The double `$$` is required to escape the dollar sign in Docker Compose files.
|
||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
|
|
||||||
@@ -199,12 +201,48 @@ environment:
|
|||||||
SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Configure Webhook**:
|
4. **⚠️ CRITICAL: Configure Custom Docker Compose Command**:
|
||||||
|
|
||||||
|
Because we use non-default compose file names (`docker-compose.dev.yml`, `docker-compose.prod.yml`, etc.), you **MUST** configure a custom command in Dokploy.
|
||||||
|
|
||||||
|
**In Dokploy UI:**
|
||||||
|
- Go to the application **Settings** or **Advanced** tab
|
||||||
|
- Find **"Custom Command"** or **"Docker Compose Command"** field
|
||||||
|
- Set it to:
|
||||||
|
```bash
|
||||||
|
compose -p <app-name> -f ./docker-compose.dev.yml up -d --remove-orphans --pull always
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace `<app-name>`** with your actual application name from Dokploy (e.g., `aistackportal-portal-0rohwx`)
|
||||||
|
|
||||||
|
**Replace `docker-compose.dev.yml`** with the appropriate file for each environment:
|
||||||
|
- Dev: `docker-compose.dev.yml`
|
||||||
|
- Staging: `docker-compose.staging.yml`
|
||||||
|
- Production: `docker-compose.prod.yml`
|
||||||
|
|
||||||
|
**Why this is required:**
|
||||||
|
- Dokploy's default command is `docker compose up -d` without the `-f` flag
|
||||||
|
- Without `-f`, docker looks for `docker-compose.yml` (which doesn't exist)
|
||||||
|
- This causes the error: `no configuration file provided: not found`
|
||||||
|
|
||||||
|
**Full examples:**
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
compose -p aistackportal-deployer-dev-xyz123 -f ./docker-compose.dev.yml up -d --remove-orphans --pull always
|
||||||
|
|
||||||
|
# Staging
|
||||||
|
compose -p aistackportal-deployer-staging-abc456 -f ./docker-compose.staging.yml up -d --remove-orphans --pull always
|
||||||
|
|
||||||
|
# Production
|
||||||
|
compose -p aistackportal-portal-0rohwx -f ./docker-compose.prod.yml up -d --remove-orphans --pull always
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Configure Webhook**:
|
||||||
- Event: **Push**
|
- Event: **Push**
|
||||||
- Branch: `dev`
|
- Branch: `dev`
|
||||||
- This will auto-deploy when you push to dev branch
|
- This will auto-deploy when you push to dev branch
|
||||||
|
|
||||||
5. **Deploy**
|
6. **Deploy**
|
||||||
|
|
||||||
### Step 2: Create Staging Application
|
### Step 2: Create Staging Application
|
||||||
|
|
||||||
@@ -412,6 +450,42 @@ Common issues:
|
|||||||
```
|
```
|
||||||
3. **Check environment variables**: Make sure all required vars are set
|
3. **Check environment variables**: Make sure all required vars are set
|
||||||
|
|
||||||
|
### Error: "no configuration file provided: not found"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ Command: docker compose up -d --force-recreate --pull always ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
no configuration file provided: not found
|
||||||
|
Error: ❌ Docker command failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Dokploy is looking for the default `docker-compose.yml` file, which doesn't exist. We use environment-specific files (`docker-compose.dev.yml`, `docker-compose.prod.yml`, etc.).
|
||||||
|
|
||||||
|
**Solution:** Configure a **custom Docker Compose command** in Dokploy:
|
||||||
|
|
||||||
|
1. Go to your application in Dokploy UI
|
||||||
|
2. Navigate to **Settings** → **Advanced** (or similar section)
|
||||||
|
3. Find **"Custom Command"** field
|
||||||
|
4. Set it to:
|
||||||
|
```bash
|
||||||
|
compose -p <app-name> -f ./docker-compose.{env}.yml up -d --remove-orphans --pull always
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
- `<app-name>` with your actual Dokploy app name (e.g., `aistackportal-portal-0rohwx`)
|
||||||
|
- `{env}` with `dev`, `staging`, or `prod`
|
||||||
|
|
||||||
|
**Example for production:**
|
||||||
|
```bash
|
||||||
|
compose -p aistackportal-portal-0rohwx -f ./docker-compose.prod.yml up -d --remove-orphans --pull always
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Save and redeploy
|
||||||
|
|
||||||
|
**Why the `-f` flag is needed:** Docker Compose defaults to looking for `docker-compose.yml`. The `-f` flag explicitly specifies which file to use.
|
||||||
|
|
||||||
### Health Check Failing
|
### Health Check Failing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ The AI Stack Deployer portal deploys **all user AI stacks to a single shared Dok
|
|||||||
│ 📦 Portal Application: ai-stack-deployer-prod │
|
│ 📦 Portal Application: ai-stack-deployer-prod │
|
||||||
│ ├─ Domain: portal.ai.flexinit.nl │
|
│ ├─ Domain: portal.ai.flexinit.nl │
|
||||||
│ ├─ Image: git.app.flexinit.nl/.../ai-stack-deployer:latest│
|
│ ├─ Image: git.app.flexinit.nl/.../ai-stack-deployer:latest│
|
||||||
│ └─ Env: SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}} │
|
│ └─ Env: SHARED_PROJECT_ID=$${{project.SHARED_PROJECT_ID}} │
|
||||||
│ │
|
│ │
|
||||||
│ ───────────────────────────────────────────────────────────── │
|
│ ───────────────────────────────────────────────────────────── │
|
||||||
│ │
|
│ │
|
||||||
@@ -56,10 +56,12 @@ When a user submits a stack name (e.g., "john-dev"), the portal:
|
|||||||
2. **These are set via Dokploy's project-level variables**:
|
2. **These are set via Dokploy's project-level variables**:
|
||||||
```yaml
|
```yaml
|
||||||
environment:
|
environment:
|
||||||
- SHARED_PROJECT_ID=${{project.SHARED_PROJECT_ID}}
|
- SHARED_PROJECT_ID=$${{project.SHARED_PROJECT_ID}}
|
||||||
- SHARED_ENVIRONMENT_ID=${{project.SHARED_ENVIRONMENT_ID}}
|
- SHARED_ENVIRONMENT_ID=$${{project.SHARED_ENVIRONMENT_ID}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note**: The double `$$` is required to escape the dollar sign in Docker Compose.
|
||||||
|
|
||||||
### Step 2: Portal Deploys to Shared Project
|
### Step 2: Portal Deploys to Shared Project
|
||||||
|
|
||||||
Instead of creating a new project, the portal:
|
Instead of creating a new project, the portal:
|
||||||
|
|||||||
110
docs/TESTING.md
110
docs/TESTING.md
@@ -261,3 +261,113 @@ Authorization: token <your-api-token>
|
|||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `GITEA_API_TOKEN` | Gitea API access for workflow status |
|
| `GITEA_API_TOKEN` | Gitea API access for workflow status |
|
||||||
| `DOKPLOY_API_TOKEN` | Dokploy deployment API (BWS ID: `6b3618fc-ba02-49bc-bdc8-b3c9004087bc`) |
|
| `DOKPLOY_API_TOKEN` | Dokploy deployment API (BWS ID: `6b3618fc-ba02-49bc-bdc8-b3c9004087bc`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Session: 2026-01-13
|
||||||
|
|
||||||
|
### Session Summary
|
||||||
|
|
||||||
|
**Goal:** Verify multi-environment deployment setup and shared project configuration.
|
||||||
|
|
||||||
|
### Completed Tasks
|
||||||
|
|
||||||
|
| Task | Status | Evidence |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Workflow separation (dev/staging/main) | ✅ | Committed as `eb2745d` |
|
||||||
|
| Dollar sign escaping (`$${{project.VAR}}`) | ✅ | Verified in all docker-compose.*.yml |
|
||||||
|
| Shared project exists | ✅ | `ai-stack-portal` (ID: `2y2Glhz5Wy0dBNf6BOR_-`) |
|
||||||
|
| Environment IDs retrieved | ✅ | See below |
|
||||||
|
| Local dev server health | ✅ | `/health` returns healthy |
|
||||||
|
|
||||||
|
### Environment IDs
|
||||||
|
|
||||||
|
```
|
||||||
|
Project: ai-stack-portal
|
||||||
|
ID: 2y2Glhz5Wy0dBNf6BOR_-
|
||||||
|
|
||||||
|
Environments:
|
||||||
|
- production: _dKAmxVcadqi-z73wKpEB (default)
|
||||||
|
- deployments: RqE9OFMdLwkzN7pif1xN8 (for user stacks)
|
||||||
|
- test: KVKn5fXGz10g7KVxPWOQj
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blockers Identified
|
||||||
|
|
||||||
|
#### BLOCKER: Dokploy API Token Permissions
|
||||||
|
|
||||||
|
**Symptom:** All Dokploy API calls return `Forbidden`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Previously working
|
||||||
|
curl -s "https://app.flexinit.nl/api/project.all" -H "x-api-key: $DOKPLOY_API_TOKEN"
|
||||||
|
# Now returns: Forbidden
|
||||||
|
|
||||||
|
# Environment endpoint
|
||||||
|
curl -s "https://app.flexinit.nl/api/environment.one?environmentId=RqE9OFMdLwkzN7pif1xN8" -H "x-api-key: $DOKPLOY_API_TOKEN"
|
||||||
|
# Returns: Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root Cause:** The API token `app_deployment...` has been revoked or has limited scope.
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Cannot verify Docker image exists in registry
|
||||||
|
- Cannot test name availability (requires `environment.one`)
|
||||||
|
- Cannot create applications or compose stacks
|
||||||
|
- Cannot deploy portal to Dokploy
|
||||||
|
|
||||||
|
**Resolution Required:**
|
||||||
|
1. Log into Dokploy UI at https://app.flexinit.nl
|
||||||
|
2. Navigate to Settings → API Keys
|
||||||
|
3. Generate new API key with full permissions:
|
||||||
|
- Read/Write access to projects
|
||||||
|
- Read/Write access to applications
|
||||||
|
- Read/Write access to compose stacks
|
||||||
|
- Read/Write access to domains
|
||||||
|
4. Update `.env` with new token
|
||||||
|
5. Update BWS secret (ID: `6b3618fc-ba02-49bc-bdc8-b3c9004087bc`)
|
||||||
|
|
||||||
|
### Local Testing Results
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check - WORKS
|
||||||
|
curl -s "http://localhost:3000/health"
|
||||||
|
# {"status":"healthy","timestamp":"2026-01-13T13:01:46.100Z","version":"0.2.0",...}
|
||||||
|
|
||||||
|
# Name check - FAILS (API token issue)
|
||||||
|
curl -s "http://localhost:3000/api/check/test-stack"
|
||||||
|
# {"available":false,"valid":false,"error":"Failed to check availability"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required .env Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Added for shared project deployment
|
||||||
|
SHARED_PROJECT_ID=2y2Glhz5Wy0dBNf6BOR_-
|
||||||
|
SHARED_ENVIRONMENT_ID=RqE9OFMdLwkzN7pif1xN8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next Steps After Token Fix
|
||||||
|
|
||||||
|
1. Verify `project.all` API works with new token
|
||||||
|
2. Deploy portal to Dokploy (docker-compose.dev.yml)
|
||||||
|
3. Test end-to-end stack deployment
|
||||||
|
4. Verify stacks deploy to shared project
|
||||||
|
5. Clean up test deployments
|
||||||
|
|
||||||
|
### Commands Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test API token
|
||||||
|
source .env && curl -s "https://app.flexinit.nl/api/project.all" \
|
||||||
|
-H "x-api-key: $DOKPLOY_API_TOKEN" | jq '.[].name'
|
||||||
|
|
||||||
|
# Get environment applications
|
||||||
|
source .env && curl -s "https://app.flexinit.nl/api/environment.one?environmentId=RqE9OFMdLwkzN7pif1xN8" \
|
||||||
|
-H "x-api-key: $DOKPLOY_API_TOKEN" | jq '.applications'
|
||||||
|
|
||||||
|
# Deploy test stack
|
||||||
|
curl -X POST http://localhost:3000/api/deploy \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"test-'$(date +%s | tail -c 4)'"}'
|
||||||
|
```
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import type { DeploymentState as OrchestratorDeploymentState } from './orchestra
|
|||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
// Extended deployment state for HTTP server (adds logs)
|
// Extended deployment state for HTTP server (adds logs and language)
|
||||||
interface HttpDeploymentState extends OrchestratorDeploymentState {
|
interface HttpDeploymentState extends OrchestratorDeploymentState {
|
||||||
logs: string[];
|
logs: string[];
|
||||||
|
lang: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deployments = new Map<string, HttpDeploymentState>();
|
const deployments = new Map<string, HttpDeploymentState>();
|
||||||
@@ -90,6 +91,7 @@ async function deployStack(deploymentId: string): Promise<void> {
|
|||||||
registryId: process.env.STACK_REGISTRY_ID,
|
registryId: process.env.STACK_REGISTRY_ID,
|
||||||
sharedProjectId: process.env.SHARED_PROJECT_ID,
|
sharedProjectId: process.env.SHARED_PROJECT_ID,
|
||||||
sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID,
|
sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID,
|
||||||
|
lang: deployment.lang,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Final update with logs
|
// Final update with logs
|
||||||
@@ -144,7 +146,7 @@ app.get('/health', (c) => {
|
|||||||
app.post('/api/deploy', async (c) => {
|
app.post('/api/deploy', async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { name } = body;
|
const { name, lang = 'en' } = body;
|
||||||
|
|
||||||
// Validate name
|
// Validate name
|
||||||
const validation = validateStackName(name);
|
const validation = validateStackName(name);
|
||||||
@@ -197,6 +199,7 @@ app.post('/api/deploy', async (c) => {
|
|||||||
started: new Date().toISOString(),
|
started: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
logs: [],
|
logs: [],
|
||||||
|
lang,
|
||||||
};
|
};
|
||||||
|
|
||||||
deployments.set(deploymentId, deployment);
|
deployments.set(deploymentId, deployment);
|
||||||
|
|||||||
65
src/lib/i18n-backend.ts
Normal file
65
src/lib/i18n-backend.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export const backendTranslations = {
|
||||||
|
en: {
|
||||||
|
'initializing': 'Initializing deployment',
|
||||||
|
'creatingProject': 'Creating project',
|
||||||
|
'gettingEnvironment': 'Getting environment ID',
|
||||||
|
'environmentAvailable': 'Environment ID already available',
|
||||||
|
'environmentRetrieved': 'Environment ID retrieved',
|
||||||
|
'creatingApplication': 'Creating application',
|
||||||
|
'configuringApplication': 'Configuring application',
|
||||||
|
'creatingDomain': 'Creating domain',
|
||||||
|
'deployingApplication': 'Deploying application',
|
||||||
|
'waitingForSSL': 'Waiting for SSL certificate provisioning...',
|
||||||
|
'waitingForStart': 'Waiting for application to start',
|
||||||
|
'deploymentSuccess': 'Application deployed successfully',
|
||||||
|
'verifyingHealth': 'Verifying application health',
|
||||||
|
},
|
||||||
|
nl: {
|
||||||
|
'initializing': 'Implementatie initialiseren',
|
||||||
|
'creatingProject': 'Project aanmaken',
|
||||||
|
'gettingEnvironment': 'Omgeving ID ophalen',
|
||||||
|
'environmentAvailable': 'Omgeving ID al beschikbaar',
|
||||||
|
'environmentRetrieved': 'Omgeving ID opgehaald',
|
||||||
|
'creatingApplication': 'Applicatie aanmaken',
|
||||||
|
'configuringApplication': 'Applicatie configureren',
|
||||||
|
'creatingDomain': 'Domein aanmaken',
|
||||||
|
'deployingApplication': 'Applicatie implementeren',
|
||||||
|
'waitingForSSL': 'Wachten op SSL-certificaat...',
|
||||||
|
'waitingForStart': 'Wachten tot applicatie start',
|
||||||
|
'deploymentSuccess': 'Applicatie succesvol geïmplementeerd',
|
||||||
|
'verifyingHealth': 'Applicatie gezondheid verifiëren',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
'initializing': 'جاري التهيئة',
|
||||||
|
'creatingProject': 'إنشاء المشروع',
|
||||||
|
'gettingEnvironment': 'الحصول على معرف البيئة',
|
||||||
|
'environmentAvailable': 'معرف البيئة متاح بالفعل',
|
||||||
|
'environmentRetrieved': 'تم استرداد معرف البيئة',
|
||||||
|
'creatingApplication': 'إنشاء التطبيق',
|
||||||
|
'configuringApplication': 'تكوين التطبيق',
|
||||||
|
'creatingDomain': 'إنشاء النطاق',
|
||||||
|
'deployingApplication': 'نشر التطبيق',
|
||||||
|
'waitingForSSL': 'انتظار شهادة SSL...',
|
||||||
|
'waitingForStart': 'انتظار بدء التطبيق',
|
||||||
|
'deploymentSuccess': 'تم نشر التطبيق بنجاح',
|
||||||
|
'verifyingHealth': 'التحقق من صحة التطبيق',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BackendLanguage = keyof typeof backendTranslations;
|
||||||
|
export type BackendTranslationKey = keyof typeof backendTranslations.en;
|
||||||
|
|
||||||
|
export function createTranslator(lang: BackendLanguage = 'en') {
|
||||||
|
return (key: BackendTranslationKey, params?: Record<string, string | number>): string => {
|
||||||
|
const translations = backendTranslations[lang] || backendTranslations.en;
|
||||||
|
let text: string = translations[key];
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([paramKey, value]) => {
|
||||||
|
text = text.replace(`{${paramKey}}`, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DokployProductionClient } from '../api/dokploy-production.js';
|
import { DokployProductionClient } from '../api/dokploy-production.js';
|
||||||
|
import { createTranslator, type BackendLanguage } from '../lib/i18n-backend.js';
|
||||||
|
|
||||||
export interface DeploymentConfig {
|
export interface DeploymentConfig {
|
||||||
stackName: string;
|
stackName: string;
|
||||||
@@ -22,6 +23,7 @@ export interface DeploymentConfig {
|
|||||||
registryId?: string;
|
registryId?: string;
|
||||||
sharedProjectId?: string;
|
sharedProjectId?: string;
|
||||||
sharedEnvironmentId?: string;
|
sharedEnvironmentId?: string;
|
||||||
|
lang?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeploymentState {
|
export interface DeploymentState {
|
||||||
@@ -71,10 +73,12 @@ export type ProgressCallback = (state: DeploymentState) => void;
|
|||||||
export class ProductionDeployer {
|
export class ProductionDeployer {
|
||||||
private client: DokployProductionClient;
|
private client: DokployProductionClient;
|
||||||
private progressCallback?: ProgressCallback;
|
private progressCallback?: ProgressCallback;
|
||||||
|
private t: ReturnType<typeof createTranslator>;
|
||||||
|
|
||||||
constructor(client: DokployProductionClient, progressCallback?: ProgressCallback) {
|
constructor(client: DokployProductionClient, progressCallback?: ProgressCallback) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.progressCallback = progressCallback;
|
this.progressCallback = progressCallback;
|
||||||
|
this.t = createTranslator('en');
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyProgress(state: DeploymentState): void {
|
private notifyProgress(state: DeploymentState): void {
|
||||||
@@ -87,13 +91,15 @@ export class ProductionDeployer {
|
|||||||
* Deploy a complete AI stack with full production safeguards
|
* Deploy a complete AI stack with full production safeguards
|
||||||
*/
|
*/
|
||||||
async deploy(config: DeploymentConfig): Promise<DeploymentResult> {
|
async deploy(config: DeploymentConfig): Promise<DeploymentResult> {
|
||||||
|
this.t = createTranslator((config.lang || 'en') as BackendLanguage);
|
||||||
|
|
||||||
const state: DeploymentState = {
|
const state: DeploymentState = {
|
||||||
id: `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
id: `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
stackName: config.stackName,
|
stackName: config.stackName,
|
||||||
phase: 'initializing',
|
phase: 'initializing',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: 'Initializing deployment',
|
message: this.t('initializing'),
|
||||||
resources: {},
|
resources: {},
|
||||||
timestamps: {
|
timestamps: {
|
||||||
started: new Date().toISOString(),
|
started: new Date().toISOString(),
|
||||||
@@ -228,12 +234,12 @@ export class ProductionDeployer {
|
|||||||
private async getEnvironment(state: DeploymentState): Promise<void> {
|
private async getEnvironment(state: DeploymentState): Promise<void> {
|
||||||
state.phase = 'getting_environment';
|
state.phase = 'getting_environment';
|
||||||
state.progress = 25;
|
state.progress = 25;
|
||||||
state.message = 'Getting environment ID';
|
state.message = this.t('gettingEnvironment');
|
||||||
|
|
||||||
// Skip if we already have environment ID from project creation
|
// Skip if we already have environment ID from project creation
|
||||||
if (state.resources.environmentId) {
|
if (state.resources.environmentId) {
|
||||||
console.log('Environment ID already available from project creation');
|
console.log('Environment ID already available from project creation');
|
||||||
state.message = 'Environment ID already available';
|
state.message = this.t('environmentAvailable');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +249,7 @@ export class ProductionDeployer {
|
|||||||
|
|
||||||
const environment = await this.client.getDefaultEnvironment(state.resources.projectId);
|
const environment = await this.client.getDefaultEnvironment(state.resources.projectId);
|
||||||
state.resources.environmentId = environment.environmentId;
|
state.resources.environmentId = environment.environmentId;
|
||||||
state.message = 'Environment ID retrieved';
|
state.message = this.t('environmentRetrieved');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createOrFindApplication(
|
private async createOrFindApplication(
|
||||||
@@ -252,7 +258,7 @@ export class ProductionDeployer {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
state.phase = 'creating_application';
|
state.phase = 'creating_application';
|
||||||
state.progress = 40;
|
state.progress = 40;
|
||||||
state.message = 'Creating application';
|
state.message = this.t('creatingApplication');
|
||||||
|
|
||||||
if (!state.resources.environmentId) {
|
if (!state.resources.environmentId) {
|
||||||
throw new Error('Environment ID not available');
|
throw new Error('Environment ID not available');
|
||||||
@@ -279,7 +285,7 @@ export class ProductionDeployer {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
state.phase = 'configuring_application';
|
state.phase = 'configuring_application';
|
||||||
state.progress = 50;
|
state.progress = 50;
|
||||||
state.message = 'Configuring application with Docker image';
|
state.message = this.t('configuringApplication');
|
||||||
|
|
||||||
if (!state.resources.applicationId) {
|
if (!state.resources.applicationId) {
|
||||||
throw new Error('Application ID not available');
|
throw new Error('Application ID not available');
|
||||||
@@ -332,7 +338,7 @@ export class ProductionDeployer {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
state.phase = 'creating_domain';
|
state.phase = 'creating_domain';
|
||||||
state.progress = 70;
|
state.progress = 70;
|
||||||
state.message = 'Creating domain';
|
state.message = this.t('creatingDomain');
|
||||||
|
|
||||||
if (!state.resources.applicationId) {
|
if (!state.resources.applicationId) {
|
||||||
throw new Error('Application ID not available');
|
throw new Error('Application ID not available');
|
||||||
@@ -359,7 +365,7 @@ export class ProductionDeployer {
|
|||||||
private async deployApplication(state: DeploymentState): Promise<void> {
|
private async deployApplication(state: DeploymentState): Promise<void> {
|
||||||
state.phase = 'deploying';
|
state.phase = 'deploying';
|
||||||
state.progress = 85;
|
state.progress = 85;
|
||||||
state.message = 'Triggering deployment';
|
state.message = this.t('deployingApplication');
|
||||||
|
|
||||||
if (!state.resources.applicationId) {
|
if (!state.resources.applicationId) {
|
||||||
throw new Error('Application ID not available');
|
throw new Error('Application ID not available');
|
||||||
@@ -375,7 +381,7 @@ export class ProductionDeployer {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
state.phase = 'verifying_health';
|
state.phase = 'verifying_health';
|
||||||
state.progress = 95;
|
state.progress = 95;
|
||||||
state.message = 'Verifying application status via Dokploy';
|
state.message = this.t('verifyingHealth');
|
||||||
|
|
||||||
if (!state.resources.applicationId) {
|
if (!state.resources.applicationId) {
|
||||||
throw new Error('Application ID not available');
|
throw new Error('Application ID not available');
|
||||||
@@ -392,13 +398,13 @@ export class ProductionDeployer {
|
|||||||
console.log(`Application status: ${appStatus}`);
|
console.log(`Application status: ${appStatus}`);
|
||||||
|
|
||||||
if (appStatus === 'done') {
|
if (appStatus === 'done') {
|
||||||
state.message = 'Waiting for SSL certificate provisioning...';
|
state.message = this.t('waitingForSSL');
|
||||||
state.progress = 98;
|
state.progress = 98;
|
||||||
this.notifyProgress(state);
|
this.notifyProgress(state);
|
||||||
|
|
||||||
await this.sleep(15000);
|
await this.sleep(15000);
|
||||||
|
|
||||||
state.message = 'Application deployed successfully';
|
state.message = this.t('deploymentSuccess');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +416,7 @@ export class ProductionDeployer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||||
state.message = `Waiting for application to start (${elapsed}s)...`;
|
state.message = `${this.t('waitingForStart')} (${elapsed}s)...`;
|
||||||
this.notifyProgress(state);
|
this.notifyProgress(state);
|
||||||
|
|
||||||
await this.sleep(interval);
|
await this.sleep(interval);
|
||||||
|
|||||||
Reference in New Issue
Block a user