fix(ci): trigger workflow on main branch to enable :latest tag
Changes:
- Create Gitea workflow for ai-stack-deployer
- Trigger on main branch (default branch)
- Use oussamadouhou + REGISTRY_TOKEN for authentication
- Build from ./Dockerfile
This enables :latest tag creation via {{is_default_branch}}.
Tags created:
- git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest
- git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:<sha>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
297
src/frontend/app.js
Normal file
297
src/frontend/app.js
Normal file
@@ -0,0 +1,297 @@
|
||||
// State Machine for Deployment
|
||||
const STATE = {
|
||||
FORM: 'form',
|
||||
PROGRESS: 'progress',
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error'
|
||||
};
|
||||
|
||||
let currentState = STATE.FORM;
|
||||
let deploymentId = null;
|
||||
let deploymentUrl = null;
|
||||
let eventSource = null;
|
||||
|
||||
// DOM Elements
|
||||
const formState = document.getElementById('form-state');
|
||||
const progressState = document.getElementById('progress-state');
|
||||
const successState = document.getElementById('success-state');
|
||||
const errorState = document.getElementById('error-state');
|
||||
|
||||
const deployForm = document.getElementById('deploy-form');
|
||||
const stackNameInput = document.getElementById('stack-name');
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const validationMessage = document.getElementById('validation-message');
|
||||
const previewName = document.getElementById('preview-name');
|
||||
|
||||
const progressBar = document.getElementById('progress-fill');
|
||||
const progressPercent = document.getElementById('progress-percent');
|
||||
const deployingName = document.getElementById('deploying-name');
|
||||
const deployingUrl = document.getElementById('deploying-url');
|
||||
const progressLog = document.getElementById('progress-log');
|
||||
|
||||
const successName = document.getElementById('success-name');
|
||||
const successUrl = document.getElementById('success-url');
|
||||
const openStackBtn = document.getElementById('open-stack-btn');
|
||||
const deployAnotherBtn = document.getElementById('deploy-another-btn');
|
||||
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const tryAgainBtn = document.getElementById('try-again-btn');
|
||||
|
||||
// State Management
|
||||
function setState(newState) {
|
||||
currentState = newState;
|
||||
|
||||
formState.style.display = 'none';
|
||||
progressState.style.display = 'none';
|
||||
successState.style.display = 'none';
|
||||
errorState.style.display = 'none';
|
||||
|
||||
switch (newState) {
|
||||
case STATE.FORM:
|
||||
formState.style.display = 'block';
|
||||
break;
|
||||
case STATE.PROGRESS:
|
||||
progressState.style.display = 'block';
|
||||
break;
|
||||
case STATE.SUCCESS:
|
||||
successState.style.display = 'block';
|
||||
break;
|
||||
case STATE.ERROR:
|
||||
errorState.style.display = 'block';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Name Validation
|
||||
function validateName(name) {
|
||||
if (!name) {
|
||||
return { valid: false, error: 'Name is required' };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim().toLowerCase();
|
||||
|
||||
if (trimmedName.length < 3 || trimmedName.length > 20) {
|
||||
return { valid: false, error: 'Name must be between 3 and 20 characters' };
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
|
||||
return { valid: false, error: 'Only lowercase letters, numbers, and hyphens allowed' };
|
||||
}
|
||||
|
||||
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
|
||||
return { valid: false, error: 'Cannot start or end with a hyphen' };
|
||||
}
|
||||
|
||||
const reservedNames = ['admin', 'api', 'www', 'root', 'system', 'test', 'demo', 'portal'];
|
||||
if (reservedNames.includes(trimmedName)) {
|
||||
return { valid: false, error: 'This name is reserved' };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
}
|
||||
|
||||
// Real-time Name Validation
|
||||
let checkTimeout;
|
||||
stackNameInput.addEventListener('input', (e) => {
|
||||
const value = e.target.value.toLowerCase();
|
||||
e.target.value = value; // Force lowercase
|
||||
|
||||
// Update preview
|
||||
previewName.textContent = value || 'yourname';
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(checkTimeout);
|
||||
|
||||
// Validate format first
|
||||
const validation = validateName(value);
|
||||
|
||||
if (!validation.valid) {
|
||||
stackNameInput.classList.remove('success');
|
||||
stackNameInput.classList.add('error');
|
||||
validationMessage.textContent = validation.error;
|
||||
validationMessage.className = 'validation-message error';
|
||||
deployBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability with debounce
|
||||
stackNameInput.classList.remove('error', 'success');
|
||||
validationMessage.textContent = 'Checking availability...';
|
||||
validationMessage.className = 'validation-message';
|
||||
|
||||
checkTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/check/${validation.name}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.available && data.valid) {
|
||||
stackNameInput.classList.add('success');
|
||||
validationMessage.textContent = '✓ Name is available!';
|
||||
validationMessage.className = 'validation-message success';
|
||||
deployBtn.disabled = false;
|
||||
} else {
|
||||
stackNameInput.classList.add('error');
|
||||
validationMessage.textContent = data.error || 'Name is not available';
|
||||
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.className = 'validation-message error';
|
||||
deployBtn.disabled = true;
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Form Submission
|
||||
deployForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validation = validateName(stackNameInput.value);
|
||||
if (!validation.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
deployBtn.disabled = true;
|
||||
deployBtn.innerHTML = '<span class="btn-text">Deploying...</span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: validation.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Deployment failed');
|
||||
}
|
||||
|
||||
deploymentId = data.deploymentId;
|
||||
deploymentUrl = data.url;
|
||||
|
||||
// Update progress UI
|
||||
deployingName.textContent = validation.name;
|
||||
deployingUrl.textContent = deploymentUrl;
|
||||
|
||||
// Switch to progress state
|
||||
setState(STATE.PROGRESS);
|
||||
|
||||
// Start SSE connection
|
||||
startProgressStream(deploymentId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Deployment error:', error);
|
||||
showError(error.message);
|
||||
deployBtn.disabled = false;
|
||||
deployBtn.innerHTML = `
|
||||
<span class="btn-text">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>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// SSE Progress Streaming
|
||||
function startProgressStream(deploymentId) {
|
||||
eventSource = new EventSource(`/api/status/${deploymentId}`);
|
||||
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
updateProgress(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('complete', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
eventSource.close();
|
||||
showSuccess(data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
const data = event.data ? JSON.parse(event.data) : { message: 'Unknown error' };
|
||||
eventSource.close();
|
||||
showError(data.message);
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
showError('Connection lost. Please refresh and try again.');
|
||||
};
|
||||
}
|
||||
|
||||
// Update Progress UI
|
||||
function updateProgress(data) {
|
||||
// Update progress bar
|
||||
progressBar.style.width = `${data.progress}%`;
|
||||
progressPercent.textContent = `${data.progress}%`;
|
||||
|
||||
// Update current step
|
||||
const stepContainer = document.querySelector('.progress-steps');
|
||||
stepContainer.innerHTML = `
|
||||
<div class="step active">
|
||||
<div class="step-icon">⚙️</div>
|
||||
<div class="step-text">${data.currentStep}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add to log
|
||||
const logEntry = document.createElement('div');
|
||||
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;
|
||||
successUrl.href = deploymentUrl;
|
||||
openStackBtn.href = deploymentUrl;
|
||||
|
||||
setState(STATE.SUCCESS);
|
||||
}
|
||||
|
||||
// Show Error
|
||||
function showError(message) {
|
||||
errorMessage.textContent = message;
|
||||
setState(STATE.ERROR);
|
||||
}
|
||||
|
||||
// Reset to Form
|
||||
function resetToForm() {
|
||||
deploymentId = null;
|
||||
deploymentUrl = null;
|
||||
stackNameInput.value = '';
|
||||
previewName.textContent = 'yourname';
|
||||
validationMessage.textContent = '';
|
||||
validationMessage.className = 'validation-message';
|
||||
stackNameInput.classList.remove('error', 'success');
|
||||
progressLog.innerHTML = '';
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.textContent = '0%';
|
||||
|
||||
deployBtn.disabled = false;
|
||||
deployBtn.innerHTML = `
|
||||
<span class="btn-text">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>
|
||||
`;
|
||||
|
||||
setState(STATE.FORM);
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
deployAnotherBtn.addEventListener('click', resetToForm);
|
||||
tryAgainBtn.addEventListener('click', resetToForm);
|
||||
|
||||
// Initialize
|
||||
setState(STATE.FORM);
|
||||
console.log('AI Stack Deployer initialized');
|
||||
Reference in New Issue
Block a user