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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
66
src/index.ts
66
src/index.ts
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user