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:
Oussama Douhou
2026-01-10 09:56:33 +01:00
parent eb6d5142ca
commit 2f306f7d68
10 changed files with 1196 additions and 462 deletions

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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 |

View File

@@ -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();
}

View File

@@ -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');
});

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
};

View File

@@ -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);
console.log(`Health check returned ${response.status}, retrying...`);
} catch (error) {
console.log(`Health check failed: ${error instanceof Error ? error.message : String(error)}, retrying...`);
// 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}`);
}
}
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) {