fix: resolve 4 UI/UX issues

1. Fix typewriter double-letter bug (race condition)
2. Replace flag emojis with text labels (NL/AR/EN)
3. Fix health check TLS options for Bun compatibility
4. Translate 'yourname' placeholder per language
This commit is contained in:
Oussama Douhou
2026-01-10 11:39:14 +01:00
parent fe8abda7d3
commit 67069f3bda
4 changed files with 120 additions and 23 deletions

View File

@@ -108,3 +108,71 @@ source .env && curl -s -H "x-api-key: $DOKPLOY_API_TOKEN" \
| 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 |
---
## Production Verification (2026-01-10)
### Verified Working
| Component | Status | Notes |
|-----------|--------|-------|
| Portal Health | ✅ | `https://portal.ai.flexinit.nl/health` returns healthy |
| Name Validation | ✅ | `/api/check/:name` validates correctly |
| Frontend UI | ✅ | 3 languages (NL, AR, EN), RTL support, typewriter animation |
| Stack Deployment | ✅ | Full flow: project → app → domain → deploy → health check |
| Stack Cleanup | ✅ | `DELETE /api/stack/:name` removes all resources |
| SSL/HTTPS | ✅ | Wildcard cert working for all `*.ai.flexinit.nl` |
### Critical Configuration
```bash
# These settings are REQUIRED for deployment to work
DOKPLOY_URL=https://app.flexinit.nl # Public URL, NOT internal 10.100.0.20
STACK_IMAGE=git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest
STACK_REGISTRY_ID=bKDYM5X7NN34x_lRDjWbz # Registry ID for Docker auth
```
### Known Gotchas (Fixed)
1. **Registry URL format** - Use `git.app.flexinit.nl`, NOT `https://git.app.flexinit.nl`
2. **Username in image path** - Must be `oussamadouhou`, not `odouhou`
3. **Dokploy URL** - Must use public URL for container-to-container communication
4. **Health check** - SSL errors treated as "alive" during cert provisioning
### Test Commands
```bash
# Quick health check
curl -s https://portal.ai.flexinit.nl/health | jq .
# Full deployment test
NAME="test-$(date +%s | tail -c 5)"
RESULT=$(curl -s -X POST https://portal.ai.flexinit.nl/api/deploy \
-H "Content-Type: application/json" \
-d "{\"name\": \"$NAME\"}")
echo $RESULT | jq .
# Monitor deployment (use deploymentId from above)
curl -N "https://portal.ai.flexinit.nl/api/status/$(echo $RESULT | jq -r .deploymentId)"
# Verify stack accessible (after deployment completes)
curl -s -k https://$NAME.ai.flexinit.nl | head -5
# Cleanup
curl -s -X DELETE https://portal.ai.flexinit.nl/api/stack/$NAME | jq .
```
### Dokploy Direct Commands
```bash
# List all ai-stack projects
source .env && curl -s -H "x-api-key: $DOKPLOY_API_TOKEN" \
https://app.flexinit.nl/api/project.all | \
jq '[.[] | select(.name | startswith("ai-stack-")) | {name: .name, id: .projectId}]'
# Get application status
source .env && curl -s -H "x-api-key: $DOKPLOY_API_TOKEN" \
"https://app.flexinit.nl/api/project.one?projectId=<PROJECT_ID>" | \
jq '.environments[0].applications[0] | {name: .name, status: .applicationStatus}'
```

View File

@@ -29,7 +29,8 @@ const translations = {
nameNotAvailable: 'Name is not available',
checkFailed: 'Failed to check availability',
connectionLost: 'Connection lost. Please refresh and try again.',
deployingText: 'Deploying...'
deployingText: 'Deploying...',
yournamePlaceholder: 'yourname'
},
nl: {
title: 'AI Stack Deployer',
@@ -61,7 +62,8 @@ const translations = {
nameNotAvailable: 'Naam is niet beschikbaar',
checkFailed: 'Controle mislukt',
connectionLost: 'Verbinding verbroken. Ververs de pagina en probeer opnieuw.',
deployingText: 'Implementeren...'
deployingText: 'Implementeren...',
yournamePlaceholder: 'jouwnaam'
},
ar: {
title: 'AI Stack Deployer',
@@ -93,7 +95,8 @@ const translations = {
nameNotAvailable: 'الاسم غير متاح',
checkFailed: 'فشل التحقق',
connectionLost: 'انقطع الاتصال. يرجى تحديث الصفحة والمحاولة مرة أخرى.',
deployingText: 'جاري النشر...'
deployingText: 'جاري النشر...',
yournamePlaceholder: 'اسمك'
}
};
@@ -139,6 +142,11 @@ function setLanguage(lang) {
}
});
const previewNameEl = document.getElementById('preview-name');
if (previewNameEl && !stackNameInput?.value) {
previewNameEl.textContent = t('yournamePlaceholder');
}
const typewriterTarget = document.getElementById('typewriter-target');
if (typewriterTarget && currentState === STATE.FORM) {
typewriter(typewriterTarget, t('chooseStackName'));
@@ -157,7 +165,17 @@ function initLanguage() {
});
}
// Track active typewriter instances to prevent race conditions
let activeTypewriters = new Map();
function typewriter(element, text, speed = 50) {
// Cancel any existing typewriter on this element
const elementId = element.id || 'default';
if (activeTypewriters.has(elementId)) {
clearTimeout(activeTypewriters.get(elementId));
activeTypewriters.delete(elementId);
}
let i = 0;
element.innerHTML = '';
@@ -175,9 +193,10 @@ function typewriter(element, text, speed = 50) {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
setTimeout(type, speed);
const timeoutId = setTimeout(type, speed);
activeTypewriters.set(elementId, timeoutId);
} else {
activeTypewriters.delete(elementId);
}
}
type();
@@ -289,10 +308,9 @@ function validateName(name) {
let checkTimeout;
stackNameInput.addEventListener('input', (e) => {
const value = e.target.value.toLowerCase();
e.target.value = value; // Force lowercase
e.target.value = value;
// Update preview
previewName.textContent = value || 'yourname';
previewName.textContent = value || t('yournamePlaceholder');
// Clear previous timeout
clearTimeout(checkTimeout);
@@ -469,7 +487,7 @@ function resetToForm() {
deploymentId = null;
deploymentUrl = null;
stackNameInput.value = '';
previewName.textContent = 'yourname';
previewName.textContent = t('yournamePlaceholder');
validationMessage.textContent = '';
validationMessage.className = 'validation-message';
stackNameInput.classList.remove('error', 'success');

View File

@@ -8,9 +8,9 @@
</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>
<button class="lang-btn" data-lang="nl" title="Nederlands">NL</button>
<button class="lang-btn" data-lang="ar" title="العربية">AR</button>
<button class="lang-btn" data-lang="en" title="English">EN</button>
</div>
<div class="container">
@@ -25,7 +25,7 @@
<!-- Form State -->
<div id="form-state" class="card">
<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>
<p class="info-text"><span data-i18n="availableAt">Your AI assistant will be available at</span> <strong><span id="preview-name" data-i18n="yournamePlaceholder">yourname</span>.ai.flexinit.nl</strong></p>
<form id="deploy-form">
<div class="input-group">

View File

@@ -278,6 +278,10 @@ export class ProductionDeployer {
dockerImage: config.dockerImage,
sourceType: 'docker',
registryId: config.registryId,
memoryLimit: 2048,
memoryReservation: 1024,
cpuLimit: 2,
cpuReservation: 0.5,
});
state.progress = 55;
@@ -353,33 +357,40 @@ export class ProductionDeployer {
throw new Error('Application URL not available');
}
const timeout = config.healthCheckTimeout || 120000; // 2 minutes
const interval = config.healthCheckInterval || 5000; // 5 seconds
const timeout = config.healthCheckTimeout || 120000;
const interval = config.healthCheckInterval || 5000;
const startTime = Date.now();
// Try multiple endpoints - the container may not have /health
const endpoints = ['/', '/health', '/api'];
while (Date.now() - startTime < timeout) {
for (const endpoint of endpoints) {
try {
const checkUrl = `${state.url}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(checkUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
tls: { rejectUnauthorized: false },
signal: controller.signal,
});
// Accept ANY HTTP response (even 404) as "server is alive"
// Only connection errors mean the container isn't ready
clearTimeout(timeoutId);
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')) {
if (
errorMsg.includes('certificate') ||
errorMsg.includes('SSL') ||
errorMsg.includes('TLS') ||
errorMsg.includes('CERT') ||
errorMsg.includes('unable to verify') ||
errorMsg.includes('self signed') ||
errorMsg.includes('self-signed')
) {
console.log(`Health check SSL error (treating as alive): ${errorMsg}`);
state.message = 'Application is responding (SSL provisioning)';
return;