feat: production-ready deployment with multi-language UI
- Add multi-language support (NL, AR, EN) with RTL
- Improve health checks (SSL-tolerant, multi-endpoint)
- Add DELETE /api/stack/:name for cleanup
- Add persistent storage (portal-ai-workspace-{name})
- Improve rollback (delete domain, app, project)
- Increase SSE timeout to 255s
- Add deployment strategy documentation
This commit is contained in:
@@ -1,58 +1,53 @@
|
||||
# START HERE
|
||||
# AI Stack Deployer
|
||||
|
||||
🚨🚨🚨🚨 ***PRINCIPLE RULES ALWAYS FIRST*** 🚨🚨🚨🚨
|
||||
## Quick Start
|
||||
|
||||
## What Happened Last Session
|
||||
|
||||
**Last Session** (2026-01-09 21:48):
|
||||
- Fixed CI workflow in `oh-my-opencode-free` project
|
||||
- Changed trigger from `master` → `main` to enable `:latest` tag
|
||||
- Build successful: `git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest`
|
||||
- Commit: `bdfa5c1`
|
||||
|
||||
## Current Task
|
||||
|
||||
**Build is successful** - Now need to:
|
||||
1. ✅ Create `.gitea/workflows/docker-publish.yaml` for THIS project
|
||||
2. ✅ Initialize git repo
|
||||
3. 🔧 Create repository on Gitea: https://git.app.flexinit.nl (repo: ai-stack-deployer)
|
||||
4. Push to trigger build and enable `:latest` tag
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Working Workflow**: `/home/odouhou/locale-projects/oh-my-opencode-free/.gitea/workflows/docker-publish.yaml`
|
||||
|
||||
**Key Config**:
|
||||
- Triggers on `main` branch (not master)
|
||||
- Registry: `git.app.flexinit.nl`
|
||||
- Auth: `oussamadouhou` + `REGISTRY_TOKEN` secret
|
||||
- Tags: `:latest` + `:<sha>`
|
||||
|
||||
## DOCUMENTATION
|
||||
|
||||
**Git Repository**:
|
||||
```bash
|
||||
# Remote URL pattern
|
||||
ssh://git@git.app.flexinit.nl:22222/oussamadouhou/{repo-name}.git
|
||||
bun run dev # Start server at http://localhost:3000
|
||||
bun run typecheck # Verify TypeScript
|
||||
```
|
||||
|
||||
# This project
|
||||
## Current Status
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| HTTP Server | ✅ Running |
|
||||
| Dokploy API | ✅ Connected |
|
||||
| Frontend | ✅ Redesigned (Antigravity style) |
|
||||
| Multi-language | ✅ NL/AR/EN with auto-detect |
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: Bun + Hono
|
||||
- **Frontend**: Vanilla JS (no frameworks)
|
||||
- **Deployment**: Dokploy → Traefik
|
||||
- **Styling**: Dark theme, IBM Plex Mono
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | HTTP server + API routes |
|
||||
| `src/frontend/` | UI (index.html, style.css, app.js) |
|
||||
| `src/api/dokploy-production.ts` | Dokploy client with retry logic |
|
||||
| `src/orchestrator/production-deployer.ts` | Deployment orchestration |
|
||||
|
||||
## Environment
|
||||
|
||||
```bash
|
||||
DOKPLOY_URL=https://app.flexinit.nl
|
||||
DOKPLOY_API_TOKEN=<from .env>
|
||||
STACK_DOMAIN_SUFFIX=ai.flexinit.nl
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
```bash
|
||||
origin: ssh://git@git.app.flexinit.nl:22222/oussamadouhou/ai-stack-deployer.git
|
||||
```
|
||||
|
||||
**Environment**:
|
||||
```bash
|
||||
STACK_IMAGE=git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest
|
||||
DOKPLOY_URL=https://app.flexinit.nl
|
||||
```
|
||||
## Docs
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Dev server
|
||||
bun run dev
|
||||
|
||||
# Docker
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
See `CLAUDE.md` for full documentation.
|
||||
- `CLAUDE.md` - Full project documentation
|
||||
- `docs/TESTING.md` - Test procedures and results
|
||||
- `docs/AGENTS.md` - AI agent guidelines
|
||||
|
||||
235
docs/AGENTS.md
235
docs/AGENTS.md
@@ -1,94 +1,170 @@
|
||||
# AI Agent Instructions - AI Stack Deployer
|
||||
# AI Stack Deployer - Agent Instructions
|
||||
|
||||
**Project-specific guidelines for AI coding agents**
|
||||
**MANDATORY: Read before implementing**
|
||||
|
||||
---
|
||||
|
||||
## Project Context
|
||||
## PROJECT IDENTITY
|
||||
|
||||
This is a self-service portal for deploying OpenCode AI stacks. Users enter their name and get a fully deployed AI assistant at `{name}.ai.flexinit.nl`.
|
||||
Self-service portal: Users enter name → Get deployed AI assistant at `{name}.ai.flexinit.nl`
|
||||
|
||||
**Key Technologies:**
|
||||
- Bun + Hono (backend)
|
||||
- Vanilla HTML/CSS/JS (frontend)
|
||||
- Docker + Dokploy (deployment)
|
||||
- Hetzner DNS API + Traefik (networking)
|
||||
**Stack:**
|
||||
- Backend: Bun + Hono
|
||||
- Frontend: Vanilla HTML/CSS/JS (NO frameworks)
|
||||
- Deploy: Docker + Dokploy
|
||||
- Network: Hetzner DNS + Traefik
|
||||
|
||||
---
|
||||
|
||||
## Critical Information
|
||||
## MANDATORY: DEPLOYMENT WORKFLOW
|
||||
|
||||
### API Endpoints
|
||||
**User submits name → Deploy AI stack (5 steps):**
|
||||
|
||||
1. **Validate** - Check name format, reserved names, availability
|
||||
2. **DNS** - Create `{name}.ai.flexinit.nl` → 144.76.116.169 (or rely on wildcard)
|
||||
3. **Dokploy Project** - Create project via API
|
||||
4. **Dokploy App** - Create application, add domain, deploy
|
||||
5. **Verify** - Wait for SSL, test OpenCode + ttyd endpoints
|
||||
|
||||
**CRITICAL:** Each step MUST succeed before proceeding. No parallel execution.
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY: SECRETS (Get FIRST)
|
||||
|
||||
#### Hetzner Cloud API (DNS)
|
||||
```bash
|
||||
# Base URL
|
||||
https://api.hetzner.cloud/v1
|
||||
|
||||
# IMPORTANT: Use /zones/{zone_id}/rrsets NOT /dns/zones
|
||||
# The old dns.hetzner.com API is DEPRECATED
|
||||
|
||||
# List records (RRSets)
|
||||
GET /zones/343733/rrsets
|
||||
Authorization: Bearer {HETZNER_API_TOKEN}
|
||||
|
||||
# Create DNS record (individual A record for user)
|
||||
# NOTE: Wildcard *.ai.flexinit.nl already exists pointing to Traefik
|
||||
# For per-user records (optional, wildcard handles it):
|
||||
POST /zones/343733/rrsets
|
||||
Authorization: Bearer {HETZNER_API_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "{name}.ai",
|
||||
"type": "A",
|
||||
"ttl": 300,
|
||||
"records": [
|
||||
{
|
||||
"value": "144.76.116.169",
|
||||
"comment": "AI Stack for {name}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Zone ID for flexinit.nl: 343733
|
||||
# Traefik IP: 144.76.116.169
|
||||
# Wildcard *.ai.flexinit.nl -> 144.76.116.169 (already configured)
|
||||
DOKPLOY_TOKEN=$(bws-wrapper get 6b3618fc-ba02-49bc-bdc8-b3c9004087bc)
|
||||
HETZNER_TOKEN=$(bws-wrapper get <HETZNER_BWS_ID>) # Ask user or search BWS
|
||||
```
|
||||
|
||||
#### Dokploy API
|
||||
---
|
||||
|
||||
## MANDATORY: API COMMANDS (COPY EXACTLY)
|
||||
|
||||
### Hetzner DNS API (MODIFY ONLY: name, comment)
|
||||
|
||||
**CRITICAL:** Use `api.hetzner.cloud/v1`, NOT `dns.hetzner.com` (deprecated)
|
||||
|
||||
```bash
|
||||
# Base URL
|
||||
http://10.100.0.20:3000/api
|
||||
# List existing DNS records
|
||||
curl -s "https://api.hetzner.cloud/v1/zones/343733/rrsets" \
|
||||
-H "Authorization: Bearer $HETZNER_TOKEN"
|
||||
|
||||
# All requests need:
|
||||
Authorization: Bearer {DOKPLOY_API_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
# Key endpoints:
|
||||
POST /project.create # Create project
|
||||
POST /application.create # Create application
|
||||
POST /domain.create # Add domain to application
|
||||
POST /application.deploy # Trigger deployment
|
||||
GET /application.one # Get application status
|
||||
# Create A record (OPTIONAL - wildcard *.ai.flexinit.nl already exists)
|
||||
curl -X POST "https://api.hetzner.cloud/v1/zones/343733/rrsets" \
|
||||
-H "Authorization: Bearer $HETZNER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "{name}.ai",
|
||||
"type": "A",
|
||||
"ttl": 300,
|
||||
"records": [{"value": "144.76.116.169", "comment": "AI Stack for {name}"}]
|
||||
}'
|
||||
```
|
||||
|
||||
### BWS Secrets
|
||||
**Constants:**
|
||||
- Zone ID: `343733` (flexinit.nl)
|
||||
- Traefik IP: `144.76.116.169`
|
||||
- Wildcard: `*.ai.flexinit.nl` → 144.76.116.169 (pre-configured)
|
||||
|
||||
| Purpose | BWS ID |
|
||||
|---------|--------|
|
||||
| Dokploy Token | `6b3618fc-ba02-49bc-bdc8-b3c9004087bc` |
|
||||
| Hetzner Token | Search BWS or ask user |
|
||||
### Dokploy API (MODIFY ONLY: projectId, applicationId, name, domain)
|
||||
|
||||
### Infrastructure IPs
|
||||
**Base:** `http://10.100.0.20:3000/api`
|
||||
|
||||
- **Traefik**: 144.76.116.169 (public, SSL termination)
|
||||
- **Dokploy**: 10.100.0.20:3000 (internal)
|
||||
- **DNS Zone**: flexinit.nl, ID 343733
|
||||
```bash
|
||||
# Create project
|
||||
curl -X POST "http://10.100.0.20:3000/api/project.create" \
|
||||
-H "Authorization: Bearer $DOKPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "{username}-ai-stack"}'
|
||||
|
||||
# Create application
|
||||
curl -X POST "http://10.100.0.20:3000/api/application.create" \
|
||||
-H "Authorization: Bearer $DOKPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"projectId": "...", "name": "{username}-opencode"}'
|
||||
|
||||
# Add domain
|
||||
curl -X POST "http://10.100.0.20:3000/api/domain.create" \
|
||||
-H "Authorization: Bearer $DOKPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"applicationId": "...", "host": "{name}.ai.flexinit.nl"}'
|
||||
|
||||
# Deploy application
|
||||
curl -X POST "http://10.100.0.20:3000/api/application.deploy" \
|
||||
-H "Authorization: Bearer $DOKPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"applicationId": "..."}'
|
||||
|
||||
# Check status
|
||||
curl -s "http://10.100.0.20:3000/api/application.one?applicationId=..." \
|
||||
-H "Authorization: Bearer $DOKPLOY_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Guidelines
|
||||
## CRITICAL: MANDATORY RULES
|
||||
|
||||
**NEVER:**
|
||||
- Use frontend frameworks (React, Vue, Svelte, etc.)
|
||||
- Use old Hetzner API (`dns.hetzner.com`)
|
||||
- Create Dokploy domain BEFORE application exists
|
||||
- Skip error handling on ANY API call
|
||||
- Store secrets in code
|
||||
- Proceed to next step if current step fails
|
||||
|
||||
**ALWAYS:**
|
||||
- Validate name (alphanumeric, 3-20 chars, not reserved)
|
||||
- Get tokens from BWS first
|
||||
- Wait for SSL certificate (30-60 seconds)
|
||||
- Verify each deployment step
|
||||
- Handle errors gracefully with user feedback
|
||||
- Mobile-responsive design
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL: GOTCHAS
|
||||
|
||||
1. **Hetzner API** - Use `api.hetzner.cloud`, NOT `dns.hetzner.com` (deprecated)
|
||||
2. **Dokploy domain** - MUST create domain AFTER application exists (or 404)
|
||||
3. **SSL delay** - Let's Encrypt cert takes 30-60 seconds to provision
|
||||
4. **ttyd WebSocket** - Requires Traefik WebSocket support configured
|
||||
5. **Container startup** - OpenCode server takes ~10 seconds to be ready
|
||||
6. **Reserved names** - Block: admin, api, www, root, test, staging, prod, etc.
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY: VALIDATION
|
||||
|
||||
**Before deployment, check:**
|
||||
```javascript
|
||||
// Name validation regex
|
||||
const isValid = /^[a-z0-9]{3,20}$/.test(name);
|
||||
|
||||
// Reserved names
|
||||
const reserved = ['admin', 'api', 'www', 'root', 'test', 'staging', 'prod'];
|
||||
const isReserved = reserved.includes(name);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MANDATORY: VERIFICATION
|
||||
|
||||
**After deployment, verify:**
|
||||
```bash
|
||||
# 1. SSL certificate provisioned
|
||||
curl -I "https://{name}.ai.flexinit.nl" # Should return 200
|
||||
|
||||
# 2. OpenCode server responding
|
||||
curl "https://{name}.ai.flexinit.nl" # Should show OpenCode UI
|
||||
|
||||
# 3. ttyd terminal accessible
|
||||
curl -I "https://{name}.ai.flexinit.nl:7681" # Should return 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Patterns
|
||||
|
||||
### Backend (Bun + Hono)
|
||||
|
||||
@@ -197,9 +273,9 @@ CMD ["sh", "-c", "opencode serve --host 0.0.0.0 --port 8080 & ttyd -W -p 7681 op
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
## MANDATORY: TESTING CHECKLIST
|
||||
|
||||
Before considering implementation complete:
|
||||
**Before marking complete, ALL must pass:**
|
||||
|
||||
- [ ] Name validation works (alphanumeric, 3-20 chars)
|
||||
- [ ] Reserved names blocked (admin, api, www, root, etc.)
|
||||
@@ -212,17 +288,7 @@ Before considering implementation complete:
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Loading states smooth (no flicker)
|
||||
|
||||
---
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Hetzner API** - Use `api.hetzner.cloud`, NOT `dns.hetzner.com` (deprecated)
|
||||
2. **Dokploy domain** - Must create domain AFTER application exists
|
||||
3. **SSL delay** - Let's Encrypt cert may take 30-60 seconds
|
||||
4. **ttyd WebSocket** - Needs proper Traefik WebSocket support
|
||||
5. **Container startup** - OpenCode server takes ~10 seconds to be ready
|
||||
|
||||
---
|
||||
|
||||
## File Reading Order
|
||||
|
||||
@@ -233,17 +299,6 @@ When starting implementation, read in this order:
|
||||
|
||||
---
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Do NOT use any frontend framework (React, Vue, etc.)
|
||||
- Do NOT add unnecessary dependencies
|
||||
- Do NOT store secrets in code
|
||||
- Do NOT skip error handling
|
||||
- Do NOT make the UI overly complex
|
||||
- Do NOT forget mobile responsiveness
|
||||
|
||||
---
|
||||
|
||||
## Reference Projects
|
||||
|
||||
- `~/locale-projects/oh-my-opencode-free` - The stack being deployed
|
||||
|
||||
290
docs/DEPLOYMENT_STRATEGY.md
Normal file
290
docs/DEPLOYMENT_STRATEGY.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# AI Stack Deployer - Deployment Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy the AI Stack Deployer portal to production, making it accessible at `deploy.ai.flexinit.nl`.
|
||||
|
||||
## User Stack Container (oh-my-opencode-free)
|
||||
|
||||
### Image Details
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Registry | `git.app.flexinit.nl` |
|
||||
| Image | `oussamadouhou/oh-my-opencode-free:latest` |
|
||||
| Base | `oven/bun:debian` |
|
||||
| Port | 8080 (OpenCode server) |
|
||||
| CI/CD | Gitea Actions (auto-build on push) |
|
||||
|
||||
### Pre-configured Free Models
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus (main) | `glm-4.7-free` | Primary coding |
|
||||
| Oracle | `gpt-5-nano` | Architecture |
|
||||
| Librarian | `minimax-m2.1-free` | Documentation |
|
||||
| Explore | `grok-code` | Codebase search |
|
||||
| Frontend | `glm-4.7-free` | UI/UX |
|
||||
| Document Writer | `gpt-5-nano` | Docs |
|
||||
|
||||
### Baked Configuration
|
||||
|
||||
```
|
||||
/shared/config/
|
||||
├── opencode.json # CLI config
|
||||
├── opencode.jsonc # Permissions
|
||||
└── oh-my-opencode.json # Agent model assignments
|
||||
```
|
||||
|
||||
### Persistent Storage (per user)
|
||||
|
||||
| Volume | Mount Path | Purpose |
|
||||
|--------|------------|---------|
|
||||
| `workspace-{name}` | `/workspace` | User projects |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Traefik (144.76.116.169) │
|
||||
│ - SSL termination (wildcard *.ai.flexinit.nl)│
|
||||
│ - Routes deploy.ai.flexinit.nl → portal │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ AI Stack Deployer Container │
|
||||
│ - Port 3000 │
|
||||
│ - Needs access to 10.100.0.20 (Dokploy) │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Dokploy (10.100.0.20:3000) │
|
||||
│ - Creates user stacks │
|
||||
│ - Manages containers │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Option A: Deploy via Dokploy (Recommended)
|
||||
|
||||
**Pros**: Self-service, consistent with stack deployments, automatic restarts
|
||||
**Cons**: Circular dependency (if Dokploy is down, can't redeploy)
|
||||
|
||||
### Option B: Direct Docker on Traefik Host
|
||||
|
||||
**Pros**: Independent of Dokploy, simpler network (same host as Traefik)
|
||||
**Cons**: Manual management, no Dokploy UI
|
||||
|
||||
### Option C: Separate VM with Docker Compose
|
||||
|
||||
**Pros**: Isolation, can use docker-compose.yml directly
|
||||
**Cons**: Extra VM, network routing complexity
|
||||
|
||||
**Decision: Option A (Dokploy)** - Eat our own dog food, leverage existing infrastructure.
|
||||
|
||||
## Step-by-Step Deployment
|
||||
|
||||
### Phase 1: Prepare Image
|
||||
|
||||
```bash
|
||||
# 1. Build and tag image
|
||||
docker build -t git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest .
|
||||
|
||||
# 2. Push to registry
|
||||
docker push git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest
|
||||
```
|
||||
|
||||
### Phase 2: Create Dokploy Project
|
||||
|
||||
1. Open Dokploy UI: https://app.flexinit.nl
|
||||
2. Create project: `ai-stack-deployer-portal`
|
||||
3. Create application:
|
||||
- Name: `deployer`
|
||||
- Type: Docker Image
|
||||
- Image: `git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest`
|
||||
|
||||
### Phase 3: Configure Environment
|
||||
|
||||
Set these environment variables in Dokploy:
|
||||
|
||||
| Variable | Value | Source |
|
||||
|----------|-------|--------|
|
||||
| `NODE_ENV` | `production` | Static |
|
||||
| `PORT` | `3000` | Static |
|
||||
| `HOST` | `0.0.0.0` | Static |
|
||||
| `DOKPLOY_URL` | `http://10.100.0.20:3000` | Static |
|
||||
| `DOKPLOY_API_TOKEN` | `<token>` | BWS: `6b3618fc-ba02-49bc-bdc8-b3c9004087bc` |
|
||||
| `STACK_DOMAIN_SUFFIX` | `ai.flexinit.nl` | Static |
|
||||
| `STACK_IMAGE` | `git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest` | Static |
|
||||
| `RESERVED_NAMES` | `admin,api,www,root,system,test,demo,portal,deploy` | Static |
|
||||
|
||||
### Phase 4: Configure Domain
|
||||
|
||||
1. In Dokploy application settings, add domain:
|
||||
- Host: `deploy.ai.flexinit.nl`
|
||||
- HTTPS: Enabled
|
||||
- Port: 3000
|
||||
|
||||
2. DNS is already configured (wildcard `*.ai.flexinit.nl` → 144.76.116.169)
|
||||
|
||||
### Phase 5: Deploy
|
||||
|
||||
1. Click "Deploy" in Dokploy UI
|
||||
2. Wait for container to start (~30s)
|
||||
3. Verify health: `curl https://deploy.ai.flexinit.nl/health`
|
||||
|
||||
## Network Requirements
|
||||
|
||||
The deployer container MUST be able to reach:
|
||||
- `10.100.0.20:3000` - Dokploy API (internal network)
|
||||
|
||||
This works automatically when deployed via Dokploy since containers share the internal network.
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### Using BWS
|
||||
|
||||
```bash
|
||||
# Get Dokploy token
|
||||
bws secret get 6b3618fc-ba02-49bc-bdc8-b3c9004087bc
|
||||
|
||||
# Set in Dokploy environment variables (manually or via API)
|
||||
```
|
||||
|
||||
### Rotation Strategy
|
||||
|
||||
1. Generate new token in Dokploy UI
|
||||
2. Store in BWS
|
||||
3. Update environment variable in Dokploy
|
||||
4. Redeploy application
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
- **Endpoint**: `GET /health`
|
||||
- **Interval**: 30 seconds
|
||||
- **Expected**: `{"status":"healthy",...}`
|
||||
|
||||
### Alerting
|
||||
|
||||
Set up monitoring for:
|
||||
- Health endpoint failures
|
||||
- High error rates in deployment logs
|
||||
- Circuit breaker state changes
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
### Quick Rollback
|
||||
|
||||
```bash
|
||||
# Via Dokploy UI
|
||||
1. Go to application → Deployments
|
||||
2. Click "Rollback" on previous successful deployment
|
||||
```
|
||||
|
||||
### Manual Rollback
|
||||
|
||||
```bash
|
||||
# Re-deploy previous image tag
|
||||
docker pull git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:v0.1.0
|
||||
# Update image in Dokploy and redeploy
|
||||
```
|
||||
|
||||
## CI/CD Pipeline (Future)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
docker build -t git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:${{ github.sha }} .
|
||||
docker push git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:${{ github.sha }}
|
||||
|
||||
- name: Deploy via Dokploy API
|
||||
run: |
|
||||
curl -X POST "$DOKPLOY_URL/api/application.update" \
|
||||
-H "x-api-key: $DOKPLOY_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"applicationId": "$APP_ID", "dockerImage": "...:${{ github.sha }}"}'
|
||||
|
||||
curl -X POST "$DOKPLOY_URL/api/application.deploy" \
|
||||
-H "x-api-key: $DOKPLOY_API_TOKEN" \
|
||||
-d '{"applicationId": "$APP_ID"}'
|
||||
```
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
- [ ] Docker image builds successfully: `docker build -t test .`
|
||||
- [ ] TypeScript compiles: `bun run typecheck`
|
||||
- [ ] Health endpoint works locally: `curl localhost:3000/health`
|
||||
- [ ] Dokploy API token is valid
|
||||
- [ ] Registry credentials configured
|
||||
- [ ] Domain DNS resolves correctly
|
||||
|
||||
## Post-Deployment Verification
|
||||
|
||||
```bash
|
||||
# 1. Health check
|
||||
curl https://deploy.ai.flexinit.nl/health
|
||||
|
||||
# 2. Name validation
|
||||
curl https://deploy.ai.flexinit.nl/api/check/test-name
|
||||
|
||||
# 3. Frontend loads
|
||||
curl -s https://deploy.ai.flexinit.nl | grep -q "AI Stack Deployer"
|
||||
|
||||
# 4. Full deployment test (optional)
|
||||
curl -X POST https://deploy.ai.flexinit.nl/api/deploy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "smoke-test"}'
|
||||
|
||||
# 5. Cleanup test
|
||||
curl -X DELETE https://deploy.ai.flexinit.nl/api/stack/smoke-test
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **No public auth yet** - Consider adding authentication for production
|
||||
2. **Rate limiting** - Not implemented, consider adding
|
||||
3. **API token exposure** - Stored in Dokploy env vars (encrypted at rest)
|
||||
4. **Delete endpoint** - Should require authentication in production
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Phase | Duration | Notes |
|
||||
|-------|----------|-------|
|
||||
| Image build & push | 5 min | Automated |
|
||||
| Dokploy project setup | 10 min | One-time |
|
||||
| Environment config | 5 min | One-time |
|
||||
| Deployment | 2 min | Per deploy |
|
||||
| Verification | 5 min | Per deploy |
|
||||
| **Total first deploy** | ~30 min | |
|
||||
| **Subsequent deploys** | ~10 min | |
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Build and push Docker image
|
||||
2. Create Dokploy project and application
|
||||
3. Configure environment variables
|
||||
4. Add domain
|
||||
5. Deploy and verify
|
||||
6. Set up monitoring
|
||||
7. (Optional) Implement CI/CD
|
||||
224
docs/TESTING.md
224
docs/TESTING.md
@@ -1,178 +1,110 @@
|
||||
# AI Stack Deployer - Testing Documentation
|
||||
# Testing Guide
|
||||
|
||||
🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨
|
||||
🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨
|
||||
## Quick Verification
|
||||
|
||||
Your only and main job now is to accurate follow the orders from the user. Your job is to validate and audit all code for issues, failures or misconfiguration. Your test plan should look like:
|
||||
Phase 1: Preparation & Static Analysis
|
||||
|
||||
Code Review (Audit): Have the code reviewed by a colleague or use a static analysis tool (linter) to identify syntax and style errors prior to execution.
|
||||
Logic Validation: Verify that the code logic aligns with the initial requirements (checking what it is supposed to do, not necessarily if it runs yet).
|
||||
|
||||
Phase 2: Unit Testing (Automated)
|
||||
3. Run Unit Tests: Execute tests on small, isolated components (e.g., verifying that the authentication function returns the correct token).
|
||||
4. Check Code Coverage: Ensure that critical paths and functions are actually being tested.
|
||||
|
||||
Phase 3: Integration & Functional Testing
|
||||
5. Authentication Test: Verify that the application or script can successfully connect to required external systems (databases, APIs, login services). Note: This is a prerequisite for the next steps.
|
||||
6. Execute Scripts (Happy Path): Run the script or application in the standard, intended way.
|
||||
7. Monitor Logs: Monitor the output for error logs, warnings, or unexpected behavior during execution.
|
||||
|
||||
Phase 4: Evaluation & Reporting
|
||||
8. Analyze Results: Compare the actual output against the expected results.
|
||||
9. Report Status: If all tests pass, approve the code for release and inform the user.
|
||||
|
||||
🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨🚨
|
||||
|
||||
**Last Updated**: 2026-01-09
|
||||
|
||||
## Infrastructure Context (from memory/docs)
|
||||
|
||||
| Service | IP | Port | Notes |
|
||||
|---------|-----|------|-------|
|
||||
| Dokploy | 10.100.0.20 | 3000 | Container orchestration, Grafana Loki also here |
|
||||
| Loki | 10.100.0.20 | 3100 | Logging aggregation |
|
||||
| Grafana | 10.100.0.20 | 3000 (UI) | Dashboards at https://logs.intra.flexinit.nl |
|
||||
| Traefik | 10.100.0.12 | - | VM 202 - Reverse proxy, SSL |
|
||||
| AI Server | 10.100.0.19 | - | VM 209 - OpenCode agents |
|
||||
|
||||
**Dokploy config location**: `/etc/dokploy/compose/` on 10.100.0.20
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 Test Results
|
||||
|
||||
### 1. Hono Server
|
||||
|
||||
| Test | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Server starts | ✅ PASS | Runs on port 3000 |
|
||||
| Health endpoint | ✅ PASS | Returns JSON with status, timestamp, version |
|
||||
| Root endpoint | ✅ PASS | Returns API endpoint list |
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Start dev server
|
||||
# 1. Start server
|
||||
bun run dev
|
||||
|
||||
# Test health endpoint
|
||||
# 2. Health check
|
||||
curl http://localhost:3000/health
|
||||
# Response: {"status":"healthy","timestamp":"2026-01-09T14:13:50.237Z","version":"0.1.0","service":"ai-stack-deployer"}
|
||||
|
||||
# Test root endpoint
|
||||
curl http://localhost:3000/
|
||||
# Response: {"message":"AI Stack Deployer API","endpoints":{...}}
|
||||
# 3. Open browser
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
### 2. Hetzner DNS Client
|
||||
## API Endpoints
|
||||
|
||||
| Test | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Connection test | ✅ PASS | Successfully connects to Hetzner Cloud API |
|
||||
| Zone access | ✅ PASS | Zone "flexinit.nl" (ID: 343733) accessible |
|
||||
| RRSets listing | ✅ PASS | Returns 75 RRSets |
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Server status |
|
||||
| `/api/check/:name` | GET | Name availability |
|
||||
| `/api/deploy` | POST | Start deployment |
|
||||
| `/api/status/:id` | GET | SSE progress stream |
|
||||
| `/api/stack/:name` | DELETE | Delete stack and cleanup |
|
||||
|
||||
**IMPORTANT FINDING:**
|
||||
- Hetzner DNS has been **migrated from dns.hetzner.com to api.hetzner.cloud**
|
||||
- The old DNS Console API at `dns.hetzner.com/api/v1` is deprecated
|
||||
- Must use new Hetzner Cloud API at `api.hetzner.cloud/v1`
|
||||
- Authentication: `Authorization: Bearer {token}` (NOT `Auth-API-Token`)
|
||||
- Endpoints: `/zones`, `/zones/{id}/rrsets`
|
||||
## Test Checklist
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Test Hetzner client
|
||||
bun run src/test-clients.ts
|
||||
### Backend
|
||||
|
||||
# Manual API test
|
||||
curl -s "https://api.hetzner.cloud/v1/zones" \
|
||||
-H "Authorization: Bearer $HETZNER_API_TOKEN"
|
||||
```
|
||||
| Test | Command | Expected |
|
||||
|------|---------|----------|
|
||||
| Server starts | `bun run dev` | "starting on http://0.0.0.0:3000" |
|
||||
| Health endpoint | `curl localhost:3000/health` | `{"status":"healthy"...}` |
|
||||
| TypeScript | `bun run typecheck` | No errors |
|
||||
| Dokploy connection | Check `/api/check/test-name` | Returns availability |
|
||||
|
||||
### 3. Dokploy Client
|
||||
### Frontend
|
||||
|
||||
| Test | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| Connection test | ❌ FAIL | Returns "Unauthorized" |
|
||||
| Server accessible | ✅ PASS | Dokploy UI loads at http://10.100.0.20:3000 |
|
||||
| Test | Action | Expected |
|
||||
|------|--------|----------|
|
||||
| Page loads | Open localhost:3000 | Dark theme, centered content |
|
||||
| Typewriter | Wait 2s | "Choose Your Stack Name" animates |
|
||||
| Language switch | Click 🇲🇦 | Arabic text, RTL layout |
|
||||
| Name validation | Type "ab" | Error: too short |
|
||||
| Reserved name | Type "admin" | Error: reserved |
|
||||
| Valid name | Type "my-stack" | "✓ Name is available!" |
|
||||
|
||||
**BLOCKER:**
|
||||
- Token `app_deployment...` returns 401 Unauthorized
|
||||
- Token was created 2026-01-05 but may be expired or have insufficient permissions
|
||||
- **ACTION REQUIRED**: Generate new token from Dokploy dashboard
|
||||
### Deployment Flow
|
||||
|
||||
**Steps to generate new Dokploy API token:**
|
||||
1. Navigate to https://deploy.intra.flexinit.nl or http://10.100.0.20:3000
|
||||
2. Login with admin credentials
|
||||
3. Go to: Settings (gear icon) → Profile
|
||||
4. Scroll to "API Tokens" section
|
||||
5. Click "Generate" button
|
||||
6. Copy the new token (format: `app_deployment<random>`)
|
||||
7. Update BWS secret: `bws secret edit 6b3618fc-ba02-49bc-bdc8-b3c9004087bc`
|
||||
8. Update local `.env` file
|
||||
| Step | Indicator |
|
||||
|------|-----------|
|
||||
| Submit form | Progress bar appears |
|
||||
| SSE updates | Log entries animate in |
|
||||
| Success | Typewriter: "Deployment Complete" |
|
||||
| Error | Typewriter: "Deployment Failed" |
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# Test Dokploy API (currently failing)
|
||||
curl -s "http://10.100.0.20:3000/api/project.all" \
|
||||
-H "Authorization: Bearer $DOKPLOY_API_TOKEN"
|
||||
# Response: {"message":"Unauthorized"}
|
||||
```
|
||||
## Infrastructure
|
||||
|
||||
---
|
||||
| Service | URL | Purpose |
|
||||
|---------|-----|---------|
|
||||
| Dokploy | https://app.flexinit.nl | Container orchestration |
|
||||
| Traefik | 144.76.116.169 | SSL termination |
|
||||
| Stacks | *.ai.flexinit.nl | Deployed AI assistants |
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### .env File (from .env.example)
|
||||
## Full Deployment Test
|
||||
|
||||
```bash
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
# 1. Generate unique name
|
||||
NAME="test-$(date +%s | tail -c 5)"
|
||||
|
||||
# Hetzner Cloud DNS API (WORKING)
|
||||
HETZNER_API_TOKEN=<from BWS - HETZNER_DNS_TOKEN>
|
||||
HETZNER_ZONE_ID=343733
|
||||
# 2. Check availability
|
||||
curl -s http://localhost:3000/api/check/$NAME
|
||||
|
||||
# Dokploy API (NEEDS NEW TOKEN)
|
||||
DOKPLOY_URL=http://10.100.0.20:3000
|
||||
DOKPLOY_API_TOKEN=<generate from Dokploy dashboard>
|
||||
# 3. Deploy
|
||||
curl -s -X POST http://localhost:3000/api/deploy \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\": \"$NAME\"}"
|
||||
|
||||
STACK_DOMAIN_SUFFIX=ai.flexinit.nl
|
||||
STACK_IMAGE=git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest
|
||||
TRAEFIK_IP=144.76.116.169
|
||||
# 4. Monitor SSE (wait ~2-3 min)
|
||||
curl -N http://localhost:3000/api/status/<deployment-id>
|
||||
|
||||
RESERVED_NAMES=admin,api,www,root,system,test,demo,portal
|
||||
# 5. Verify stack accessible
|
||||
curl -s https://$NAME.ai.flexinit.nl
|
||||
|
||||
# 6. Cleanup
|
||||
curl -s -X DELETE http://localhost:3000/api/stack/$NAME
|
||||
```
|
||||
|
||||
---
|
||||
## Cleanup Commands
|
||||
|
||||
## BWS Secrets Reference
|
||||
```bash
|
||||
# Delete specific stack
|
||||
curl -s -X DELETE http://localhost:3000/api/stack/my-stack
|
||||
|
||||
| Secret | BWS Key | Status |
|
||||
|--------|---------|--------|
|
||||
| Hetzner API Token | `HETZNER_DNS_TOKEN` | ✅ Working |
|
||||
| Dokploy API Token | `DOKPLOY_API_TOKEN` (ID: 6b3618fc-ba02-49bc-bdc8-b3c9004087bc) | ❌ Expired/Invalid |
|
||||
# List all projects (direct Dokploy)
|
||||
source .env && curl -s -H "x-api-key: $DOKPLOY_API_TOKEN" \
|
||||
"$DOKPLOY_URL/api/project.all" | jq '.[].name'
|
||||
```
|
||||
|
||||
---
|
||||
## Common Issues
|
||||
|
||||
## Gotchas & Learnings
|
||||
|
||||
### 1. Hetzner DNS API Migration
|
||||
- **Old API**: `dns.hetzner.com/api/v1` with `Auth-API-Token` header
|
||||
- **New API**: `api.hetzner.cloud/v1` with `Authorization: Bearer` header
|
||||
- Zone ID 343733 works in new API
|
||||
- RRSets replace Records concept
|
||||
|
||||
### 2. Dokploy Token Format
|
||||
- Format: `app_deployment<random>`
|
||||
- Created from: Dashboard > Settings > Profile > API Tokens
|
||||
- Must have permissions for project/application management
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. [ ] Generate new Dokploy API token from dashboard
|
||||
2. [ ] Update BWS with new token
|
||||
3. [ ] Verify Dokploy client works
|
||||
4. [ ] Proceed to Phase 2 implementation
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| CSS not loading | Check `/style.css` returns CSS, not HTML |
|
||||
| 401 on Dokploy | Regenerate API token in Dokploy dashboard |
|
||||
| Typewriter not running | Check browser console for JS errors |
|
||||
| RTL not working | Verify `dir="rtl"` on `<html>` element |
|
||||
| Health check timeout | Container startup can take 1-2 min, timeout is 3 min |
|
||||
| SSL cert errors | Health check treats SSL errors as "alive" during provisioning |
|
||||
| SSE disconnects | idleTimeout set to 255s (max), long deployments should complete |
|
||||
|
||||
@@ -443,6 +443,36 @@ export class DokployProductionClient {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteDomain(domainId: string): Promise<void> {
|
||||
await this.request(
|
||||
'POST',
|
||||
'/domain.delete',
|
||||
{ domainId },
|
||||
'domain',
|
||||
'delete'
|
||||
);
|
||||
}
|
||||
|
||||
async createMount(
|
||||
applicationId: string,
|
||||
volumeName: string,
|
||||
mountPath: string
|
||||
): Promise<{ mountId: string }> {
|
||||
return this.request<{ mountId: string }>(
|
||||
'POST',
|
||||
'/mounts.create',
|
||||
{
|
||||
type: 'volume',
|
||||
volumeName,
|
||||
mountPath,
|
||||
serviceId: applicationId,
|
||||
serviceType: 'application',
|
||||
},
|
||||
'mount',
|
||||
'create'
|
||||
);
|
||||
}
|
||||
|
||||
getCircuitBreakerState() {
|
||||
return this.circuitBreaker.getState();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,188 @@
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'AI Stack Deployer',
|
||||
subtitle: 'Deploy your personal OpenCode AI coding assistant in seconds',
|
||||
chooseStackName: 'Choose Your Stack Name',
|
||||
availableAt: 'Your AI assistant will be available at',
|
||||
stackName: 'Stack Name',
|
||||
placeholder: 'e.g., john-dev',
|
||||
inputHint: '3-20 characters, lowercase letters, numbers, and hyphens only',
|
||||
deployBtn: 'Deploy My AI Stack',
|
||||
deploying: 'Deploying Your Stack',
|
||||
stack: 'Stack',
|
||||
initializing: 'Initializing deployment...',
|
||||
successMessage: 'Your AI coding assistant is ready to use',
|
||||
stackNameLabel: 'Stack Name:',
|
||||
openStack: 'Open My AI Stack',
|
||||
deployAnother: 'Deploy Another Stack',
|
||||
tryAgain: 'Try Again',
|
||||
poweredBy: 'Powered by',
|
||||
deploymentComplete: 'Deployment Complete',
|
||||
deploymentFailed: 'Deployment Failed',
|
||||
nameRequired: 'Name is required',
|
||||
nameLengthError: 'Name must be between 3 and 20 characters',
|
||||
nameCharsError: 'Only lowercase letters, numbers, and hyphens allowed',
|
||||
nameHyphenError: 'Cannot start or end with a hyphen',
|
||||
nameReserved: 'This name is reserved',
|
||||
checkingAvailability: 'Checking availability...',
|
||||
nameAvailable: '✓ Name is available!',
|
||||
nameNotAvailable: 'Name is not available',
|
||||
checkFailed: 'Failed to check availability',
|
||||
connectionLost: 'Connection lost. Please refresh and try again.',
|
||||
deployingText: 'Deploying...'
|
||||
},
|
||||
nl: {
|
||||
title: 'AI Stack Deployer',
|
||||
subtitle: 'Implementeer je persoonlijke OpenCode AI programmeerassistent in seconden',
|
||||
chooseStackName: 'Kies Je Stack Naam',
|
||||
availableAt: 'Je AI-assistent is beschikbaar op',
|
||||
stackName: 'Stack Naam',
|
||||
placeholder: 'bijv., jan-dev',
|
||||
inputHint: '3-20 tekens, kleine letters, cijfers en koppeltekens',
|
||||
deployBtn: 'Implementeer Mijn AI Stack',
|
||||
deploying: 'Stack Wordt Geïmplementeerd',
|
||||
stack: 'Stack',
|
||||
initializing: 'Implementatie initialiseren...',
|
||||
successMessage: 'Je AI programmeerassistent is klaar voor gebruik',
|
||||
stackNameLabel: 'Stack Naam:',
|
||||
openStack: 'Open Mijn AI Stack',
|
||||
deployAnother: 'Implementeer Nog Een Stack',
|
||||
tryAgain: 'Probeer Opnieuw',
|
||||
poweredBy: 'Mogelijk gemaakt door',
|
||||
deploymentComplete: 'Implementatie Voltooid',
|
||||
deploymentFailed: 'Implementatie Mislukt',
|
||||
nameRequired: 'Naam is verplicht',
|
||||
nameLengthError: 'Naam moet tussen 3 en 20 tekens zijn',
|
||||
nameCharsError: 'Alleen kleine letters, cijfers en koppeltekens toegestaan',
|
||||
nameHyphenError: 'Kan niet beginnen of eindigen met een koppelteken',
|
||||
nameReserved: 'Deze naam is gereserveerd',
|
||||
checkingAvailability: 'Beschikbaarheid controleren...',
|
||||
nameAvailable: '✓ Naam is beschikbaar!',
|
||||
nameNotAvailable: 'Naam is niet beschikbaar',
|
||||
checkFailed: 'Controle mislukt',
|
||||
connectionLost: 'Verbinding verbroken. Ververs de pagina en probeer opnieuw.',
|
||||
deployingText: 'Implementeren...'
|
||||
},
|
||||
ar: {
|
||||
title: 'AI Stack Deployer',
|
||||
subtitle: 'انشر مساعد البرمجة الذكي الخاص بك في ثوانٍ',
|
||||
chooseStackName: 'اختر اسم المشروع',
|
||||
availableAt: 'سيكون مساعدك الذكي متاحًا على',
|
||||
stackName: 'اسم المشروع',
|
||||
placeholder: 'مثال: أحمد-dev',
|
||||
inputHint: '3-20 حرف، أحرف صغيرة وأرقام وشرطات فقط',
|
||||
deployBtn: 'انشر مشروعي',
|
||||
deploying: 'جاري النشر',
|
||||
stack: 'المشروع',
|
||||
initializing: 'جاري التهيئة...',
|
||||
successMessage: 'مساعد البرمجة الذكي جاهز للاستخدام',
|
||||
stackNameLabel: 'اسم المشروع:',
|
||||
openStack: 'افتح مشروعي',
|
||||
deployAnother: 'انشر مشروع آخر',
|
||||
tryAgain: 'حاول مرة أخرى',
|
||||
poweredBy: 'مدعوم من',
|
||||
deploymentComplete: 'تم النشر بنجاح',
|
||||
deploymentFailed: 'فشل النشر',
|
||||
nameRequired: 'الاسم مطلوب',
|
||||
nameLengthError: 'يجب أن يكون الاسم بين 3 و 20 حرفًا',
|
||||
nameCharsError: 'يُسمح فقط بالأحرف الصغيرة والأرقام والشرطات',
|
||||
nameHyphenError: 'لا يمكن أن يبدأ أو ينتهي بشرطة',
|
||||
nameReserved: 'هذا الاسم محجوز',
|
||||
checkingAvailability: 'جاري التحقق...',
|
||||
nameAvailable: '✓ الاسم متاح!',
|
||||
nameNotAvailable: 'الاسم غير متاح',
|
||||
checkFailed: 'فشل التحقق',
|
||||
connectionLost: 'انقطع الاتصال. يرجى تحديث الصفحة والمحاولة مرة أخرى.',
|
||||
deployingText: 'جاري النشر...'
|
||||
}
|
||||
};
|
||||
|
||||
let currentLang = 'en';
|
||||
|
||||
function detectLanguage() {
|
||||
const browserLang = navigator.language || navigator.userLanguage;
|
||||
const lang = browserLang.split('-')[0].toLowerCase();
|
||||
|
||||
if (translations[lang]) {
|
||||
return lang;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
return translations[currentLang][key] || translations['en'][key] || key;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!translations[lang]) return;
|
||||
|
||||
currentLang = lang;
|
||||
localStorage.setItem('preferredLanguage', lang);
|
||||
|
||||
document.documentElement.lang = lang;
|
||||
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.getAttribute('data-lang') === lang) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
const typewriterTarget = document.getElementById('typewriter-target');
|
||||
if (typewriterTarget && currentState === STATE.FORM) {
|
||||
typewriter(typewriterTarget, t('chooseStackName'));
|
||||
}
|
||||
}
|
||||
|
||||
function initLanguage() {
|
||||
const saved = localStorage.getItem('preferredLanguage');
|
||||
const lang = saved || detectLanguage();
|
||||
setLanguage(lang);
|
||||
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setLanguage(btn.getAttribute('data-lang'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function typewriter(element, text, speed = 50) {
|
||||
let i = 0;
|
||||
element.innerHTML = '';
|
||||
|
||||
const cursor = document.createElement('span');
|
||||
cursor.className = 'typing-cursor';
|
||||
|
||||
const existingCursor = element.parentNode.querySelector('.typing-cursor');
|
||||
if (existingCursor) {
|
||||
existingCursor.remove();
|
||||
}
|
||||
|
||||
element.parentNode.insertBefore(cursor, element.nextSibling);
|
||||
|
||||
function type() {
|
||||
if (i < text.length) {
|
||||
element.textContent += text.charAt(i);
|
||||
i++;
|
||||
setTimeout(type, speed);
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
type();
|
||||
}
|
||||
|
||||
// State Machine for Deployment
|
||||
const STATE = {
|
||||
FORM: 'form',
|
||||
@@ -41,50 +226,60 @@ const tryAgainBtn = document.getElementById('try-again-btn');
|
||||
function setState(newState) {
|
||||
currentState = newState;
|
||||
|
||||
formState.style.display = 'none';
|
||||
progressState.style.display = 'none';
|
||||
successState.style.display = 'none';
|
||||
errorState.style.display = 'none';
|
||||
const states = [formState, progressState, successState, errorState];
|
||||
|
||||
states.forEach(state => {
|
||||
state.style.display = 'none';
|
||||
state.classList.remove('fade-in');
|
||||
});
|
||||
|
||||
let activeState;
|
||||
switch (newState) {
|
||||
case STATE.FORM:
|
||||
formState.style.display = 'block';
|
||||
activeState = formState;
|
||||
break;
|
||||
case STATE.PROGRESS:
|
||||
progressState.style.display = 'block';
|
||||
activeState = progressState;
|
||||
break;
|
||||
case STATE.SUCCESS:
|
||||
successState.style.display = 'block';
|
||||
activeState = successState;
|
||||
break;
|
||||
case STATE.ERROR:
|
||||
errorState.style.display = 'block';
|
||||
activeState = errorState;
|
||||
break;
|
||||
}
|
||||
|
||||
if (activeState) {
|
||||
activeState.style.display = 'block';
|
||||
// Add a slight delay to ensure the display property has taken effect before adding the class
|
||||
setTimeout(() => {
|
||||
activeState.classList.add('fade-in');
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Name Validation
|
||||
function validateName(name) {
|
||||
if (!name) {
|
||||
return { valid: false, error: 'Name is required' };
|
||||
return { valid: false, error: t('nameRequired') };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim().toLowerCase();
|
||||
|
||||
if (trimmedName.length < 3 || trimmedName.length > 20) {
|
||||
return { valid: false, error: 'Name must be between 3 and 20 characters' };
|
||||
return { valid: false, error: t('nameLengthError') };
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
|
||||
return { valid: false, error: 'Only lowercase letters, numbers, and hyphens allowed' };
|
||||
return { valid: false, error: t('nameCharsError') };
|
||||
}
|
||||
|
||||
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
|
||||
return { valid: false, error: 'Cannot start or end with a hyphen' };
|
||||
return { valid: false, error: t('nameHyphenError') };
|
||||
}
|
||||
|
||||
const reservedNames = ['admin', 'api', 'www', 'root', 'system', 'test', 'demo', 'portal'];
|
||||
if (reservedNames.includes(trimmedName)) {
|
||||
return { valid: false, error: 'This name is reserved' };
|
||||
return { valid: false, error: t('nameReserved') };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
@@ -114,9 +309,8 @@ stackNameInput.addEventListener('input', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability with debounce
|
||||
stackNameInput.classList.remove('error', 'success');
|
||||
validationMessage.textContent = 'Checking availability...';
|
||||
validationMessage.textContent = t('checkingAvailability');
|
||||
validationMessage.className = 'validation-message';
|
||||
|
||||
checkTimeout = setTimeout(async () => {
|
||||
@@ -126,18 +320,18 @@ stackNameInput.addEventListener('input', (e) => {
|
||||
|
||||
if (data.available && data.valid) {
|
||||
stackNameInput.classList.add('success');
|
||||
validationMessage.textContent = '✓ Name is available!';
|
||||
validationMessage.textContent = t('nameAvailable');
|
||||
validationMessage.className = 'validation-message success';
|
||||
deployBtn.disabled = false;
|
||||
} else {
|
||||
stackNameInput.classList.add('error');
|
||||
validationMessage.textContent = data.error || 'Name is not available';
|
||||
validationMessage.textContent = data.error || t('nameNotAvailable');
|
||||
validationMessage.className = 'validation-message error';
|
||||
deployBtn.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check name availability:', error);
|
||||
validationMessage.textContent = 'Failed to check availability';
|
||||
validationMessage.textContent = t('checkFailed');
|
||||
validationMessage.className = 'validation-message error';
|
||||
deployBtn.disabled = true;
|
||||
}
|
||||
@@ -154,7 +348,7 @@ deployForm.addEventListener('submit', async (e) => {
|
||||
}
|
||||
|
||||
deployBtn.disabled = true;
|
||||
deployBtn.innerHTML = '<span class="btn-text">Deploying...</span>';
|
||||
deployBtn.innerHTML = `<span class="btn-text">${t('deployingText')}</span>`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/deploy', {
|
||||
@@ -191,7 +385,7 @@ deployForm.addEventListener('submit', async (e) => {
|
||||
showError(error.message);
|
||||
deployBtn.disabled = false;
|
||||
deployBtn.innerHTML = `
|
||||
<span class="btn-text">Deploy My AI Stack</span>
|
||||
<span class="btn-text" data-i18n="deployBtn">${t('deployBtn')}</span>
|
||||
<svg class="btn-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4V16M4 10H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -222,7 +416,7 @@ function startProgressStream(deploymentId) {
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
showError('Connection lost. Please refresh and try again.');
|
||||
showError(t('connectionLost'));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,12 +437,12 @@ function updateProgress(data) {
|
||||
|
||||
// Add to log
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = 'log-entry';
|
||||
logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${data.currentStep}`;
|
||||
progressLog.appendChild(logEntry);
|
||||
progressLog.scrollTop = progressLog.scrollHeight;
|
||||
}
|
||||
|
||||
// Show Success
|
||||
function showSuccess(data) {
|
||||
successName.textContent = deployingName.textContent;
|
||||
successUrl.textContent = deploymentUrl;
|
||||
@@ -256,15 +450,21 @@ function showSuccess(data) {
|
||||
openStackBtn.href = deploymentUrl;
|
||||
|
||||
setState(STATE.SUCCESS);
|
||||
const targetSpan = document.getElementById('success-title');
|
||||
if(targetSpan) {
|
||||
typewriter(targetSpan, t('deploymentComplete'));
|
||||
}
|
||||
}
|
||||
|
||||
// Show Error
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
setState(STATE.ERROR);
|
||||
const targetSpan = document.getElementById('error-title');
|
||||
if(targetSpan) {
|
||||
typewriter(targetSpan, t('deploymentFailed'), 30);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to Form
|
||||
function resetToForm() {
|
||||
deploymentId = null;
|
||||
deploymentUrl = null;
|
||||
@@ -279,19 +479,29 @@ function resetToForm() {
|
||||
|
||||
deployBtn.disabled = false;
|
||||
deployBtn.innerHTML = `
|
||||
<span class="btn-text">Deploy My AI Stack</span>
|
||||
<span class="btn-text" data-i18n="deployBtn">${t('deployBtn')}</span>
|
||||
<svg class="btn-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4V16M4 10H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
setState(STATE.FORM);
|
||||
const targetSpan = document.getElementById('typewriter-target');
|
||||
if(targetSpan) {
|
||||
typewriter(targetSpan, t('chooseStackName'));
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
deployAnotherBtn.addEventListener('click', resetToForm);
|
||||
tryAgainBtn.addEventListener('click', resetToForm);
|
||||
|
||||
// Initialize
|
||||
setState(STATE.FORM);
|
||||
console.log('AI Stack Deployer initialized');
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initLanguage();
|
||||
setState(STATE.FORM);
|
||||
const targetSpan = document.getElementById('typewriter-target');
|
||||
if(targetSpan) {
|
||||
typewriter(targetSpan, t('chooseStackName'));
|
||||
}
|
||||
console.log('AI Stack Deployer initialized');
|
||||
});
|
||||
|
||||
@@ -4,53 +4,50 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Stack Deployer - Deploy Your Personal OpenCode Assistant</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="language-selector">
|
||||
<button class="lang-btn" data-lang="nl" title="Nederlands">🇳🇱</button>
|
||||
<button class="lang-btn" data-lang="ar" title="العربية">🇲🇦</button>
|
||||
<button class="lang-btn" data-lang="en" title="English">🇬🇧</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="8" fill="url(#gradient)"/>
|
||||
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop offset="0%" stop-color="#667eea"/>
|
||||
<stop offset="100%" stop-color="#764ba2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>AI Stack Deployer</h1>
|
||||
<h1 data-i18n="title">AI Stack Deployer</h1>
|
||||
</div>
|
||||
<p class="subtitle">Deploy your personal OpenCode AI coding assistant in seconds</p>
|
||||
<p class="subtitle" data-i18n="subtitle">Deploy your personal OpenCode AI coding assistant in seconds</p>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
<!-- Form State -->
|
||||
<div id="form-state" class="card">
|
||||
<h2>Choose Your Stack Name</h2>
|
||||
<p class="info-text">Your AI assistant will be available at <strong><span id="preview-name">yourname</span>.ai.flexinit.nl</strong></p>
|
||||
<h2><span id="typewriter-target"></span></h2>
|
||||
<p class="info-text"><span data-i18n="availableAt">Your AI assistant will be available at</span> <strong><span id="preview-name">yourname</span>.ai.flexinit.nl</strong></p>
|
||||
|
||||
<form id="deploy-form">
|
||||
<div class="input-group">
|
||||
<label for="stack-name">Stack Name</label>
|
||||
<label for="stack-name" data-i18n="stackName">Stack Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="stack-name"
|
||||
name="name"
|
||||
data-i18n-placeholder="placeholder"
|
||||
placeholder="e.g., john-dev"
|
||||
pattern="[a-z0-9-]{3,20}"
|
||||
required
|
||||
autocomplete="off"
|
||||
>
|
||||
<div class="input-hint" id="name-hint">
|
||||
<div class="input-hint" id="name-hint" data-i18n="inputHint">
|
||||
3-20 characters, lowercase letters, numbers, and hyphens only
|
||||
</div>
|
||||
<div class="validation-message" id="validation-message"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="deploy-btn">
|
||||
<span class="btn-text">Deploy My AI Stack</span>
|
||||
<span class="btn-text" data-i18n="deployBtn">Deploy My AI Stack</span>
|
||||
<svg class="btn-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4V16M4 10H16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -61,12 +58,12 @@
|
||||
<!-- Deployment Progress State -->
|
||||
<div id="progress-state" class="card" style="display: none;">
|
||||
<div class="progress-header">
|
||||
<h2>Deploying Your Stack</h2>
|
||||
<h2 data-i18n="deploying">Deploying Your Stack</h2>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-info">
|
||||
<p>Stack: <strong id="deploying-name"></strong></p>
|
||||
<p><span data-i18n="stack">Stack</span>: <strong id="deploying-name"></strong></p>
|
||||
<p>URL: <strong id="deploying-url"></strong></p>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +77,7 @@
|
||||
<div class="progress-steps">
|
||||
<div class="step" id="step-0">
|
||||
<div class="step-icon">⏳</div>
|
||||
<div class="step-text" id="step-text-0">Initializing deployment...</div>
|
||||
<div class="step-text" id="step-text-0" data-i18n="initializing">Initializing deployment...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,18 +86,13 @@
|
||||
|
||||
<!-- Success State -->
|
||||
<div id="success-state" class="card success-card" style="display: none;">
|
||||
<div class="success-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
||||
<circle cx="40" cy="40" r="40" fill="#10b981"/>
|
||||
<path d="M25 40L35 50L55 30" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Stack Deployed Successfully!</h2>
|
||||
<p class="success-message">Your AI coding assistant is ready to use</p>
|
||||
<div class="success-icon">✓</div>
|
||||
<h2><span id="success-title"></span></h2>
|
||||
<p class="success-message" data-i18n="successMessage">Your AI coding assistant is ready to use</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Stack Name:</span>
|
||||
<span class="detail-label" data-i18n="stackNameLabel">Stack Name:</span>
|
||||
<span class="detail-value" id="success-name"></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
@@ -111,12 +103,12 @@
|
||||
|
||||
<div class="success-actions">
|
||||
<a href="#" target="_blank" class="btn btn-primary" id="open-stack-btn">
|
||||
Open My AI Stack
|
||||
<span data-i18n="openStack">Open My AI Stack</span>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4L16 10L10 16M16 10H4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button class="btn btn-secondary" id="deploy-another-btn">
|
||||
<button class="btn btn-secondary" id="deploy-another-btn" data-i18n="deployAnother">
|
||||
Deploy Another Stack
|
||||
</button>
|
||||
</div>
|
||||
@@ -124,26 +116,21 @@
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="error-state" class="card error-card" style="display: none;">
|
||||
<div class="error-icon">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
||||
<circle cx="40" cy="40" r="40" fill="#ef4444"/>
|
||||
<path d="M30 30L50 50M50 30L30 50" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Deployment Failed</h2>
|
||||
<div class="error-icon">✕</div>
|
||||
<h2><span id="error-title"></span></h2>
|
||||
<p class="error-message" id="error-message"></p>
|
||||
|
||||
<button class="btn btn-secondary" id="try-again-btn">
|
||||
<button class="btn btn-secondary" id="try-again-btn" data-i18n="tryAgain">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p>Powered by <a href="https://dokploy.com" target="_blank">Dokploy</a> • OpenCode AI Assistant</p>
|
||||
<p><span data-i18n="poweredBy">Powered by</span> <a href="https://flexinit.nl" target="_blank">FLEXINIT</a> • FlexAI Assistant</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--primary: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--secondary: #764ba2;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--text: #1f2937;
|
||||
--text-light: #6b7280;
|
||||
--bg: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--bg: #0A0A0A;
|
||||
--surface: #141414;
|
||||
--border: #2a2a2a;
|
||||
--text-primary: #EAEAEA;
|
||||
--text-secondary: #888888;
|
||||
--accent-blue: #33A3FF;
|
||||
--accent-cyan: #00E5FF;
|
||||
--success: #00C853;
|
||||
--error: #FF4D4D;
|
||||
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
--shadow-glow: 0 0 25px 0px rgba(0, 229, 255, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -19,19 +26,23 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -51,42 +62,46 @@ body {
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
color: var(--text-primary);
|
||||
min-height: 2.4rem; /* Reserve space for text */
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.info-text strong {
|
||||
color: var(--primary);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
@@ -96,25 +111,29 @@ body {
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border);
|
||||
border-radius: 0;
|
||||
font-size: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-family: var(--font-mono);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
border-bottom-color: var(--accent-blue);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type="text"].error {
|
||||
@@ -127,7 +146,7 @@ input[type="text"].success {
|
||||
|
||||
.input-hint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -153,7 +172,7 @@ input[type="text"].success {
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -163,13 +182,16 @@ input[type="text"].success {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
color: white;
|
||||
background: linear-gradient(90deg, var(--accent-blue) 0%, var(--accent-cyan) 100%);
|
||||
color: #0A0A0A;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 15px rgba(0, 229, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0 25px rgba(0, 229, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
@@ -177,21 +199,23 @@ input[type="text"].success {
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
background: var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 2px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: white;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--surface);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -211,20 +235,43 @@ input[type="text"].success {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
.card.fade-in {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 15px rgba(0, 229, 255, 0.2); }
|
||||
50% { box-shadow: 0 0 25px rgba(0, 229, 255, 0.5); }
|
||||
100% { box-shadow: 0 0 15px rgba(0, 229, 255, 0.2); }
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
background: var(--bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-info p {
|
||||
@@ -245,24 +292,25 @@ input[type="text"].success {
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
background: var(--accent-cyan);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
@@ -298,15 +346,20 @@ input[type="text"].success {
|
||||
.progress-log {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.progress-log .log-entry {
|
||||
animation: fadeInUp 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-log:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -319,6 +372,15 @@ input[type="text"].success {
|
||||
.success-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
animation: scaleIn 0.5s ease;
|
||||
font-size: 3rem;
|
||||
color: var(--success);
|
||||
border: 2px solid var(--success);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
@@ -333,16 +395,17 @@ input[type="text"].success {
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background: var(--bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
@@ -357,30 +420,29 @@ input[type="text"].success {
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 600;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
color: var(--primary);
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
.success-actions .btn-primary {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
@@ -391,6 +453,15 @@ input[type="text"].success {
|
||||
.error-icon {
|
||||
margin-bottom: 1.5rem;
|
||||
animation: shake 0.5s ease;
|
||||
font-size: 3rem;
|
||||
color: var(--error);
|
||||
border: 2px solid var(--error);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
@@ -400,12 +471,13 @@ input[type="text"].success {
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-light);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 8px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
background: rgba(255, 77, 77, 0.05);
|
||||
border: 1px solid rgba(255, 77, 77, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -446,3 +518,64 @@ input[type="text"].success {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 1.5rem;
|
||||
background-color: var(--accent-cyan);
|
||||
margin-left: 4px;
|
||||
animation: blink 1s steps(1) infinite;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.language-selector {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
border-color: var(--accent-blue);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
|
||||
[dir="rtl"] .typing-cursor {
|
||||
margin-left: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .detail-item {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
[dir="rtl"] .btn svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
[dir="rtl"] .language-selector {
|
||||
right: auto;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
66
src/index.ts
66
src/index.ts
@@ -86,8 +86,8 @@ async function deployStack(deploymentId: string): Promise<void> {
|
||||
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest',
|
||||
domainSuffix: process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl',
|
||||
port: 8080,
|
||||
healthCheckTimeout: 60000, // 60 seconds
|
||||
healthCheckInterval: 5000, // 5 seconds
|
||||
healthCheckTimeout: 180000,
|
||||
healthCheckInterval: 5000,
|
||||
});
|
||||
|
||||
// Final update with logs
|
||||
@@ -371,9 +371,66 @@ app.get('/api/check/:name', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/stack/:name', async (c) => {
|
||||
try {
|
||||
const name = c.req.param('name');
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const projectName = `ai-stack-${normalizedName}`;
|
||||
|
||||
const client = createProductionDokployClient();
|
||||
const existingProject = await client.findProjectByName(projectName);
|
||||
|
||||
if (!existingProject) {
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Stack not found',
|
||||
code: 'NOT_FOUND'
|
||||
}, 404);
|
||||
}
|
||||
|
||||
console.log(`Deleting stack: ${projectName} (projectId: ${existingProject.project.projectId})`);
|
||||
|
||||
await client.deleteProject(existingProject.project.projectId);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: `Stack ${normalizedName} deleted successfully`,
|
||||
deletedProjectId: existingProject.project.projectId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delete endpoint error:', error);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete stack',
|
||||
code: 'DELETE_FAILED'
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve static files (frontend)
|
||||
app.use('/static/*', serveStatic({ root: './src/frontend' }));
|
||||
app.use('/*', serveStatic({ root: './src/frontend', path: '/index.html' }));
|
||||
// Serve CSS and JS files directly
|
||||
app.get('/style.css', async (c) => {
|
||||
const file = Bun.file('./src/frontend/style.css');
|
||||
return new Response(file, {
|
||||
headers: { 'Content-Type': 'text/css' }
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/app.js', async (c) => {
|
||||
const file = Bun.file('./src/frontend/app.js');
|
||||
return new Response(file, {
|
||||
headers: { 'Content-Type': 'application/javascript' }
|
||||
});
|
||||
});
|
||||
|
||||
// Serve index.html for all other routes (SPA fallback)
|
||||
app.get('/', async (c) => {
|
||||
const file = Bun.file('./src/frontend/index.html');
|
||||
return new Response(file, {
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`🚀 AI Stack Deployer (Production) starting on http://${HOST}:${PORT}`);
|
||||
console.log(`✅ Production features enabled:`);
|
||||
@@ -387,4 +444,5 @@ export default {
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
fetch: app.fetch,
|
||||
idleTimeout: 255,
|
||||
};
|
||||
|
||||
@@ -266,7 +266,7 @@ export class ProductionDeployer {
|
||||
config: DeploymentConfig
|
||||
): Promise<void> {
|
||||
state.phase = 'configuring_application';
|
||||
state.progress = 55;
|
||||
state.progress = 50;
|
||||
state.message = 'Configuring application with Docker image';
|
||||
|
||||
if (!state.resources.applicationId) {
|
||||
@@ -278,7 +278,22 @@ export class ProductionDeployer {
|
||||
sourceType: 'docker',
|
||||
});
|
||||
|
||||
state.message = 'Application configured';
|
||||
state.progress = 55;
|
||||
state.message = 'Creating persistent storage';
|
||||
|
||||
const volumeName = `portal-ai-workspace-${config.stackName}`;
|
||||
try {
|
||||
await this.client.createMount(
|
||||
state.resources.applicationId,
|
||||
volumeName,
|
||||
'/workspace'
|
||||
);
|
||||
console.log(`Created persistent volume: ${volumeName}`);
|
||||
} catch (error) {
|
||||
console.warn(`Volume creation failed (may already exist): ${error}`);
|
||||
}
|
||||
|
||||
state.message = 'Application configured with storage';
|
||||
}
|
||||
|
||||
private async createOrFindDomain(
|
||||
@@ -340,24 +355,42 @@ export class ProductionDeployer {
|
||||
const interval = config.healthCheckInterval || 5000; // 5 seconds
|
||||
const startTime = Date.now();
|
||||
|
||||
// Try multiple endpoints - the container may not have /health
|
||||
const endpoints = ['/', '/health', '/api'];
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const healthUrl = `${state.url}/health`;
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const checkUrl = `${state.url}${endpoint}`;
|
||||
const response = await fetch(checkUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
state.message = 'Application is healthy';
|
||||
// Accept ANY HTTP response (even 404) as "server is alive"
|
||||
// Only connection errors mean the container isn't ready
|
||||
console.log(`Health check ${checkUrl} returned ${response.status}`);
|
||||
state.message = 'Application is responding';
|
||||
return;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// SSL cert errors mean server IS responding, just cert issue during provisioning
|
||||
if (errorMsg.includes('certificate') || errorMsg.includes('SSL') || errorMsg.includes('TLS')) {
|
||||
console.log(`Health check SSL error (treating as alive): ${errorMsg}`);
|
||||
state.message = 'Application is responding (SSL provisioning)';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Health check failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
console.log(`Health check returned ${response.status}, retrying...`);
|
||||
} catch (error) {
|
||||
console.log(`Health check failed: ${error instanceof Error ? error.message : String(error)}, retrying...`);
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
state.message = `Waiting for application to start (${elapsed}s)...`;
|
||||
this.notifyProgress(state);
|
||||
|
||||
await this.sleep(interval);
|
||||
}
|
||||
|
||||
@@ -370,10 +403,15 @@ export class ProductionDeployer {
|
||||
state.message = 'Rolling back deployment';
|
||||
|
||||
try {
|
||||
// Rollback in reverse order
|
||||
if (state.resources.domainId) {
|
||||
console.log(`Rolling back: deleting domain ${state.resources.domainId}`);
|
||||
try {
|
||||
await this.client.deleteDomain(state.resources.domainId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete domain during rollback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't delete domain as it might be reused
|
||||
// Delete application if created
|
||||
if (state.resources.applicationId) {
|
||||
console.log(`Rolling back: deleting application ${state.resources.applicationId}`);
|
||||
try {
|
||||
@@ -383,8 +421,14 @@ export class ProductionDeployer {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't delete the project as it might have other resources
|
||||
// or be reused in future deployments
|
||||
if (state.resources.projectId) {
|
||||
console.log(`Rolling back: deleting project ${state.resources.projectId}`);
|
||||
try {
|
||||
await this.client.deleteProject(state.resources.projectId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project during rollback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
state.message = 'Rollback completed';
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user