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:
@@ -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 |
|
| 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 |
|
| SSL cert errors | Health check treats SSL errors as "alive" during provisioning |
|
||||||
| SSE disconnects | idleTimeout set to 255s (max), long deployments should complete |
|
| 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}'
|
||||||
|
```
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ const translations = {
|
|||||||
nameNotAvailable: 'Name is not available',
|
nameNotAvailable: 'Name is not available',
|
||||||
checkFailed: 'Failed to check availability',
|
checkFailed: 'Failed to check availability',
|
||||||
connectionLost: 'Connection lost. Please refresh and try again.',
|
connectionLost: 'Connection lost. Please refresh and try again.',
|
||||||
deployingText: 'Deploying...'
|
deployingText: 'Deploying...',
|
||||||
|
yournamePlaceholder: 'yourname'
|
||||||
},
|
},
|
||||||
nl: {
|
nl: {
|
||||||
title: 'AI Stack Deployer',
|
title: 'AI Stack Deployer',
|
||||||
@@ -61,7 +62,8 @@ const translations = {
|
|||||||
nameNotAvailable: 'Naam is niet beschikbaar',
|
nameNotAvailable: 'Naam is niet beschikbaar',
|
||||||
checkFailed: 'Controle mislukt',
|
checkFailed: 'Controle mislukt',
|
||||||
connectionLost: 'Verbinding verbroken. Ververs de pagina en probeer opnieuw.',
|
connectionLost: 'Verbinding verbroken. Ververs de pagina en probeer opnieuw.',
|
||||||
deployingText: 'Implementeren...'
|
deployingText: 'Implementeren...',
|
||||||
|
yournamePlaceholder: 'jouwnaam'
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'AI Stack Deployer',
|
title: 'AI Stack Deployer',
|
||||||
@@ -93,7 +95,8 @@ const translations = {
|
|||||||
nameNotAvailable: 'الاسم غير متاح',
|
nameNotAvailable: 'الاسم غير متاح',
|
||||||
checkFailed: 'فشل التحقق',
|
checkFailed: 'فشل التحقق',
|
||||||
connectionLost: 'انقطع الاتصال. يرجى تحديث الصفحة والمحاولة مرة أخرى.',
|
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');
|
const typewriterTarget = document.getElementById('typewriter-target');
|
||||||
if (typewriterTarget && currentState === STATE.FORM) {
|
if (typewriterTarget && currentState === STATE.FORM) {
|
||||||
typewriter(typewriterTarget, t('chooseStackName'));
|
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) {
|
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;
|
let i = 0;
|
||||||
element.innerHTML = '';
|
element.innerHTML = '';
|
||||||
|
|
||||||
@@ -175,9 +193,10 @@ function typewriter(element, text, speed = 50) {
|
|||||||
if (i < text.length) {
|
if (i < text.length) {
|
||||||
element.textContent += text.charAt(i);
|
element.textContent += text.charAt(i);
|
||||||
i++;
|
i++;
|
||||||
setTimeout(type, speed);
|
const timeoutId = setTimeout(type, speed);
|
||||||
|
activeTypewriters.set(elementId, timeoutId);
|
||||||
} else {
|
} else {
|
||||||
|
activeTypewriters.delete(elementId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type();
|
type();
|
||||||
@@ -289,10 +308,9 @@ function validateName(name) {
|
|||||||
let checkTimeout;
|
let checkTimeout;
|
||||||
stackNameInput.addEventListener('input', (e) => {
|
stackNameInput.addEventListener('input', (e) => {
|
||||||
const value = e.target.value.toLowerCase();
|
const value = e.target.value.toLowerCase();
|
||||||
e.target.value = value; // Force lowercase
|
e.target.value = value;
|
||||||
|
|
||||||
// Update preview
|
previewName.textContent = value || t('yournamePlaceholder');
|
||||||
previewName.textContent = value || 'yourname';
|
|
||||||
|
|
||||||
// Clear previous timeout
|
// Clear previous timeout
|
||||||
clearTimeout(checkTimeout);
|
clearTimeout(checkTimeout);
|
||||||
@@ -469,7 +487,7 @@ function resetToForm() {
|
|||||||
deploymentId = null;
|
deploymentId = null;
|
||||||
deploymentUrl = null;
|
deploymentUrl = null;
|
||||||
stackNameInput.value = '';
|
stackNameInput.value = '';
|
||||||
previewName.textContent = 'yourname';
|
previewName.textContent = t('yournamePlaceholder');
|
||||||
validationMessage.textContent = '';
|
validationMessage.textContent = '';
|
||||||
validationMessage.className = 'validation-message';
|
validationMessage.className = 'validation-message';
|
||||||
stackNameInput.classList.remove('error', 'success');
|
stackNameInput.classList.remove('error', 'success');
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="language-selector">
|
<div class="language-selector">
|
||||||
<button class="lang-btn" data-lang="nl" title="Nederlands">🇳🇱</button>
|
<button class="lang-btn" data-lang="nl" title="Nederlands">NL</button>
|
||||||
<button class="lang-btn" data-lang="ar" title="العربية">🇲🇦</button>
|
<button class="lang-btn" data-lang="ar" title="العربية">AR</button>
|
||||||
<button class="lang-btn" data-lang="en" title="English">🇬🇧</button>
|
<button class="lang-btn" data-lang="en" title="English">EN</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<!-- Form State -->
|
<!-- Form State -->
|
||||||
<div id="form-state" class="card">
|
<div id="form-state" class="card">
|
||||||
<h2><span id="typewriter-target"></span></h2>
|
<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">
|
<form id="deploy-form">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
|
|||||||
@@ -278,6 +278,10 @@ export class ProductionDeployer {
|
|||||||
dockerImage: config.dockerImage,
|
dockerImage: config.dockerImage,
|
||||||
sourceType: 'docker',
|
sourceType: 'docker',
|
||||||
registryId: config.registryId,
|
registryId: config.registryId,
|
||||||
|
memoryLimit: 2048,
|
||||||
|
memoryReservation: 1024,
|
||||||
|
cpuLimit: 2,
|
||||||
|
cpuReservation: 0.5,
|
||||||
});
|
});
|
||||||
|
|
||||||
state.progress = 55;
|
state.progress = 55;
|
||||||
@@ -353,33 +357,40 @@ export class ProductionDeployer {
|
|||||||
throw new Error('Application URL not available');
|
throw new Error('Application URL not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = config.healthCheckTimeout || 120000; // 2 minutes
|
const timeout = config.healthCheckTimeout || 120000;
|
||||||
const interval = config.healthCheckInterval || 5000; // 5 seconds
|
const interval = config.healthCheckInterval || 5000;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
// Try multiple endpoints - the container may not have /health
|
|
||||||
const endpoints = ['/', '/health', '/api'];
|
const endpoints = ['/', '/health', '/api'];
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
while (Date.now() - startTime < timeout) {
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
try {
|
try {
|
||||||
const checkUrl = `${state.url}${endpoint}`;
|
const checkUrl = `${state.url}${endpoint}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
const response = await fetch(checkUrl, {
|
const response = await fetch(checkUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: controller.signal,
|
||||||
tls: { rejectUnauthorized: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Accept ANY HTTP response (even 404) as "server is alive"
|
clearTimeout(timeoutId);
|
||||||
// Only connection errors mean the container isn't ready
|
|
||||||
console.log(`Health check ${checkUrl} returned ${response.status}`);
|
console.log(`Health check ${checkUrl} returned ${response.status}`);
|
||||||
state.message = 'Application is responding';
|
state.message = 'Application is responding';
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
// SSL cert errors mean server IS responding, just cert issue during provisioning
|
if (
|
||||||
if (errorMsg.includes('certificate') || errorMsg.includes('SSL') || errorMsg.includes('TLS')) {
|
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}`);
|
console.log(`Health check SSL error (treating as alive): ${errorMsg}`);
|
||||||
state.message = 'Application is responding (SSL provisioning)';
|
state.message = 'Application is responding (SSL provisioning)';
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user