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

@@ -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);
// SSL cert errors mean server IS responding, just cert issue during provisioning
if (errorMsg.includes('certificate') || errorMsg.includes('SSL') || errorMsg.includes('TLS')) {
console.log(`Health check SSL error (treating as alive): ${errorMsg}`);
state.message = 'Application is responding (SSL provisioning)';
return;
}
console.log(`Health check failed: ${errorMsg}`);
}
console.log(`Health check returned ${response.status}, retrying...`);
} catch (error) {
console.log(`Health check failed: ${error instanceof Error ? error.message : String(error)}, retrying...`);
}
const elapsed = Math.round((Date.now() - startTime) / 1000);
state.message = `Waiting for application to start (${elapsed}s)...`;
this.notifyProgress(state);
await this.sleep(interval);
}
@@ -370,10 +403,15 @@ export class ProductionDeployer {
state.message = 'Rolling back deployment';
try {
// Rollback in reverse order
if (state.resources.domainId) {
console.log(`Rolling back: deleting domain ${state.resources.domainId}`);
try {
await this.client.deleteDomain(state.resources.domainId);
} catch (error) {
console.error('Failed to delete domain during rollback:', error);
}
}
// Note: We don't delete domain as it might be reused
// Delete application if created
if (state.resources.applicationId) {
console.log(`Rolling back: deleting application ${state.resources.applicationId}`);
try {
@@ -383,8 +421,14 @@ export class ProductionDeployer {
}
}
// Note: We don't delete the project as it might have other resources
// or be reused in future deployments
if (state.resources.projectId) {
console.log(`Rolling back: deleting project ${state.resources.projectId}`);
try {
await this.client.deleteProject(state.resources.projectId);
} catch (error) {
console.error('Failed to delete project during rollback:', error);
}
}
state.message = 'Rollback completed';
} catch (error) {