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 |
|
||||
| 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}'
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user