3569 lines
158 KiB
JavaScript
3569 lines
158 KiB
JavaScript
/**
|
|
* Launch Progress JavaScript
|
|
*
|
|
* This file handles the instance launch and update process, including:
|
|
* - Step-by-step progress tracking
|
|
* - Stack deployment via Portainer API
|
|
* - Error handling for HTTP 502/504 responses
|
|
*
|
|
* Note: HTTP 502 (Bad Gateway) and 504 (Gateway Timeout) errors are treated as
|
|
* potential success cases since they often occur when Portainer is busy but the
|
|
* operation may still succeed. In these cases, the system continues monitoring
|
|
* the stack status.
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Check if this is an update operation
|
|
if (window.isUpdate && window.updateInstanceId && window.updateRepoId && window.updateBranch) {
|
|
// This is an update operation
|
|
const updateData = {
|
|
instanceId: window.updateInstanceId,
|
|
repository: window.updateRepoId,
|
|
branch: window.updateBranch,
|
|
isUpdate: true
|
|
};
|
|
|
|
// Initialize the steps
|
|
initializeSteps();
|
|
|
|
// Start the update process
|
|
startUpdate(updateData);
|
|
} else {
|
|
// This is a new launch operation
|
|
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
|
|
if (!launchData) {
|
|
showError('No launch data found. Please start over.');
|
|
return;
|
|
}
|
|
|
|
// Initialize the steps
|
|
initializeSteps();
|
|
|
|
// Start the launch process
|
|
startLaunch(launchData);
|
|
}
|
|
});
|
|
|
|
function initializeSteps() {
|
|
const stepsContainer = document.getElementById('stepsContainer');
|
|
const isUpdate = window.isUpdate;
|
|
|
|
if (isUpdate) {
|
|
// For updates, show fewer steps
|
|
const steps = [
|
|
{ icon: 'fab fa-docker', title: 'Checking Portainer Connection', description: 'Verifying connection to Portainer...' },
|
|
{ icon: 'fas fa-file-code', title: 'Downloading Docker Compose', description: 'Fetching docker-compose.yml from repository...' },
|
|
{ icon: 'fab fa-docker', title: 'Deploying Updated Stack', description: 'Deploying the updated application stack...' },
|
|
{ icon: 'fas fa-save', title: 'Updating Instance Data', description: 'Updating instance information...' },
|
|
{ icon: 'fas fa-heartbeat', title: 'Health Check', description: 'Verifying updated instance health...' },
|
|
{ icon: 'fas fa-check-circle', title: 'Update Complete', description: 'Instance has been successfully updated!' }
|
|
];
|
|
|
|
steps.forEach((step, index) => {
|
|
const stepElement = document.createElement('div');
|
|
stepElement.className = 'step-item';
|
|
stepElement.innerHTML = `
|
|
<div class="step-icon"><i class="${step.icon}"></i></div>
|
|
<div class="step-content">
|
|
<h5>${step.title}</h5>
|
|
<p class="step-status">${step.description}</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(stepElement);
|
|
});
|
|
} else {
|
|
// For new launches, show all steps
|
|
// Add Cloudflare connection check step
|
|
const cloudflareStep = document.createElement('div');
|
|
cloudflareStep.className = 'step-item';
|
|
cloudflareStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-cloud"></i></div>
|
|
<div class="step-content">
|
|
<h5>Checking Cloudflare Connection</h5>
|
|
<p class="step-status">Verifying Cloudflare API connection...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(cloudflareStep);
|
|
|
|
// Add DNS record creation step
|
|
const dnsCreateStep = document.createElement('div');
|
|
dnsCreateStep.className = 'step-item';
|
|
dnsCreateStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-plus-circle"></i></div>
|
|
<div class="step-content">
|
|
<h5>Creating DNS Records</h5>
|
|
<p class="step-status">Setting up domain DNS records in Cloudflare...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(dnsCreateStep);
|
|
|
|
// Add DNS check step
|
|
const dnsStep = document.createElement('div');
|
|
dnsStep.className = 'step-item';
|
|
dnsStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-globe"></i></div>
|
|
<div class="step-content">
|
|
<h5>Checking DNS Records</h5>
|
|
<p class="step-status">Verifying domain configurations...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(dnsStep);
|
|
|
|
// Add NGINX connection check step
|
|
const nginxStep = document.createElement('div');
|
|
nginxStep.className = 'step-item';
|
|
nginxStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-network-wired"></i></div>
|
|
<div class="step-content">
|
|
<h5>Checking NGINX Connection</h5>
|
|
<p class="step-status">Verifying connection to NGINX Proxy Manager...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(nginxStep);
|
|
|
|
// Add SSL Certificate generation step
|
|
const sslStep = document.createElement('div');
|
|
sslStep.className = 'step-item';
|
|
sslStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-lock"></i></div>
|
|
<div class="step-content">
|
|
<h5>Generating SSL Certificate</h5>
|
|
<p class="step-status">Setting up secure HTTPS connection...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(sslStep);
|
|
|
|
// Add Proxy Host creation step
|
|
const proxyStep = document.createElement('div');
|
|
proxyStep.className = 'step-item';
|
|
proxyStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-server"></i></div>
|
|
<div class="step-content">
|
|
<h5>Creating Proxy Host</h5>
|
|
<p class="step-status">Setting up NGINX proxy host configuration...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(proxyStep);
|
|
|
|
// Add Portainer connection check step
|
|
const portainerStep = document.createElement('div');
|
|
portainerStep.className = 'step-item';
|
|
portainerStep.innerHTML = `
|
|
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
|
<div class="step-content">
|
|
<h5>Checking Portainer Connection</h5>
|
|
<p class="step-status">Verifying connection to Portainer...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(portainerStep);
|
|
|
|
// Add Docker Compose download step
|
|
const dockerComposeStep = document.createElement('div');
|
|
dockerComposeStep.className = 'step-item';
|
|
dockerComposeStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-file-code"></i></div>
|
|
<div class="step-content">
|
|
<h5>Downloading Docker Compose</h5>
|
|
<p class="step-status">Fetching docker-compose.yml from repository...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(dockerComposeStep);
|
|
|
|
// Add Portainer stack deployment step
|
|
const stackDeployStep = document.createElement('div');
|
|
stackDeployStep.className = 'step-item';
|
|
stackDeployStep.innerHTML = `
|
|
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
|
<div class="step-content">
|
|
<h5>Deploying Stack</h5>
|
|
<p class="step-status">Launching your application stack...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(stackDeployStep);
|
|
|
|
// Add Save Instance Data step
|
|
const saveDataStep = document.createElement('div');
|
|
saveDataStep.className = 'step-item';
|
|
saveDataStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-save"></i></div>
|
|
<div class="step-content">
|
|
<h5>Saving Instance Data</h5>
|
|
<p class="step-status">Storing instance information...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(saveDataStep);
|
|
|
|
// Add Health Check step
|
|
const healthStep = document.createElement('div');
|
|
healthStep.className = 'step-item';
|
|
healthStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-heartbeat"></i></div>
|
|
<div class="step-content">
|
|
<h5>Health Check</h5>
|
|
<p class="step-status">Verifying instance health...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(healthStep);
|
|
|
|
// Add Authentication step
|
|
const authStep = document.createElement('div');
|
|
authStep.className = 'step-item';
|
|
authStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-key"></i></div>
|
|
<div class="step-content">
|
|
<h5>Instance Authentication</h5>
|
|
<p class="step-status">Setting up instance authentication...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(authStep);
|
|
|
|
// Add Apply Company Information step
|
|
const companyStep = document.createElement('div');
|
|
companyStep.className = 'step-item';
|
|
companyStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-building"></i></div>
|
|
<div class="step-content">
|
|
<h5>Apply Company Information</h5>
|
|
<p class="step-status">Configuring company details...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(companyStep);
|
|
|
|
// Add Apply Colors step
|
|
const colorsStep = document.createElement('div');
|
|
colorsStep.className = 'step-item';
|
|
colorsStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-palette"></i></div>
|
|
<div class="step-content">
|
|
<h5>Apply Colors</h5>
|
|
<p class="step-status">Configuring color scheme...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(colorsStep);
|
|
|
|
// Add Update Admin Credentials step
|
|
const credentialsStep = document.createElement('div');
|
|
credentialsStep.className = 'step-item';
|
|
credentialsStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-user-shield"></i></div>
|
|
<div class="step-content">
|
|
<h5>Update Admin Credentials</h5>
|
|
<p class="step-status">Setting up admin account...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(credentialsStep);
|
|
|
|
// Add Copy SMTP Settings step
|
|
const smtpStep = document.createElement('div');
|
|
smtpStep.className = 'step-item';
|
|
smtpStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-envelope"></i></div>
|
|
<div class="step-content">
|
|
<h5>Copy SMTP Settings</h5>
|
|
<p class="step-status">Configuring email settings...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(smtpStep);
|
|
|
|
// Add Send Completion Email step
|
|
const emailStep = document.createElement('div');
|
|
emailStep.className = 'step-item';
|
|
emailStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-paper-plane"></i></div>
|
|
<div class="step-content">
|
|
<h5>Send Completion Email</h5>
|
|
<p class="step-status">Sending completion notification...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(emailStep);
|
|
|
|
// Add Download Report step
|
|
const reportStep = document.createElement('div');
|
|
reportStep.className = 'step-item';
|
|
reportStep.innerHTML = `
|
|
<div class="step-icon"><i class="fas fa-download"></i></div>
|
|
<div class="step-content">
|
|
<h5>Download Launch Report</h5>
|
|
<p class="step-status">Preparing launch report...</p>
|
|
</div>
|
|
`;
|
|
stepsContainer.appendChild(reportStep);
|
|
}
|
|
}
|
|
|
|
async function startLaunch(data) {
|
|
const launchReport = {
|
|
startedAt: new Date().toISOString(),
|
|
steps: []
|
|
};
|
|
try {
|
|
// Step 1: Check Cloudflare connection
|
|
await updateStep(1, 'Checking Cloudflare Connection', 'Verifying Cloudflare API connection...');
|
|
const cloudflareResult = await checkCloudflareConnection();
|
|
launchReport.steps.push({
|
|
step: 'Cloudflare Connection',
|
|
status: cloudflareResult.success ? 'success' : 'error',
|
|
details: cloudflareResult
|
|
});
|
|
if (!cloudflareResult.success) {
|
|
throw new Error(cloudflareResult.error || 'Failed to connect to Cloudflare');
|
|
}
|
|
|
|
// Update the step to show success
|
|
const cloudflareStep = document.querySelectorAll('.step-item')[0];
|
|
cloudflareStep.classList.remove('active');
|
|
cloudflareStep.classList.add('completed');
|
|
cloudflareStep.querySelector('.step-status').textContent = `Successfully connected to Cloudflare (${cloudflareResult.zone_name})`;
|
|
|
|
// Step 2: Create DNS records
|
|
await updateStep(2, 'Creating DNS Records', 'Setting up domain DNS records in Cloudflare...');
|
|
const dnsCreateResult = await createDNSRecords(data.webAddresses);
|
|
launchReport.steps.push({
|
|
step: 'DNS Records',
|
|
status: dnsCreateResult.success ? 'success' : 'error',
|
|
details: dnsCreateResult
|
|
});
|
|
if (!dnsCreateResult.success) {
|
|
throw new Error(dnsCreateResult.error || 'Failed to create DNS records');
|
|
}
|
|
|
|
// Update the step to show success
|
|
const dnsCreateStep = document.querySelectorAll('.step-item')[1];
|
|
dnsCreateStep.classList.remove('active');
|
|
dnsCreateStep.classList.add('completed');
|
|
dnsCreateStep.querySelector('.step-status').textContent = 'DNS records created successfully';
|
|
|
|
// Add DNS creation details
|
|
const dnsCreateDetails = document.createElement('div');
|
|
dnsCreateDetails.className = 'mt-3';
|
|
dnsCreateDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">DNS Record Creation Results</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Domain</th>
|
|
<th>Status</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${Object.entries(dnsCreateResult.results).map(([domain, result]) => `
|
|
<tr>
|
|
<td>${domain}</td>
|
|
<td>
|
|
<span class="badge bg-${result.status === 'created' || result.status === 'updated' ? 'success' : 'danger'}">
|
|
${result.status}
|
|
</span>
|
|
</td>
|
|
<td>${result.message}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
dnsCreateStep.querySelector('.step-content').appendChild(dnsCreateDetails);
|
|
|
|
// Step 3: Check DNS records
|
|
await updateStep(3, 'Checking DNS Records', 'Verifying domain configurations...');
|
|
const dnsResult = await checkDNSRecords(data.webAddresses);
|
|
launchReport.steps.push({
|
|
step: 'DNS Check',
|
|
status: dnsResult.success ? 'success' : 'error',
|
|
details: dnsResult
|
|
});
|
|
if (!dnsResult.success) {
|
|
throw new Error(dnsResult.error || 'Failed to check DNS records');
|
|
}
|
|
|
|
// Check if any domains failed to resolve
|
|
const failedDomains = Object.entries(dnsResult.results)
|
|
.filter(([_, result]) => !result.resolved)
|
|
.map(([domain]) => domain);
|
|
|
|
if (failedDomains.length > 0) {
|
|
throw new Error(`DNS records not found for: ${failedDomains.join(', ')}`);
|
|
}
|
|
|
|
// Update the step to show success
|
|
const dnsStep = document.querySelectorAll('.step-item')[2];
|
|
dnsStep.classList.remove('active');
|
|
dnsStep.classList.add('completed');
|
|
|
|
// Create a details section for DNS results
|
|
const detailsSection = document.createElement('div');
|
|
detailsSection.className = 'mt-3';
|
|
detailsSection.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">DNS Check Results</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Domain</th>
|
|
<th>Status</th>
|
|
<th>IP Address</th>
|
|
<th>TTL</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${Object.entries(dnsResult.results).map(([domain, result]) => `
|
|
<tr>
|
|
<td>${domain}</td>
|
|
<td>
|
|
<span class="badge bg-${result.resolved ? 'success' : 'danger'}">
|
|
${result.resolved ? 'Resolved' : 'Not Found'}
|
|
</span>
|
|
</td>
|
|
<td>${result.ip || 'N/A'}</td>
|
|
<td>${result.ttl || 'N/A'}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add the details section after the status text
|
|
const statusText = dnsStep.querySelector('.step-status');
|
|
statusText.textContent = 'DNS records verified successfully';
|
|
statusText.after(detailsSection);
|
|
|
|
// Step 4: Check NGINX connection
|
|
await updateStep(4, 'Checking NGINX Connection', 'Verifying connection to NGINX Proxy Manager...');
|
|
const nginxResult = await checkNginxConnection();
|
|
launchReport.steps.push({
|
|
step: 'NGINX Connection',
|
|
status: nginxResult.success ? 'success' : 'error',
|
|
details: nginxResult
|
|
});
|
|
if (!nginxResult.success) {
|
|
throw new Error(nginxResult.error || 'Failed to connect to NGINX Proxy Manager');
|
|
}
|
|
|
|
// Update the step to show success
|
|
const nginxStep = document.querySelectorAll('.step-item')[3];
|
|
nginxStep.classList.remove('active');
|
|
nginxStep.classList.add('completed');
|
|
nginxStep.querySelector('.step-status').textContent = 'Successfully connected to NGINX Proxy Manager';
|
|
|
|
// Step 5: Generate SSL Certificate
|
|
await updateStep(5, 'Generating SSL Certificate', 'Setting up secure HTTPS connection...');
|
|
const sslResult = await generateSSLCertificate(data.webAddresses);
|
|
launchReport.steps.push({
|
|
step: 'SSL Certificate',
|
|
status: sslResult.success ? 'success' : 'error',
|
|
details: sslResult
|
|
});
|
|
if (!sslResult.success) {
|
|
throw new Error(sslResult.error || 'Failed to generate SSL certificate');
|
|
}
|
|
|
|
// Step 6: Create Proxy Host
|
|
await updateStep(6, 'Creating Proxy Host', 'Setting up NGINX proxy host configuration...');
|
|
const proxyResult = await createProxyHost(data.webAddresses, data.port, sslResult.data.certificate.id);
|
|
launchReport.steps.push({
|
|
step: 'Proxy Host',
|
|
status: proxyResult.success ? 'success' : 'error',
|
|
details: proxyResult
|
|
});
|
|
if (!proxyResult.success) {
|
|
throw new Error(proxyResult.error || 'Failed to create proxy host');
|
|
}
|
|
|
|
// Step 7: Check Portainer connection
|
|
await updateStep(7, 'Checking Portainer Connection', 'Verifying connection to Portainer...');
|
|
const portainerResult = await checkPortainerConnection();
|
|
launchReport.steps.push({
|
|
step: 'Portainer Connection',
|
|
status: portainerResult.success ? 'success' : 'error',
|
|
details: portainerResult
|
|
});
|
|
if (!portainerResult.success) {
|
|
throw new Error(portainerResult.message || 'Failed to connect to Portainer');
|
|
}
|
|
|
|
// Update the step to show success
|
|
const portainerStep = document.querySelectorAll('.step-item')[6];
|
|
portainerStep.classList.remove('active');
|
|
portainerStep.classList.add('completed');
|
|
portainerStep.querySelector('.step-status').textContent = portainerResult.message;
|
|
|
|
// Step 8: Download Docker Compose
|
|
await updateStep(8, 'Downloading Docker Compose', 'Fetching docker-compose.yml from repository...');
|
|
const dockerComposeResult = await downloadDockerCompose(data.repository, data.branch);
|
|
launchReport.steps.push({
|
|
step: 'Docker Compose',
|
|
status: dockerComposeResult.success ? 'success' : 'error',
|
|
details: dockerComposeResult
|
|
});
|
|
if (!dockerComposeResult.success) {
|
|
throw new Error(dockerComposeResult.error || 'Failed to download docker-compose.yml');
|
|
}
|
|
|
|
// Set global version variables for deployment
|
|
window.currentDeploymentVersion = dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown';
|
|
window.currentDeploymentCommit = dockerComposeResult.commit_hash || 'unknown';
|
|
window.currentDeploymentBranch = data.branch;
|
|
|
|
// Update the step to show success
|
|
const dockerComposeStep = document.querySelectorAll('.step-item')[7];
|
|
dockerComposeStep.classList.remove('active');
|
|
dockerComposeStep.classList.add('completed');
|
|
dockerComposeStep.querySelector('.step-status').textContent = 'Successfully downloaded docker-compose.yml';
|
|
|
|
// Add download button
|
|
const downloadButton = document.createElement('button');
|
|
downloadButton.className = 'btn btn-sm btn-primary mt-2';
|
|
downloadButton.innerHTML = '<i class="fas fa-download me-1"></i> Download docker-compose.yml';
|
|
downloadButton.onclick = () => {
|
|
// Generate the modified docker-compose content with updated volume names
|
|
let modifiedContent = dockerComposeResult.content;
|
|
const stackName = generateStackName(data.port);
|
|
|
|
// Extract timestamp from stack name (format: docupulse_PORT_TIMESTAMP)
|
|
const stackNameParts = stackName.split('_');
|
|
if (stackNameParts.length >= 3) {
|
|
const timestamp = stackNameParts.slice(2).join('_'); // Get everything after port
|
|
const baseName = `docupulse_${data.port}_${timestamp}`;
|
|
|
|
// Replace volume names to match stack naming convention
|
|
modifiedContent = modifiedContent.replace(
|
|
/name: docupulse_\$\{PORT:-10335\}_postgres_data/g,
|
|
`name: ${baseName}_postgres_data`
|
|
);
|
|
modifiedContent = modifiedContent.replace(
|
|
/name: docupulse_\$\{PORT:-10335\}_uploads/g,
|
|
`name: ${baseName}_uploads`
|
|
);
|
|
}
|
|
|
|
const blob = new Blob([modifiedContent], { type: 'text/yaml' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'docker-compose.yml';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
};
|
|
dockerComposeStep.querySelector('.step-content').appendChild(downloadButton);
|
|
|
|
// Step 9: Deploy Stack
|
|
await updateStep(9, 'Deploying Stack', 'Launching your application stack...');
|
|
|
|
// Add progress indicator for stack deployment
|
|
const stackDeployStepElement = document.querySelectorAll('.step-item')[8];
|
|
const stackProgressDiv = document.createElement('div');
|
|
stackProgressDiv.className = 'mt-2';
|
|
stackProgressDiv.innerHTML = `
|
|
<div class="progress" style="height: 20px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar"
|
|
style="width: 0%"
|
|
id="stackProgress">
|
|
0%
|
|
</div>
|
|
</div>
|
|
<small class="text-muted" id="stackProgressText">Initiating stack deployment...</small>
|
|
<div class="alert alert-info mt-2" style="font-size: 0.85em;">
|
|
<i class="fas fa-info-circle me-1"></i>
|
|
<strong>Note:</strong> Stack deployment can take several minutes. If you see HTTP 502 or 504 errors,
|
|
the deployment may still be in progress and will be monitored automatically.
|
|
</div>
|
|
`;
|
|
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
|
|
|
|
const stackName = generateStackName(data.port);
|
|
const stackResult = await deployStack(dockerComposeResult.content, stackName, data.port);
|
|
launchReport.steps.push({
|
|
step: 'Stack Deployment',
|
|
status: stackResult.success ? 'success' : 'error',
|
|
details: stackResult
|
|
});
|
|
|
|
// Handle different stack deployment scenarios
|
|
if (!stackResult.success) {
|
|
// Check if this is a timeout but the stack might still be deploying
|
|
if (stackResult.error && (
|
|
stackResult.error.includes('timed out') ||
|
|
stackResult.error.includes('504 Gateway Time-out') ||
|
|
stackResult.error.includes('504 Gateway Timeout')
|
|
)) {
|
|
console.log('Stack deployment timed out, but may still be in progress');
|
|
|
|
// Update the step to show warning instead of error
|
|
const stackDeployStep = document.querySelectorAll('.step-item')[8];
|
|
stackDeployStep.classList.remove('active');
|
|
stackDeployStep.classList.add('warning');
|
|
stackDeployStep.querySelector('.step-status').textContent = 'Stack deployment timed out but may still be in progress';
|
|
|
|
// Add a note about the timeout
|
|
const timeoutNote = document.createElement('div');
|
|
timeoutNote.className = 'alert alert-warning mt-2';
|
|
timeoutNote.innerHTML = `
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
<strong>Note:</strong> The stack deployment request timed out, but the deployment may still be in progress.
|
|
You can check the status in your Portainer dashboard or wait a few minutes and refresh this page.
|
|
`;
|
|
stackDeployStep.querySelector('.step-content').appendChild(timeoutNote);
|
|
|
|
// Continue with the process using the available data
|
|
stackResult.data = stackResult.data || {
|
|
name: stackName,
|
|
status: 'creating',
|
|
id: null
|
|
};
|
|
} else {
|
|
throw new Error(stackResult.error || 'Failed to deploy stack');
|
|
}
|
|
}
|
|
|
|
// Update the step to show success
|
|
const stackDeployStep = document.querySelectorAll('.step-item')[8];
|
|
stackDeployStep.classList.remove('active');
|
|
stackDeployStep.classList.add('completed');
|
|
stackDeployStep.querySelector('.step-status').textContent =
|
|
stackResult.data.status === 'existing' ?
|
|
'Using existing stack' :
|
|
stackResult.data.status === 'active' ?
|
|
'Successfully deployed and activated stack' :
|
|
'Successfully deployed stack';
|
|
|
|
// Add stack details
|
|
const stackDetails = document.createElement('div');
|
|
stackDetails.className = 'mt-3';
|
|
|
|
// Calculate the volume names based on the stack name
|
|
const stackNameParts = stackResult.data.name.split('_');
|
|
let volumeNames = [];
|
|
if (stackNameParts.length >= 3) {
|
|
const timestamp = stackNameParts.slice(2).join('_');
|
|
const baseName = `docupulse_${data.port}_${timestamp}`;
|
|
volumeNames = [
|
|
`${baseName}_postgres_data`,
|
|
`${baseName}_uploads`
|
|
];
|
|
}
|
|
|
|
stackDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">Stack Deployment Results</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Stack Name</td>
|
|
<td>${stackResult.data.name}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Stack ID</td>
|
|
<td>${stackResult.data.id || 'Will be determined during deployment'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Status</td>
|
|
<td>
|
|
<span class="badge bg-${stackResult.data.status === 'existing' ? 'info' : stackResult.data.status === 'active' ? 'success' : 'warning'}">
|
|
${stackResult.data.status === 'existing' ? 'Existing' : stackResult.data.status === 'active' ? 'Active' : 'Deployed'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Volume Names</td>
|
|
<td>
|
|
<div class="small">
|
|
${volumeNames.length > 0 ? volumeNames.map(name =>
|
|
`<code class="text-primary">${name}</code>`
|
|
).join('<br>') : 'Using default volume names'}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
${volumeNames.length > 0 ? `
|
|
<div class="alert alert-info mt-3">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
<strong>Volume Naming Convention:</strong> Volumes have been named using the same timestamp as the stack for easy identification and management.
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
stackDeployStep.querySelector('.step-content').appendChild(stackDetails);
|
|
|
|
// Save instance data
|
|
await updateStep(10, 'Saving Instance Data', 'Storing instance information...');
|
|
try {
|
|
// Get the launch data from sessionStorage to access pricing tier info
|
|
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData') || '{}');
|
|
|
|
const instanceData = {
|
|
name: data.instanceName,
|
|
port: data.port,
|
|
domains: data.webAddresses,
|
|
stack_id: stackResult.data.id || null, // May be null if we got a 504
|
|
stack_name: stackResult.data.name,
|
|
status: stackResult.data.status,
|
|
repository: data.repository,
|
|
branch: data.branch,
|
|
deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown',
|
|
deployed_branch: data.branch,
|
|
payment_plan: launchData.pricingTier?.name || 'Basic' // Use the selected pricing tier name
|
|
};
|
|
console.log('Saving instance data:', instanceData);
|
|
const saveResult = await saveInstanceData(instanceData);
|
|
console.log('Save result:', saveResult);
|
|
|
|
// Update step with version information
|
|
const versionInfo = dockerComposeResult.commit_hash ?
|
|
`Instance data saved successfully. Version: ${dockerComposeResult.commit_hash.substring(0, 8)}` :
|
|
'Instance data saved successfully';
|
|
await updateStep(10, 'Saving Instance Data', versionInfo);
|
|
} catch (error) {
|
|
console.error('Error saving instance data:', error);
|
|
await updateStep(10, 'Saving Instance Data', `Error: ${error.message}`);
|
|
throw error;
|
|
}
|
|
|
|
// Update the step to show success
|
|
const saveDataStep = document.querySelectorAll('.step-item')[9];
|
|
saveDataStep.classList.remove('active');
|
|
saveDataStep.classList.add('completed');
|
|
saveDataStep.querySelector('.step-status').textContent = 'Successfully saved instance data';
|
|
|
|
// After saving instance data, add the health check step
|
|
await updateStep(11, 'Health Check', 'Verifying instance health...');
|
|
|
|
// Add a progress indicator
|
|
const healthStepElement = document.querySelectorAll('.step-item')[10];
|
|
const progressDiv = document.createElement('div');
|
|
progressDiv.className = 'mt-2';
|
|
progressDiv.innerHTML = `
|
|
<div class="progress" style="height: 20px;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
role="progressbar"
|
|
style="width: 0%"
|
|
id="healthProgress">
|
|
0%
|
|
</div>
|
|
</div>
|
|
<small class="text-muted" id="healthProgressText">Starting health check...</small>
|
|
`;
|
|
healthStepElement.querySelector('.step-content').appendChild(progressDiv);
|
|
|
|
const healthResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
|
|
|
|
if (!healthResult.success) {
|
|
throw new Error(`Health check failed: ${healthResult.error}`);
|
|
}
|
|
|
|
// Add a retry button if health check fails
|
|
const healthStepElement2 = document.querySelectorAll('.step-item')[10];
|
|
if (!healthResult.success) {
|
|
const retryButton = document.createElement('button');
|
|
retryButton.className = 'btn btn-sm btn-warning mt-2';
|
|
retryButton.innerHTML = '<i class="fas fa-sync-alt me-1"></i> Retry Health Check';
|
|
retryButton.onclick = async () => {
|
|
retryButton.disabled = true;
|
|
retryButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> Checking...';
|
|
const retryResult = await checkInstanceHealth(`https://${data.webAddresses[0]}`);
|
|
if (retryResult.success) {
|
|
healthStepElement2.classList.remove('failed');
|
|
healthStepElement2.classList.add('completed');
|
|
retryButton.remove();
|
|
} else {
|
|
retryButton.disabled = false;
|
|
retryButton.innerHTML = '<i class="fas fa-sync-alt me-1"></i> Retry Health Check';
|
|
}
|
|
};
|
|
healthStepElement2.querySelector('.step-content').appendChild(retryButton);
|
|
}
|
|
|
|
// After health check, add authentication step
|
|
await updateStep(12, 'Instance Authentication', 'Setting up instance authentication...');
|
|
const authResult = await authenticateInstance(`https://${data.webAddresses[0]}`, data.instanceId);
|
|
|
|
if (!authResult.success) {
|
|
throw new Error(`Authentication failed: ${authResult.error}`);
|
|
}
|
|
|
|
// Update the auth step to show success
|
|
const authStep = document.querySelectorAll('.step-item')[11];
|
|
authStep.classList.remove('active');
|
|
authStep.classList.add('completed');
|
|
authStep.querySelector('.step-status').textContent = authResult.alreadyAuthenticated ?
|
|
'Instance is already authenticated' :
|
|
'Successfully authenticated instance';
|
|
|
|
// Add authentication details
|
|
const authDetails = document.createElement('div');
|
|
authDetails.className = 'mt-3';
|
|
authDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">Authentication Details</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Status</td>
|
|
<td><span class="badge bg-success">Authenticated</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Default Admin</td>
|
|
<td>administrator@docupulse.com</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Connection Type</td>
|
|
<td>Management API Key</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Authentication Type</td>
|
|
<td>${authResult.alreadyAuthenticated ? 'Existing' : 'New'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
authStep.querySelector('.step-content').appendChild(authDetails);
|
|
|
|
// Step 13: Apply Company Information
|
|
await updateStep(13, 'Apply Company Information', 'Configuring company details...');
|
|
const companyResult = await applyCompanyInformation(`https://${data.webAddresses[0]}`, data.company);
|
|
launchReport.steps.push({
|
|
step: 'Company Information',
|
|
status: companyResult.success ? 'success' : 'error',
|
|
details: companyResult
|
|
});
|
|
if (!companyResult.success) {
|
|
throw new Error(`Company information application failed: ${companyResult.error}`);
|
|
}
|
|
|
|
// Update the company step to show success
|
|
const companyStep = document.querySelectorAll('.step-item')[12];
|
|
companyStep.classList.remove('active');
|
|
companyStep.classList.add('completed');
|
|
companyStep.querySelector('.step-status').textContent = 'Successfully applied company information';
|
|
|
|
// Add company details
|
|
const companyDetails = document.createElement('div');
|
|
companyDetails.className = 'mt-3';
|
|
companyDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">Company Information Applied</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Company Name</td>
|
|
<td>${data.company.name || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Industry</td>
|
|
<td>${data.company.industry || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Email</td>
|
|
<td>${data.company.email || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Website</td>
|
|
<td>${data.company.website || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Address</td>
|
|
<td>${data.company.streetAddress || 'Not set'}, ${data.company.city || ''}, ${data.company.state || ''} ${data.company.zipCode || ''}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
companyStep.querySelector('.step-content').appendChild(companyDetails);
|
|
|
|
// Step 14: Apply Colors
|
|
await updateStep(14, 'Apply Colors', 'Configuring color scheme...');
|
|
const colorsResult = await applyColors(`https://${data.webAddresses[0]}`, data.colors);
|
|
launchReport.steps.push({
|
|
step: 'Colors',
|
|
status: colorsResult.success ? 'success' : 'error',
|
|
details: colorsResult
|
|
});
|
|
if (!colorsResult.success) {
|
|
throw new Error(`Colors application failed: ${colorsResult.error}`);
|
|
}
|
|
|
|
// Update the colors step to show success
|
|
const colorsStep = document.querySelectorAll('.step-item')[13];
|
|
colorsStep.classList.remove('active');
|
|
colorsStep.classList.add('completed');
|
|
colorsStep.querySelector('.step-status').textContent = 'Successfully applied color scheme';
|
|
|
|
// Add colors details
|
|
const colorsDetails = document.createElement('div');
|
|
colorsDetails.className = 'mt-3';
|
|
colorsDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">Color Scheme Applied</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
<th>Preview</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Primary Color</td>
|
|
<td>${data.colors.primary || 'Not set'}</td>
|
|
<td><div style="width: 30px; height: 20px; background-color: ${data.colors.primary || '#ccc'}; border: 1px solid #ddd;"></div></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Secondary Color</td>
|
|
<td>${data.colors.secondary || 'Not set'}</td>
|
|
<td><div style="width: 30px; height: 20px; background-color: ${data.colors.secondary || '#ccc'}; border: 1px solid #ddd;"></div></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
colorsStep.querySelector('.step-content').appendChild(colorsDetails);
|
|
|
|
// Step 15: Update Admin Credentials
|
|
await updateStep(15, 'Update Admin Credentials', 'Setting up admin account...');
|
|
const credentialsResult = await updateAdminCredentials(`https://${data.webAddresses[0]}`, data.company.email);
|
|
launchReport.steps.push({
|
|
step: 'Admin Credentials',
|
|
status: credentialsResult.success ? 'success' : 'error',
|
|
details: credentialsResult
|
|
});
|
|
if (!credentialsResult.success) {
|
|
throw new Error(`Admin credentials update failed: ${credentialsResult.error}`);
|
|
}
|
|
|
|
// Update the credentials step to show success
|
|
const credentialsStep = document.querySelectorAll('.step-item')[14];
|
|
credentialsStep.classList.remove('active');
|
|
credentialsStep.classList.add('completed');
|
|
|
|
if (credentialsResult.data.already_updated) {
|
|
credentialsStep.querySelector('.step-status').textContent = 'Admin credentials already updated';
|
|
} else {
|
|
credentialsStep.querySelector('.step-status').textContent = 'Successfully updated admin credentials';
|
|
}
|
|
|
|
// Add credentials details
|
|
const credentialsDetails = document.createElement('div');
|
|
credentialsDetails.className = 'mt-3';
|
|
credentialsDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">${credentialsResult.data.already_updated ? 'Admin Credentials Status' : 'Admin Credentials Updated'}</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Email Address</td>
|
|
<td>${credentialsResult.data.email || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Username</td>
|
|
<td>${credentialsResult.data.username || 'administrator'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Role</td>
|
|
<td><span class="badge bg-primary">Administrator</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Password Setup</td>
|
|
<td><span class="badge bg-warning">Reset Link Pending</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Status</td>
|
|
<td><span class="badge bg-${credentialsResult.data.already_updated ? 'info' : 'success'}">${credentialsResult.data.already_updated ? 'Already Updated' : 'Updated'}</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="alert alert-${credentialsResult.data.already_updated ? 'info' : 'warning'} mt-3">
|
|
<i class="fas fa-${credentialsResult.data.already_updated ? 'info-circle' : 'exclamation-triangle'} me-2"></i>
|
|
<strong>${credentialsResult.data.already_updated ? 'Note:' : 'Security Setup Required:'}</strong>
|
|
${credentialsResult.data.already_updated ?
|
|
'Admin credentials were already updated from the default settings.' :
|
|
'A secure password reset link will be sent to the admin email address in the completion email.'}
|
|
</div>
|
|
<div class="alert alert-info mt-2">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
<strong>Login URL:</strong> <a href="https://${data.webAddresses[0]}" target="_blank">https://${data.webAddresses[0]}</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
credentialsStep.querySelector('.step-content').appendChild(credentialsDetails);
|
|
|
|
// Step 16: Copy SMTP Settings
|
|
await updateStep(16, 'Copy SMTP Settings', 'Configuring email settings...');
|
|
const smtpResult = await copySmtpSettings(`https://${data.webAddresses[0]}`);
|
|
launchReport.steps.push({
|
|
step: 'SMTP Settings',
|
|
status: smtpResult.success ? 'success' : 'error',
|
|
details: smtpResult
|
|
});
|
|
if (!smtpResult.success) {
|
|
throw new Error(`SMTP settings copy failed: ${smtpResult.error}`);
|
|
}
|
|
|
|
// Update the SMTP step to show success
|
|
const smtpStep = document.querySelectorAll('.step-item')[15];
|
|
smtpStep.classList.remove('active');
|
|
smtpStep.classList.add('completed');
|
|
smtpStep.querySelector('.step-status').textContent = 'Successfully copied SMTP settings';
|
|
|
|
// Add SMTP details
|
|
const smtpDetails = document.createElement('div');
|
|
smtpDetails.className = 'mt-3';
|
|
smtpDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">SMTP Settings Copied</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>SMTP Host</td>
|
|
<td>${smtpResult.data.smtp_host || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>SMTP Port</td>
|
|
<td>${smtpResult.data.smtp_port || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Security</td>
|
|
<td><span class="badge bg-${smtpResult.data.smtp_security === 'ssl' ? 'success' : smtpResult.data.smtp_security === 'tls' ? 'warning' : 'secondary'}">${smtpResult.data.smtp_security || 'None'}</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Username</td>
|
|
<td>${smtpResult.data.smtp_username || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>From Email</td>
|
|
<td>${smtpResult.data.smtp_from_email || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>From Name</td>
|
|
<td>${smtpResult.data.smtp_from_name || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Status</td>
|
|
<td><span class="badge bg-success">Copied Successfully</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="alert alert-info mt-3">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
<strong>Email Configuration Complete!</strong> The launched instance now has the same SMTP settings as the master instance and can send emails independently.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
smtpStep.querySelector('.step-content').appendChild(smtpDetails);
|
|
|
|
// Step 17: Send Completion Email
|
|
await updateStep(17, 'Send Completion Email', 'Sending notification to client...');
|
|
const emailResult = await sendCompletionEmail(`https://${data.webAddresses[0]}`, data.company, credentialsResult.data);
|
|
launchReport.steps.push({
|
|
step: 'Completion Email',
|
|
status: emailResult.success ? 'success' : 'error',
|
|
details: emailResult
|
|
});
|
|
if (!emailResult.success) {
|
|
throw new Error(`Email sending failed: ${emailResult.error}`);
|
|
}
|
|
|
|
// Update the email step to show success
|
|
const emailStep = document.querySelectorAll('.step-item')[16];
|
|
emailStep.classList.remove('active');
|
|
emailStep.classList.add('completed');
|
|
emailStep.querySelector('.step-status').textContent = 'Successfully sent completion email';
|
|
|
|
// Add email details
|
|
const emailDetails = document.createElement('div');
|
|
emailDetails.className = 'mt-3';
|
|
emailDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">Completion Email Sent</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Recipient</td>
|
|
<td>${data.company.email || 'Not set'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Subject</td>
|
|
<td>Your DocuPulse Instance is Ready!</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Status</td>
|
|
<td><span class="badge bg-success">Sent Successfully</span></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Instance URL</td>
|
|
<td><a href="https://${data.webAddresses[0]}" target="_blank">https://${data.webAddresses[0]}</a></td>
|
|
</tr>
|
|
<tr>
|
|
<td>Password Reset</td>
|
|
<td><span class="badge bg-success">Link Included</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Email Content Dropdown -->
|
|
<div class="accordion mt-3" id="emailContentAccordion">
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header" id="emailContentHeader">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#emailContentCollapse" aria-expanded="false" aria-controls="emailContentCollapse">
|
|
<i class="fas fa-envelope me-2"></i>View Sent Email Content
|
|
</button>
|
|
</h2>
|
|
<div id="emailContentCollapse" class="accordion-collapse collapse" aria-labelledby="emailContentHeader" data-bs-parent="#emailContentAccordion">
|
|
<div class="accordion-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6 class="text-primary">HTML Version</h6>
|
|
<div class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.9em;">
|
|
<div class="email-preview">
|
|
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
|
<div style="background-color: #16767b; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0;">
|
|
<h1>🎉 Your DocuPulse Instance is Ready!</h1>
|
|
</div>
|
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 0 0 5px 5px;">
|
|
<p>Dear ${data.company.name || 'Valued Customer'},</p>
|
|
<p>Great news! Your DocuPulse instance has been successfully deployed and configured.</p>
|
|
|
|
<div style="background-color: #e3f2fd; padding: 15px; border-radius: 5px; margin: 15px 0;">
|
|
<h3>📋 Instance Details</h3>
|
|
<p><strong>Instance URL:</strong> <a href="https://${data.webAddresses[0]}">https://${data.webAddresses[0]}</a></p>
|
|
<p><strong>Company Name:</strong> ${data.company.name || 'Not set'}</p>
|
|
<p><strong>Industry:</strong> ${data.company.industry || 'Not set'}</p>
|
|
<p><strong>Deployment Date:</strong> ${new Date().toLocaleString()}</p>
|
|
</div>
|
|
|
|
<div style="background-color: #e8f5e9; padding: 15px; border-radius: 5px; margin: 15px 0;">
|
|
<h3>🔐 Account Access</h3>
|
|
<p><strong>Email Address:</strong> ${data.company.email || 'Not set'}</p>
|
|
<p><strong>Username:</strong> administrator</p>
|
|
|
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 15px 0;">
|
|
<h4>🔒 Security Setup Required</h4>
|
|
<p>For your security, you need to set up your password. Click the button below to create your secure password.</p>
|
|
<p><strong>Password Reset Link Expires:</strong> 24 hours</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="text-align: center; margin: 30px 0;">
|
|
<a href="#" class="btn btn-primary" style="background-color: #16767b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 10px 0;">🔐 Set Up Your Password</a>
|
|
<br><br>
|
|
<a href="https://${data.webAddresses[0]}" class="btn btn-primary" style="background-color: #16767b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 10px 0;">🚀 Access Your Instance</a>
|
|
</div>
|
|
|
|
<h3>✅ What's Been Configured</h3>
|
|
<ul>
|
|
<li>✅ Secure SSL certificate for HTTPS access</li>
|
|
<li>✅ Company information and branding</li>
|
|
<li>✅ Custom color scheme</li>
|
|
<li>✅ Admin account created</li>
|
|
<li>✅ Document management system ready</li>
|
|
</ul>
|
|
|
|
<h3>🎯 Next Steps</h3>
|
|
<ol>
|
|
<li>Click the "Set Up Your Password" button above</li>
|
|
<li>Create your secure password</li>
|
|
<li>Return to your instance and log in</li>
|
|
<li>Explore your new DocuPulse platform</li>
|
|
<li>Start uploading and organizing your documents</li>
|
|
<li>Invite team members to collaborate</li>
|
|
</ol>
|
|
|
|
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666;">
|
|
<p>If you have any questions or need assistance, please don't hesitate to contact our support team.</p>
|
|
<p>Thank you for choosing DocuPulse!</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<h6 class="text-primary">Plain Text Version</h6>
|
|
<div class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 0.85em; white-space: pre-wrap;">
|
|
Your DocuPulse Instance is Ready!
|
|
|
|
Dear ${data.company.name || 'Valued Customer'},
|
|
|
|
Great news! Your DocuPulse instance has been successfully deployed and configured.
|
|
|
|
INSTANCE DETAILS:
|
|
- Instance URL: https://${data.webAddresses[0]}
|
|
- Company Name: ${data.company.name || 'Not set'}
|
|
- Industry: ${data.company.industry || 'Not set'}
|
|
- Deployment Date: ${new Date().toLocaleString()}
|
|
|
|
ACCOUNT ACCESS:
|
|
- Email Address: ${data.company.email || 'Not set'}
|
|
- Username: administrator
|
|
|
|
SECURITY SETUP REQUIRED:
|
|
For your security, you need to set up your password.
|
|
|
|
Password Reset Link: [Secure reset link included]
|
|
Password Reset Link Expires: 24 hours
|
|
|
|
WHAT'S BEEN CONFIGURED:
|
|
✓ Secure SSL certificate for HTTPS access
|
|
✓ Company information and branding
|
|
✓ Custom color scheme
|
|
✓ Admin account created
|
|
✓ Document management system ready
|
|
|
|
NEXT STEPS:
|
|
1. Click the password reset link above
|
|
2. Create your secure password
|
|
3. Return to your instance and log in
|
|
4. Explore your new DocuPulse platform
|
|
5. Start uploading and organizing your documents
|
|
6. Invite team members to collaborate
|
|
|
|
If you have any questions or need assistance, please don't hesitate to contact our support team.
|
|
|
|
Thank you for choosing DocuPulse!
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="alert alert-success mt-3">
|
|
<i class="fas fa-check-circle me-2"></i>
|
|
<strong>Launch Complete!</strong> Your DocuPulse instance has been successfully deployed and configured.
|
|
The client has been notified via email with all necessary login information and a secure password reset link.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
emailStep.querySelector('.step-content').appendChild(emailDetails);
|
|
|
|
// Update the top section to show completion
|
|
document.getElementById('currentStep').textContent = 'Launch Complete!';
|
|
document.getElementById('stepDescription').textContent = 'Your DocuPulse instance has been successfully deployed and configured.';
|
|
const progressBar = document.getElementById('launchProgress');
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '100%';
|
|
|
|
// Scroll to the bottom of the steps container
|
|
const stepsContainer = document.getElementById('stepsContainer');
|
|
stepsContainer.scrollTo({
|
|
top: stepsContainer.scrollHeight,
|
|
behavior: 'smooth'
|
|
});
|
|
|
|
// Step 18: Download Launch Report
|
|
await updateStep(18, 'Download Launch Report', 'Download a full report of the launch process.');
|
|
const reportStep = document.querySelectorAll('.step-item')[17];
|
|
reportStep.classList.remove('active');
|
|
reportStep.classList.add('completed');
|
|
reportStep.querySelector('.step-status').textContent = 'Download your launch report below.';
|
|
// Prepare the report data
|
|
launchReport.completedAt = new Date().toISOString();
|
|
launchReport.instance = {
|
|
domains: data.webAddresses,
|
|
company: data.company,
|
|
port: data.port
|
|
};
|
|
// Create the download button
|
|
const downloadReportBtn = document.createElement('button');
|
|
downloadReportBtn.className = 'btn btn-success mt-3';
|
|
downloadReportBtn.innerHTML = '<i class="fas fa-file-download me-1"></i> Download Launch Report';
|
|
downloadReportBtn.onclick = () => {
|
|
const blob = new Blob([JSON.stringify(launchReport, null, 2)], { type: 'application/json' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `docupulse_launch_report_${data.instanceName || 'instance'}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
};
|
|
reportStep.querySelector('.step-content').appendChild(downloadReportBtn);
|
|
|
|
} catch (error) {
|
|
console.error('Launch failed:', error);
|
|
await updateStep(17, 'Send Completion Email', `Error: ${error.message}`);
|
|
showError(error.message);
|
|
}
|
|
}
|
|
|
|
// Function to handle instance updates
|
|
async function startUpdate(data) {
|
|
console.log('Starting instance update:', data);
|
|
|
|
try {
|
|
// Update the header to reflect this is an update
|
|
const headerTitle = document.querySelector('.header h1');
|
|
const headerDescription = document.querySelector('.header p');
|
|
if (headerTitle) headerTitle.textContent = 'Updating Instance';
|
|
if (headerDescription) headerDescription.textContent = 'Updating your DocuPulse instance with the latest version';
|
|
|
|
// Initialize launch report for update
|
|
const launchReport = {
|
|
type: 'update',
|
|
timestamp: new Date().toISOString(),
|
|
instanceId: data.instanceId,
|
|
repository: data.repository,
|
|
branch: data.branch,
|
|
steps: []
|
|
};
|
|
|
|
// Step 1: Check Portainer Connection
|
|
await updateStep(1, 'Checking Portainer Connection', 'Verifying connection to Portainer...');
|
|
const portainerResult = await checkPortainerConnection();
|
|
if (!portainerResult.success) {
|
|
throw new Error(`Portainer connection failed: ${portainerResult.error}`);
|
|
}
|
|
launchReport.steps.push({
|
|
step: 'Portainer Connection',
|
|
status: 'success',
|
|
details: portainerResult
|
|
});
|
|
|
|
// Step 2: Download Docker Compose
|
|
await updateStep(2, 'Downloading Docker Compose', 'Fetching docker-compose.yml from repository...');
|
|
const dockerComposeResult = await downloadDockerCompose(data.repository, data.branch);
|
|
if (!dockerComposeResult.success) {
|
|
throw new Error(`Failed to download Docker Compose: ${dockerComposeResult.error}`);
|
|
}
|
|
launchReport.steps.push({
|
|
step: 'Docker Compose Download',
|
|
status: 'success',
|
|
details: dockerComposeResult
|
|
});
|
|
|
|
// Step 3: Deploy Updated Stack
|
|
await updateStep(3, 'Deploying Updated Stack', 'Deploying the updated application stack...');
|
|
|
|
// Get the existing instance information to extract port and stack details
|
|
const instanceResponse = await fetch(`/api/instances/${data.instanceId}`);
|
|
if (!instanceResponse.ok) {
|
|
throw new Error('Failed to get instance information');
|
|
}
|
|
const instanceData = await instanceResponse.json();
|
|
const port = instanceData.instance.name; // Assuming the instance name is the port
|
|
|
|
// Check if we have an existing stack ID
|
|
if (!instanceData.instance.portainer_stack_id) {
|
|
throw new Error('No existing stack found for this instance. Cannot perform update.');
|
|
}
|
|
|
|
// Update the existing stack instead of creating a new one
|
|
const stackResult = await updateStack(dockerComposeResult.content, instanceData.instance.portainer_stack_id, port, instanceData.instance);
|
|
if (!stackResult.success) {
|
|
throw new Error(`Failed to update stack: ${stackResult.error}`);
|
|
}
|
|
launchReport.steps.push({
|
|
step: 'Stack Update',
|
|
status: 'success',
|
|
details: stackResult
|
|
});
|
|
|
|
// Step 4: Update Instance Data
|
|
await updateStep(4, 'Updating Instance Data', 'Updating instance information...');
|
|
const updateData = {
|
|
name: instanceData.instance.name,
|
|
port: port,
|
|
domains: instanceData.instance.main_url ? [instanceData.instance.main_url.replace(/^https?:\/\//, '')] : [],
|
|
stack_id: instanceData.instance.portainer_stack_id, // Keep the same stack ID
|
|
stack_name: instanceData.instance.portainer_stack_name, // Keep the same stack name
|
|
status: stackResult.data.status,
|
|
repository: data.repository,
|
|
branch: data.branch,
|
|
deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown',
|
|
deployed_branch: data.branch,
|
|
payment_plan: instanceData.instance.payment_plan || 'Basic'
|
|
};
|
|
|
|
const saveResult = await saveInstanceData(updateData);
|
|
if (!saveResult.success) {
|
|
throw new Error(`Failed to update instance data: ${saveResult.error}`);
|
|
}
|
|
launchReport.steps.push({
|
|
step: 'Instance Data Update',
|
|
status: 'success',
|
|
details: saveResult
|
|
});
|
|
|
|
// Step 5: Health Check
|
|
await updateStep(5, 'Health Check', 'Verifying updated instance health...');
|
|
const healthResult = await checkInstanceHealth(instanceData.instance.main_url);
|
|
if (!healthResult.success) {
|
|
throw new Error(`Health check failed: ${healthResult.error}`);
|
|
}
|
|
launchReport.steps.push({
|
|
step: 'Health Check',
|
|
status: 'success',
|
|
details: healthResult
|
|
});
|
|
|
|
// Update completed successfully
|
|
await updateStep(6, 'Update Complete', 'Instance has been successfully updated!');
|
|
|
|
// Show success message
|
|
const successStep = document.querySelectorAll('.step-item')[5];
|
|
successStep.classList.remove('active');
|
|
successStep.classList.add('completed');
|
|
successStep.querySelector('.step-status').textContent = 'Instance updated successfully!';
|
|
|
|
// Add success details
|
|
const successDetails = document.createElement('div');
|
|
successDetails.className = 'mt-3';
|
|
|
|
// Calculate the volume names based on the stack name
|
|
const stackNameParts = stackResult.data.name.split('_');
|
|
let volumeNames = [];
|
|
if (stackNameParts.length >= 3) {
|
|
const timestamp = stackNameParts.slice(2).join('_');
|
|
const baseName = `docupulse_${port}_${timestamp}`;
|
|
volumeNames = [
|
|
`${baseName}_postgres_data`,
|
|
`${baseName}_uploads`
|
|
];
|
|
}
|
|
|
|
successDetails.innerHTML = `
|
|
<div class="alert alert-success">
|
|
<h6><i class="fas fa-check-circle me-2"></i>Update Completed Successfully!</h6>
|
|
<p class="mb-2">Your instance has been updated with the latest version from the repository. All existing data and volumes have been preserved.</p>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<strong>Repository:</strong> ${data.repository}<br>
|
|
<strong>Branch:</strong> ${data.branch}<br>
|
|
<strong>New Version:</strong> ${dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown'}
|
|
</div>
|
|
<div class="col-md-6">
|
|
<strong>Stack Name:</strong> ${stackResult.data.name}<br>
|
|
<strong>Instance URL:</strong> <a href="${instanceData.instance.main_url}" target="_blank">${instanceData.instance.main_url}</a>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle me-2"></i>
|
|
<strong>Data Preservation:</strong> All existing data, volumes, and configurations have been preserved during this update.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
successStep.querySelector('.step-content').appendChild(successDetails);
|
|
|
|
// Add button to return to instances page
|
|
const returnButton = document.createElement('button');
|
|
returnButton.className = 'btn btn-primary mt-3';
|
|
returnButton.innerHTML = '<i class="fas fa-arrow-left me-2"></i>Return to Instances';
|
|
returnButton.onclick = () => window.location.href = '/instances';
|
|
successStep.querySelector('.step-content').appendChild(returnButton);
|
|
|
|
// Store the update report
|
|
sessionStorage.setItem('instanceUpdateReport', JSON.stringify(launchReport));
|
|
|
|
} catch (error) {
|
|
console.error('Update failed:', error);
|
|
await updateStep(6, 'Update Failed', `Error: ${error.message}`);
|
|
showError(error.message);
|
|
}
|
|
}
|
|
|
|
async function checkDNSRecords(domains) {
|
|
const maxRetries = 30; // 30 retries * 10 seconds = 5 minutes
|
|
const baseDelay = 10000; // 10 seconds base delay
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch('/api/check-dns', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': window.csrfToken
|
|
},
|
|
body: JSON.stringify({ domains: domains })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to check DNS records');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Check if all domains are resolved
|
|
const allResolved = Object.values(result.results).every(result => result.resolved);
|
|
|
|
if (allResolved) {
|
|
console.log(`DNS records resolved successfully on attempt ${attempt}`);
|
|
return result;
|
|
}
|
|
|
|
// If not all domains are resolved and this isn't the last attempt, wait and retry
|
|
if (attempt < maxRetries) {
|
|
const delay = baseDelay * Math.pow(1.2, attempt - 1); // Exponential backoff
|
|
const failedDomains = Object.entries(result.results)
|
|
.filter(([_, result]) => !result.resolved)
|
|
.map(([domain]) => domain);
|
|
|
|
console.log(`Attempt ${attempt}/${maxRetries}: DNS not yet propagated for ${failedDomains.join(', ')}. Waiting ${Math.round(delay/1000)}s before retry...`);
|
|
|
|
// Update the step description to show retry progress
|
|
const currentStep = document.querySelector('.step-item.active');
|
|
if (currentStep) {
|
|
const statusElement = currentStep.querySelector('.step-status');
|
|
statusElement.textContent = `Waiting for DNS propagation... (Attempt ${attempt}/${maxRetries})`;
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
} else {
|
|
// Last attempt failed
|
|
console.log(`DNS records failed to resolve after ${maxRetries} attempts`);
|
|
return result;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`Error checking DNS records (attempt ${attempt}):`, error);
|
|
|
|
if (attempt === maxRetries) {
|
|
throw error;
|
|
}
|
|
|
|
// Wait before retrying on error
|
|
const delay = baseDelay * Math.pow(1.2, attempt - 1);
|
|
console.log(`DNS check failed, retrying in ${Math.round(delay/1000)}s...`);
|
|
|
|
// Update the step description to show retry progress
|
|
const currentStep = document.querySelector('.step-item.active');
|
|
if (currentStep) {
|
|
const statusElement = currentStep.querySelector('.step-status');
|
|
statusElement.textContent = `DNS check failed, retrying... (Attempt ${attempt}/${maxRetries})`;
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkCloudflareConnection() {
|
|
try {
|
|
const response = await fetch('/api/check-cloudflare-connection', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': window.csrfToken
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to check Cloudflare connection');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error checking Cloudflare connection:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function createDNSRecords(domains) {
|
|
try {
|
|
const response = await fetch('/api/create-dns-records', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': window.csrfToken
|
|
},
|
|
body: JSON.stringify({ domains: domains })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to create DNS records');
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error creating DNS records:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function checkNginxConnection() {
|
|
try {
|
|
// Get NGINX settings from the template
|
|
const nginxSettings = {
|
|
url: window.nginxSettings?.url || '',
|
|
username: window.nginxSettings?.username || '',
|
|
password: window.nginxSettings?.password || ''
|
|
};
|
|
|
|
// Debug log the settings (without password)
|
|
console.log('NGINX Settings:', {
|
|
url: nginxSettings.url,
|
|
username: nginxSettings.username,
|
|
hasPassword: !!nginxSettings.password
|
|
});
|
|
|
|
// Check if any required field is missing
|
|
if (!nginxSettings.url || !nginxSettings.username || !nginxSettings.password) {
|
|
return {
|
|
success: false,
|
|
error: 'NGINX settings are not configured. Please configure NGINX settings in the admin panel.'
|
|
};
|
|
}
|
|
|
|
// First, get the token
|
|
const tokenResponse = await fetch(`${nginxSettings.url}/api/tokens`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
identity: nginxSettings.username,
|
|
secret: nginxSettings.password
|
|
})
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorText = await tokenResponse.text();
|
|
console.error('Token Error Response:', errorText);
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
throw new Error(`Failed to authenticate with NGINX: ${errorJson.message || errorText}`);
|
|
} catch (e) {
|
|
throw new Error(`Failed to authenticate with NGINX: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
const token = tokenData.token;
|
|
|
|
if (!token) {
|
|
throw new Error('No token received from NGINX Proxy Manager');
|
|
}
|
|
|
|
// Now test the connection using the token
|
|
const response = await fetch(`${nginxSettings.url}/api/nginx/proxy-hosts`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('NGINX connection error:', errorText);
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
throw new Error(errorJson.message || 'Failed to connect to NGINX Proxy Manager');
|
|
} catch (e) {
|
|
throw new Error(`Failed to connect to NGINX Proxy Manager: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Error checking NGINX connection:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Error checking NGINX connection'
|
|
};
|
|
}
|
|
}
|
|
|
|
async function checkPortainerConnection() {
|
|
try {
|
|
// Get Portainer settings from the template
|
|
const portainerSettings = {
|
|
url: window.portainerSettings?.url || '',
|
|
api_key: window.portainerSettings?.api_key || ''
|
|
};
|
|
|
|
// Debug log the settings (without API key)
|
|
console.log('Portainer Settings:', {
|
|
url: portainerSettings.url,
|
|
hasApiKey: !!portainerSettings.api_key
|
|
});
|
|
|
|
// Check if any required field is missing
|
|
if (!portainerSettings.url || !portainerSettings.api_key) {
|
|
console.error('Missing Portainer settings:', portainerSettings);
|
|
return {
|
|
success: false,
|
|
message: 'Portainer settings are not configured. Please configure Portainer settings in the admin panel.'
|
|
};
|
|
}
|
|
|
|
const response = await fetch('/api/admin/test-portainer-connection', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
url: portainerSettings.url,
|
|
api_key: portainerSettings.api_key
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to connect to Portainer');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Successfully connected to Portainer'
|
|
};
|
|
} catch (error) {
|
|
console.error('Portainer connection error:', error);
|
|
return {
|
|
success: false,
|
|
message: error.message || 'Failed to connect to Portainer'
|
|
};
|
|
}
|
|
}
|
|
|
|
function updateStatus(step, message, type = 'info', details = '') {
|
|
const statusElement = document.getElementById(`${step}Status`);
|
|
const detailsElement = document.getElementById(`${step}Details`);
|
|
|
|
if (statusElement) {
|
|
// Remove any existing status classes
|
|
statusElement.classList.remove('text-info', 'text-success', 'text-danger');
|
|
|
|
// Add appropriate class based on type
|
|
switch (type) {
|
|
case 'success':
|
|
statusElement.classList.add('text-success');
|
|
break;
|
|
case 'error':
|
|
statusElement.classList.add('text-danger');
|
|
break;
|
|
default:
|
|
statusElement.classList.add('text-info');
|
|
}
|
|
|
|
statusElement.textContent = message;
|
|
}
|
|
|
|
if (detailsElement) {
|
|
detailsElement.innerHTML = details;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an NGINX proxy host for the specified domains.
|
|
*
|
|
* Important: Caching is disabled (caching_enabled: false) to ensure real-time
|
|
* content delivery and avoid potential issues with cached responses interfering
|
|
* with dynamic content or authentication.
|
|
*/
|
|
async function createProxyHost(domains, port, sslCertificateId) {
|
|
try {
|
|
// Get NGINX settings from the template
|
|
const nginxSettings = {
|
|
url: window.nginxSettings?.url || '',
|
|
username: window.nginxSettings?.username || '',
|
|
password: window.nginxSettings?.password || ''
|
|
};
|
|
|
|
console.log('NGINX Settings:', { ...nginxSettings, password: '***' });
|
|
|
|
// Update status to show we're getting the token
|
|
updateStatus('proxy', 'Getting authentication token...', 'info');
|
|
|
|
// First, get the JWT token from NGINX
|
|
const tokenResponse = await fetch(`${nginxSettings.url}/api/tokens`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
identity: nginxSettings.username,
|
|
secret: nginxSettings.password
|
|
})
|
|
});
|
|
|
|
console.log('Token Response Status:', tokenResponse.status);
|
|
console.log('Token Response Headers:', Object.fromEntries(tokenResponse.headers.entries()));
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorText = await tokenResponse.text();
|
|
console.error('Token Error Response:', errorText);
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
throw new Error(`Failed to authenticate with NGINX: ${errorJson.message || errorText}`);
|
|
} catch (e) {
|
|
throw new Error(`Failed to authenticate with NGINX: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
console.log('Token Data:', { ...tokenData, token: tokenData.token ? '***' : null });
|
|
const token = tokenData.token;
|
|
|
|
if (!token) {
|
|
throw new Error('No token received from NGINX Proxy Manager');
|
|
}
|
|
|
|
// Store the token in sessionStorage for later use
|
|
sessionStorage.setItem('nginxToken', token);
|
|
|
|
// Check if a proxy host already exists with the same properties
|
|
const proxyHostsResponse = await fetch(`${nginxSettings.url}/api/nginx/proxy-hosts`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
if (!proxyHostsResponse.ok) {
|
|
throw new Error('Failed to fetch existing proxy hosts');
|
|
}
|
|
const proxyHosts = await proxyHostsResponse.json();
|
|
const existingProxy = proxyHosts.find(ph => {
|
|
const sameDomains = Array.isArray(ph.domain_names) &&
|
|
ph.domain_names.length === domains.length &&
|
|
domains.every(d => ph.domain_names.includes(d));
|
|
return (
|
|
sameDomains &&
|
|
ph.forward_scheme === 'http' &&
|
|
ph.forward_host === '192.168.68.124' &&
|
|
parseInt(ph.forward_port) === parseInt(port)
|
|
);
|
|
});
|
|
|
|
let result;
|
|
if (existingProxy) {
|
|
console.log('Found existing proxy host:', existingProxy);
|
|
result = existingProxy;
|
|
} else {
|
|
// Update status to show we're creating the proxy host
|
|
updateStatus('proxy', 'Creating proxy host configuration...', 'info');
|
|
|
|
const proxyHostData = {
|
|
domain_names: domains,
|
|
forward_scheme: 'http',
|
|
forward_host: '192.168.68.124',
|
|
forward_port: parseInt(port),
|
|
ssl_forced: false,
|
|
caching_enabled: true,
|
|
block_exploits: true,
|
|
allow_websocket_upgrade: true,
|
|
http2_support: false,
|
|
hsts_enabled: false,
|
|
hsts_subdomains: false,
|
|
certificate_id: sslCertificateId,
|
|
meta: {
|
|
letsencrypt_agree: true,
|
|
dns_challenge: false
|
|
}
|
|
};
|
|
|
|
console.log('Creating proxy host with data:', proxyHostData);
|
|
|
|
// Create the proxy host with NGINX
|
|
const response = await fetch(`${nginxSettings.url}/api/nginx/proxy-hosts`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify(proxyHostData)
|
|
});
|
|
|
|
console.log('Proxy Host Response Status:', response.status);
|
|
console.log('Proxy Host Response Headers:', Object.fromEntries(response.headers.entries()));
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Proxy Host Error Response:', errorText);
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
const errorMessage = errorJson.error?.message || errorText;
|
|
// Check if the error is about a domain already being in use
|
|
if (errorMessage.includes('is already in use')) {
|
|
const domain = errorMessage.split(' ')[0];
|
|
throw new Error(`Domain ${domain} is already configured in NGINX Proxy Manager. Please remove it from NGINX Proxy Manager and try again.`);
|
|
}
|
|
throw new Error(`Failed to create proxy host: ${errorMessage}`);
|
|
} catch (e) {
|
|
if (e.message.includes('is already configured in NGINX Proxy Manager')) {
|
|
throw e; // Re-throw the domain in use error
|
|
}
|
|
throw new Error(`Failed to create proxy host: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
result = await response.json();
|
|
console.log('Proxy Host Creation Result:', result);
|
|
}
|
|
|
|
// Create a detailed success message with NGINX Proxy results
|
|
const successDetails = `
|
|
<div class="mt-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">NGINX Proxy Results</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Proxy Host ID</td>
|
|
<td>${result.id || 'N/A'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Domains</td>
|
|
<td>${domains.join(', ')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Forward Scheme</td>
|
|
<td>http</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Forward Host</td>
|
|
<td>192.168.68.124</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Forward Port</td>
|
|
<td>${parseInt(port)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>SSL Status</td>
|
|
<td>
|
|
<span class="badge bg-success">Forced</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>SSL Certificate</td>
|
|
<td>
|
|
<span class="badge bg-success">Using Certificate ID: ${sslCertificateId}</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Security Features</td>
|
|
<td>
|
|
<span class="badge bg-success me-1">Block Exploits</span>
|
|
<span class="badge bg-success me-1">HSTS</span>
|
|
<span class="badge bg-success">HTTP/2</span>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Performance</td>
|
|
<td>
|
|
<span class="badge bg-success me-1">Caching</span>
|
|
<span class="badge bg-success">WebSocket</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Update the proxy step to show success and add the results
|
|
const proxyStep = document.querySelectorAll('.step-item')[5];
|
|
proxyStep.classList.remove('active');
|
|
proxyStep.classList.add('completed');
|
|
const statusText = proxyStep.querySelector('.step-status');
|
|
statusText.textContent = existingProxy ? 'Using existing proxy host' : 'Successfully created proxy host';
|
|
statusText.after(document.createRange().createContextualFragment(successDetails));
|
|
|
|
return {
|
|
success: true,
|
|
data: result
|
|
};
|
|
} catch (error) {
|
|
console.error('Error creating proxy host:', error);
|
|
// Update status with error message
|
|
updateStatus('proxy', `Failed: ${error.message}`, 'error');
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function generateSSLCertificate(domains) {
|
|
try {
|
|
// Get NGINX settings from the template
|
|
const nginxSettings = {
|
|
url: window.nginxSettings?.url || '',
|
|
username: window.nginxSettings?.username || '',
|
|
password: window.nginxSettings?.password || '',
|
|
email: window.nginxSettings?.email || ''
|
|
};
|
|
|
|
// Get a fresh token from NGINX
|
|
const tokenResponse = await fetch(`${nginxSettings.url}/api/tokens`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
identity: nginxSettings.username,
|
|
secret: nginxSettings.password
|
|
})
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorText = await tokenResponse.text();
|
|
console.error('Token Error Response:', errorText);
|
|
throw new Error(`Failed to authenticate with NGINX: ${errorText}`);
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json();
|
|
const token = tokenData.token;
|
|
|
|
if (!token) {
|
|
throw new Error('No token received from NGINX Proxy Manager');
|
|
}
|
|
|
|
// First, check if a certificate already exists for these domains
|
|
const checkResponse = await fetch(`${nginxSettings.url}/api/nginx/certificates`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!checkResponse.ok) {
|
|
throw new Error('Failed to check existing certificates');
|
|
}
|
|
|
|
const existingCertificates = await checkResponse.json();
|
|
const existingCertificate = existingCertificates.find(cert => {
|
|
const certDomains = cert.domain_names || [];
|
|
return domains.every(domain => certDomains.includes(domain)) &&
|
|
certDomains.length === domains.length;
|
|
});
|
|
|
|
let result;
|
|
let usedExisting = false;
|
|
if (existingCertificate) {
|
|
console.log('Found existing certificate:', existingCertificate);
|
|
result = existingCertificate;
|
|
usedExisting = true;
|
|
} else {
|
|
// Create the SSL certificate directly with NGINX
|
|
const requestBody = {
|
|
domain_names: domains,
|
|
meta: {
|
|
letsencrypt_email: nginxSettings.email,
|
|
letsencrypt_agree: true,
|
|
dns_challenge: false
|
|
},
|
|
provider: 'letsencrypt'
|
|
};
|
|
console.log('Request Body:', requestBody);
|
|
|
|
const response = await fetch(`${nginxSettings.url}/api/nginx/certificates`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
console.log('Response Status:', response.status);
|
|
console.log('Response Headers:', Object.fromEntries(response.headers.entries()));
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Certificate creation error:', errorText);
|
|
throw new Error(`Failed to generate SSL certificate: ${errorText}`);
|
|
}
|
|
|
|
result = await response.json();
|
|
console.log('Certificate creation result:', result);
|
|
}
|
|
|
|
// Update the SSL step to show success
|
|
const sslStep = document.querySelectorAll('.step-item')[4];
|
|
sslStep.classList.remove('active');
|
|
sslStep.classList.add('completed');
|
|
const sslStatusText = sslStep.querySelector('.step-status');
|
|
sslStatusText.textContent = usedExisting ?
|
|
'Using existing SSL certificate' :
|
|
'SSL certificate generated successfully';
|
|
|
|
// Always add SSL certificate details
|
|
const sslDetails = document.createElement('div');
|
|
sslDetails.className = 'mt-3';
|
|
sslDetails.innerHTML = `
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h6 class="card-title mb-3">SSL Certificate Details</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>Property</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Certificate ID</td>
|
|
<td>${result.id || 'N/A'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Domains</td>
|
|
<td>${(result.domain_names || domains).join(', ')}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Provider</td>
|
|
<td>${result.provider || 'Let\'s Encrypt'}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
sslStatusText.after(sslDetails);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
certificate: result
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error('Error generating SSL certificate:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
function scrollToStep(stepElement) {
|
|
const container = document.querySelector('.launch-steps-container');
|
|
const containerRect = container.getBoundingClientRect();
|
|
const elementRect = stepElement.getBoundingClientRect();
|
|
|
|
// Calculate the scroll position to center the element in the container
|
|
const scrollTop = elementRect.top - containerRect.top - (containerRect.height / 2) + (elementRect.height / 2);
|
|
|
|
// Smooth scroll to the element
|
|
container.scrollTo({
|
|
top: container.scrollTop + scrollTop,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
|
|
function updateStep(stepNumber, title, description) {
|
|
return new Promise((resolve) => {
|
|
// Update the current step in the header
|
|
document.getElementById('currentStep').textContent = title;
|
|
document.getElementById('stepDescription').textContent = description;
|
|
|
|
// Calculate progress based on total number of steps (18 steps total)
|
|
const totalSteps = 18;
|
|
const progress = ((stepNumber - 1) / (totalSteps - 1)) * 100;
|
|
const progressBar = document.getElementById('launchProgress');
|
|
progressBar.style.width = `${progress}%`;
|
|
progressBar.textContent = `${Math.round(progress)}%`;
|
|
|
|
// Update step items
|
|
const steps = document.querySelectorAll('.step-item');
|
|
steps.forEach((item, index) => {
|
|
const step = index + 1;
|
|
item.classList.remove('active', 'completed', 'failed');
|
|
|
|
if (step < stepNumber) {
|
|
item.classList.add('completed');
|
|
item.querySelector('.step-status').textContent = 'Completed';
|
|
} else if (step === stepNumber) {
|
|
item.classList.add('active');
|
|
item.querySelector('.step-status').textContent = description;
|
|
// Scroll to the active step
|
|
scrollToStep(item);
|
|
}
|
|
});
|
|
|
|
// Simulate some work being done
|
|
setTimeout(resolve, 1000);
|
|
});
|
|
}
|
|
|
|
function showError(message) {
|
|
const errorContainer = document.getElementById('errorContainer');
|
|
const errorMessage = document.getElementById('errorMessage');
|
|
|
|
errorMessage.textContent = message;
|
|
errorContainer.style.display = 'block';
|
|
|
|
// Update the current step to show error
|
|
const currentStep = document.querySelector('.step-item.active');
|
|
if (currentStep) {
|
|
currentStep.classList.add('failed');
|
|
currentStep.querySelector('.step-status').textContent = 'Failed: ' + message;
|
|
}
|
|
}
|
|
|
|
function retryLaunch() {
|
|
// Reload the page to start over
|
|
window.location.reload();
|
|
}
|
|
|
|
// Add new function to download docker-compose.yml
|
|
async function downloadDockerCompose(repo, branch) {
|
|
try {
|
|
const response = await fetch('/api/admin/download-docker-compose', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
repository: repo,
|
|
branch: branch
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'Failed to download docker-compose.yml');
|
|
}
|
|
|
|
const result = await response.json();
|
|
return {
|
|
success: true,
|
|
content: result.content,
|
|
commit_hash: result.commit_hash,
|
|
latest_tag: result.latest_tag
|
|
};
|
|
} catch (error) {
|
|
console.error('Error downloading docker-compose.yml:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add new function to save instance data
|
|
async function saveInstanceData(instanceData) {
|
|
try {
|
|
console.log('Saving instance data:', instanceData);
|
|
|
|
// First check if instance already exists
|
|
const checkResponse = await fetch('/instances');
|
|
const text = await checkResponse.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
|
|
// Look for an existing instance with the same name
|
|
const existingInstance = Array.from(doc.querySelectorAll('table tbody tr')).find(row => {
|
|
const nameCell = row.querySelector('td:first-child');
|
|
return nameCell && nameCell.textContent.trim() === instanceData.port;
|
|
});
|
|
|
|
if (existingInstance) {
|
|
console.log('Instance already exists:', instanceData.port);
|
|
// Update existing instance with new data
|
|
const updateResponse = await fetch('/api/admin/save-instance', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
name: instanceData.port,
|
|
port: instanceData.port,
|
|
domains: instanceData.domains,
|
|
stack_id: instanceData.stack_id || '',
|
|
stack_name: instanceData.stack_name,
|
|
status: instanceData.status,
|
|
repository: instanceData.repository,
|
|
branch: instanceData.branch,
|
|
deployed_version: instanceData.deployed_version,
|
|
deployed_branch: instanceData.deployed_branch,
|
|
payment_plan: instanceData.payment_plan || 'Basic'
|
|
})
|
|
});
|
|
|
|
if (!updateResponse.ok) {
|
|
const errorText = await updateResponse.text();
|
|
console.error('Error updating instance:', errorText);
|
|
throw new Error(`Failed to update instance data: ${updateResponse.status} ${updateResponse.statusText}`);
|
|
}
|
|
|
|
const updateResult = await updateResponse.json();
|
|
console.log('Instance updated:', updateResult);
|
|
|
|
return {
|
|
success: true,
|
|
data: updateResult.data
|
|
};
|
|
}
|
|
|
|
// If instance doesn't exist, create it
|
|
const response = await fetch('/api/admin/save-instance', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
name: instanceData.port,
|
|
port: instanceData.port,
|
|
domains: instanceData.domains,
|
|
stack_id: instanceData.stack_id || '',
|
|
stack_name: instanceData.stack_name,
|
|
status: instanceData.status,
|
|
repository: instanceData.repository,
|
|
branch: instanceData.branch,
|
|
deployed_version: instanceData.deployed_version,
|
|
deployed_branch: instanceData.deployed_branch,
|
|
payment_plan: instanceData.payment_plan || 'Basic'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Error response:', errorText);
|
|
throw new Error(`Failed to save instance data: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Instance data saved:', result);
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error saving instance data:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function checkInstanceHealth(instanceUrl) {
|
|
const maxRetries = 120; // 120 retries * 5 seconds = 10 minutes total
|
|
const baseDelay = 5000; // 5 seconds base delay
|
|
let currentAttempt = 1;
|
|
const startTime = Date.now();
|
|
const maxTotalTime = 10 * 60 * 1000; // 10 minutes in milliseconds
|
|
|
|
while (currentAttempt <= maxRetries) {
|
|
try {
|
|
// Check if we've exceeded the total timeout
|
|
if (Date.now() - startTime > maxTotalTime) {
|
|
throw new Error('Health check timeout: 10 minutes exceeded');
|
|
}
|
|
|
|
// First get the instance ID from the database
|
|
const response = await fetch('/instances');
|
|
const text = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
|
|
// Find the instance row that matches our URL
|
|
const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => {
|
|
const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column
|
|
return urlCell && urlCell.textContent.trim() === instanceUrl;
|
|
});
|
|
|
|
if (!instanceRow) {
|
|
throw new Error('Instance not found in database');
|
|
}
|
|
|
|
// Get the instance ID from the status badge's data attribute
|
|
const statusBadge = instanceRow.querySelector('[data-instance-id]');
|
|
if (!statusBadge) {
|
|
throw new Error('Could not find instance ID');
|
|
}
|
|
|
|
const instanceId = statusBadge.dataset.instanceId;
|
|
|
|
// Now use the instance ID to check status
|
|
const statusResponse = await fetch(`/instances/${instanceId}/status`);
|
|
if (!statusResponse.ok) {
|
|
throw new Error(`Health check failed with status ${statusResponse.status}`);
|
|
}
|
|
|
|
const data = await statusResponse.json();
|
|
|
|
// Update the health check step - check if we're in update mode or launch mode
|
|
const isUpdate = window.isUpdate;
|
|
const healthStepIndex = isUpdate ? 4 : 10; // Different indices for update vs launch
|
|
const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex];
|
|
|
|
if (healthStepElement) {
|
|
healthStepElement.classList.remove('active');
|
|
healthStepElement.classList.add('completed');
|
|
const statusText = healthStepElement.querySelector('.step-status');
|
|
|
|
if (data.status === 'active') {
|
|
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
|
statusText.textContent = `Instance is healthy (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed)`;
|
|
|
|
// Update progress bar to 100% if it exists
|
|
const progressBar = document.getElementById('healthProgress');
|
|
const progressText = document.getElementById('healthProgressText');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '100%';
|
|
progressBar.classList.remove('progress-bar-animated');
|
|
progressBar.classList.add('bg-success');
|
|
progressText.textContent = `Health check completed successfully in ${elapsedTime}s`;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: data,
|
|
attempts: currentAttempt,
|
|
elapsedTime: elapsedTime
|
|
};
|
|
} else if (data.status === 'inactive') {
|
|
console.log(`Stack ${stackName} is inactive, continuing to poll...`);
|
|
lastKnownStatus = 'inactive';
|
|
if (progressText) {
|
|
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
} else if (data.status === 'starting') {
|
|
console.log(`Stack ${stackName} is starting up, continuing to poll...`);
|
|
lastKnownStatus = 'starting';
|
|
if (progressText) {
|
|
progressText.textContent = `Stack is initializing (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
} else {
|
|
throw new Error('Instance is not healthy');
|
|
}
|
|
} else {
|
|
// If step element doesn't exist, just log the status
|
|
console.log(`Health check - Instance status: ${data.status}`);
|
|
if (data.status === 'active') {
|
|
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
|
return {
|
|
success: true,
|
|
data: data,
|
|
attempts: currentAttempt,
|
|
elapsedTime: elapsedTime
|
|
};
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Health check attempt ${currentAttempt} failed:`, error);
|
|
|
|
// Update status to show current attempt and elapsed time - check if step element exists
|
|
const isUpdate = window.isUpdate;
|
|
const healthStepIndex = isUpdate ? 4 : 10;
|
|
const healthStepElement = document.querySelectorAll('.step-item')[healthStepIndex];
|
|
|
|
if (healthStepElement) {
|
|
const statusText = healthStepElement.querySelector('.step-status');
|
|
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
|
statusText.textContent = `Health check failed (Attempt ${currentAttempt}/${maxRetries}, ${elapsedTime}s elapsed): ${error.message}`;
|
|
|
|
// Update progress bar
|
|
const progressBar = document.getElementById('healthProgress');
|
|
const progressText = document.getElementById('healthProgressText');
|
|
if (progressBar && progressText) {
|
|
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
|
|
progressBar.style.width = `${progressPercent}%`;
|
|
progressBar.textContent = `${Math.round(progressPercent)}%`;
|
|
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
|
|
}
|
|
}
|
|
|
|
if (currentAttempt === maxRetries || (Date.now() - startTime > maxTotalTime)) {
|
|
// Update progress bar to show failure if it exists
|
|
const progressBar = document.getElementById('healthProgress');
|
|
const progressText = document.getElementById('healthProgressText');
|
|
if (progressBar && progressText) {
|
|
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
|
progressBar.classList.remove('progress-bar-animated');
|
|
progressBar.classList.add('bg-danger');
|
|
progressText.textContent = `Health check failed after ${currentAttempt} attempts (${elapsedTime}s)`;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: `Health check failed after ${currentAttempt} attempts (${Math.round((Date.now() - startTime) / 1000)}s): ${error.message}`
|
|
};
|
|
}
|
|
|
|
// Wait before next attempt (5 seconds base delay)
|
|
await new Promise(resolve => setTimeout(resolve, baseDelay));
|
|
currentAttempt++;
|
|
|
|
// Update progress bar in real-time if it exists
|
|
const elapsedTime = Math.round((Date.now() - startTime) / 1000);
|
|
updateHealthProgress(currentAttempt, maxRetries, elapsedTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateHealthProgress(currentAttempt, maxRetries, elapsedTime) {
|
|
const progressBar = document.getElementById('healthProgress');
|
|
const progressText = document.getElementById('healthProgressText');
|
|
|
|
if (progressBar && progressText) {
|
|
const progressPercent = Math.min((currentAttempt / maxRetries) * 100, 100);
|
|
progressBar.style.width = `${progressPercent}%`;
|
|
progressBar.textContent = `${Math.round(progressPercent)}%`;
|
|
progressText.textContent = `Attempt ${currentAttempt}/${maxRetries} (${elapsedTime}s elapsed)`;
|
|
}
|
|
}
|
|
|
|
async function applyCompanyInformation(instanceUrl, company) {
|
|
try {
|
|
console.log('Applying company information to:', instanceUrl);
|
|
|
|
const response = await fetch('/api/admin/apply-company-information', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
instance_url: instanceUrl,
|
|
company_data: company
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to apply company information: ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Company information applied successfully:', result);
|
|
|
|
return {
|
|
success: true,
|
|
message: result.message,
|
|
data: result.data
|
|
};
|
|
} catch (error) {
|
|
console.error('Error applying company information:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function applyColors(instanceUrl, colors) {
|
|
try {
|
|
console.log('Applying colors to:', instanceUrl);
|
|
|
|
const response = await fetch('/api/admin/apply-colors', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
instance_url: instanceUrl,
|
|
colors_data: colors
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to apply colors: ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Colors applied successfully:', result);
|
|
|
|
return {
|
|
success: true,
|
|
message: result.message,
|
|
data: result.data
|
|
};
|
|
} catch (error) {
|
|
console.error('Error applying colors:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function updateAdminCredentials(instanceUrl, email) {
|
|
try {
|
|
console.log('Updating admin credentials for:', instanceUrl);
|
|
|
|
const response = await fetch('/api/admin/update-admin-credentials', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
instance_url: instanceUrl,
|
|
email: email
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to update admin credentials: ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Admin credentials updated successfully:', result);
|
|
|
|
return {
|
|
success: true,
|
|
message: result.message,
|
|
data: result.data
|
|
};
|
|
} catch (error) {
|
|
console.error('Error updating admin credentials:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function copySmtpSettings(instanceUrl) {
|
|
try {
|
|
console.log('Copying SMTP settings to:', instanceUrl);
|
|
|
|
const response = await fetch('/api/admin/copy-smtp-settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
instance_url: instanceUrl
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to copy SMTP settings: ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('SMTP settings copied successfully:', result);
|
|
|
|
return {
|
|
success: true,
|
|
message: result.message,
|
|
data: result.data
|
|
};
|
|
} catch (error) {
|
|
console.error('Error copying SMTP settings:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function sendCompletionEmail(instanceUrl, company, credentials) {
|
|
try {
|
|
console.log('Sending completion email to:', company.email);
|
|
|
|
const response = await fetch('/api/admin/send-completion-email', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
instance_url: instanceUrl,
|
|
company_data: company,
|
|
credentials_data: credentials
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to send completion email: ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Email sent successfully:', result);
|
|
|
|
return {
|
|
success: true,
|
|
message: result.message,
|
|
data: result.data
|
|
};
|
|
} catch (error) {
|
|
console.error('Error sending completion email:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add new function to check if stack exists
|
|
async function checkStackExists(stackName) {
|
|
try {
|
|
const response = await fetch('/api/admin/check-stack-status', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
stack_name: stackName
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return {
|
|
exists: true,
|
|
status: result.data?.status || 'unknown',
|
|
data: result.data
|
|
};
|
|
} else {
|
|
return {
|
|
exists: false,
|
|
status: 'not_found'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking stack existence:', error);
|
|
return {
|
|
exists: false,
|
|
status: 'error',
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Add new function to deploy stack
|
|
async function deployStack(dockerComposeContent, stackName, port) {
|
|
try {
|
|
// Get the launch data from sessionStorage to access pricing tier info
|
|
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
|
|
|
|
// Fetch the pricing tier details to get the actual quota values
|
|
let pricingTierDetails = null;
|
|
if (launchData?.pricingTier?.id) {
|
|
try {
|
|
const pricingResponse = await fetch(`/api/admin/pricing-plans/${launchData.pricingTier.id}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
}
|
|
});
|
|
|
|
if (pricingResponse.ok) {
|
|
const pricingData = await pricingResponse.json();
|
|
if (pricingData.success) {
|
|
pricingTierDetails = pricingData.plan;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch pricing tier details:', error);
|
|
}
|
|
}
|
|
|
|
// Update volume names in docker-compose content to match stack naming convention
|
|
let modifiedDockerComposeContent = dockerComposeContent;
|
|
|
|
// Extract timestamp from stack name (format: docupulse_PORT_TIMESTAMP)
|
|
const stackNameParts = stackName.split('_');
|
|
if (stackNameParts.length >= 3) {
|
|
const timestamp = stackNameParts.slice(2).join('_'); // Get everything after port
|
|
const baseName = `docupulse_${port}_${timestamp}`;
|
|
|
|
// Replace volume names to match stack naming convention
|
|
modifiedDockerComposeContent = modifiedDockerComposeContent.replace(
|
|
/name: docupulse_\$\{PORT:-10335\}_postgres_data/g,
|
|
`name: ${baseName}_postgres_data`
|
|
);
|
|
modifiedDockerComposeContent = modifiedDockerComposeContent.replace(
|
|
/name: docupulse_\$\{PORT:-10335\}_uploads/g,
|
|
`name: ${baseName}_uploads`
|
|
);
|
|
|
|
console.log(`Updated volume names to match stack naming convention: ${baseName}`);
|
|
}
|
|
|
|
// First, attempt to deploy the stack
|
|
console.log('Making stack deployment request to /api/admin/deploy-stack');
|
|
console.log('Stack name:', stackName);
|
|
console.log('Port:', port);
|
|
console.log('Modified docker-compose content length:', modifiedDockerComposeContent.length);
|
|
|
|
const response = await fetch('/api/admin/deploy-stack', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
name: stackName,
|
|
StackFileContent: modifiedDockerComposeContent,
|
|
Env: [
|
|
{
|
|
name: 'PORT',
|
|
value: port.toString()
|
|
},
|
|
{
|
|
name: 'ISMASTER',
|
|
value: 'false'
|
|
},
|
|
{
|
|
name: 'APP_VERSION',
|
|
value: window.currentDeploymentVersion || 'unknown'
|
|
},
|
|
{
|
|
name: 'GIT_COMMIT',
|
|
value: window.currentDeploymentCommit || 'unknown'
|
|
},
|
|
{
|
|
name: 'GIT_BRANCH',
|
|
value: window.currentDeploymentBranch || 'unknown'
|
|
},
|
|
{
|
|
name: 'DEPLOYED_AT',
|
|
value: new Date().toISOString()
|
|
},
|
|
// Pricing tier environment variables with actual quota values
|
|
{
|
|
name: 'PRICING_TIER_ID',
|
|
value: launchData?.pricingTier?.id?.toString() || '0'
|
|
},
|
|
{
|
|
name: 'PRICING_TIER_NAME',
|
|
value: launchData?.pricingTier?.name || 'Unknown'
|
|
},
|
|
{
|
|
name: 'ROOM_QUOTA',
|
|
value: pricingTierDetails?.room_quota?.toString() || '0'
|
|
},
|
|
{
|
|
name: 'CONVERSATION_QUOTA',
|
|
value: pricingTierDetails?.conversation_quota?.toString() || '0'
|
|
},
|
|
{
|
|
name: 'STORAGE_QUOTA_GB',
|
|
value: pricingTierDetails?.storage_quota_gb?.toString() || '0'
|
|
},
|
|
{
|
|
name: 'MANAGER_QUOTA',
|
|
value: pricingTierDetails?.manager_quota?.toString() || '0'
|
|
},
|
|
{
|
|
name: 'ADMIN_QUOTA',
|
|
value: pricingTierDetails?.admin_quota?.toString() || '0'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
|
|
console.log('Response status:', response.status);
|
|
console.log('Response ok:', response.ok);
|
|
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
|
|
|
|
// Log additional response details for debugging
|
|
if (response.status === 502) {
|
|
console.log('HTTP 502 Bad Gateway detected - this usually means Portainer is busy or slow to respond');
|
|
console.log('The stack deployment may still be in progress despite this error');
|
|
} else if (response.status === 504) {
|
|
console.log('HTTP 504 Gateway Timeout detected - the request took too long');
|
|
console.log('This is expected for long-running stack deployments');
|
|
}
|
|
|
|
// Handle 504 Gateway Timeout as successful initiation
|
|
if (response.status === 504) {
|
|
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack creation was initiated
|
|
console.log('Starting to poll for stack status after 504 timeout...');
|
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Failed to deploy stack';
|
|
console.log('Response not ok, status:', response.status);
|
|
|
|
// Handle 502 Bad Gateway and 504 Gateway Timeout as potential success cases
|
|
// These errors often occur when Portainer is busy or slow to respond, but the operation may still succeed
|
|
if (response.status === 502 || response.status === 504) {
|
|
console.log(`Received HTTP ${response.status} - stack creation may still be in progress`);
|
|
console.log('HTTP 502 (Bad Gateway) typically means Portainer is busy or slow to respond');
|
|
console.log('HTTP 504 (Gateway Timeout) means the request took too long');
|
|
console.log('In both cases, we continue monitoring since the operation may still succeed');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = `Stack creation initiated (HTTP ${response.status}, but continuing to monitor)...`;
|
|
}
|
|
|
|
// Start polling immediately since the stack creation was initiated
|
|
console.log(`Starting to poll for stack status after HTTP ${response.status}...`);
|
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.error || errorMessage;
|
|
console.log('Parsed error data:', errorData);
|
|
} catch (parseError) {
|
|
console.log('Failed to parse JSON error, trying text:', parseError);
|
|
// If JSON parsing fails, try to get text content
|
|
try {
|
|
const errorText = await response.text();
|
|
console.log('Error text content:', errorText);
|
|
if (errorText.includes('504 Gateway Time-out') || errorText.includes('504 Gateway Timeout')) {
|
|
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack creation was initiated
|
|
console.log('Starting to poll for stack status after 504 timeout...');
|
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
} else {
|
|
errorMessage = `HTTP ${response.status}: ${errorText}`;
|
|
}
|
|
} catch (textError) {
|
|
console.log('Failed to get error text:', textError);
|
|
errorMessage = `HTTP ${response.status}: Failed to parse response`;
|
|
}
|
|
}
|
|
console.log('Throwing error:', errorMessage);
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Stack deployment initiated:', result);
|
|
|
|
// If stack is being created, poll for status
|
|
if (result.data.status === 'creating') {
|
|
console.log('Stack is being created, polling for status...');
|
|
const pollResult = await pollStackStatus(stackName, 10 * 60 * 1000); // 10 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
// Return success result with response data
|
|
return {
|
|
success: true,
|
|
data: result.data
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error deploying stack:', error);
|
|
|
|
// Check if this is a 502 or 504 error that should be handled as a success
|
|
if (error.message && (
|
|
error.message.includes('504 Gateway Time-out') ||
|
|
error.message.includes('504 Gateway Timeout') ||
|
|
error.message.includes('timed out') ||
|
|
error.message.includes('502') ||
|
|
error.message.includes('Bad Gateway')
|
|
)) {
|
|
console.log('Detected HTTP 502/504 error in catch block - treating as successful initiation');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack creation initiated (HTTP error, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack creation was initiated
|
|
console.log('Starting to poll for stack status after HTTP 502/504 error from catch block...');
|
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Function to poll stack status
|
|
async function pollStackStatus(stackIdentifier, maxWaitTime = 15 * 60 * 1000) {
|
|
const startTime = Date.now();
|
|
const pollInterval = 5000; // 5 seconds
|
|
let attempts = 0;
|
|
let lastKnownStatus = 'unknown';
|
|
|
|
// Validate stack identifier (can be name or ID)
|
|
if (!stackIdentifier || typeof stackIdentifier !== 'string') {
|
|
console.error('Invalid stack identifier provided to pollStackStatus:', stackIdentifier);
|
|
return {
|
|
success: false,
|
|
error: `Invalid stack identifier: ${stackIdentifier}`,
|
|
data: {
|
|
name: stackIdentifier,
|
|
status: 'error'
|
|
}
|
|
};
|
|
}
|
|
|
|
console.log(`Starting to poll stack status for: ${stackIdentifier} (max wait: ${maxWaitTime / 1000}s)`);
|
|
|
|
// Update progress indicator
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
|
|
while (Date.now() - startTime < maxWaitTime) {
|
|
attempts++;
|
|
console.log(`Polling attempt ${attempts} for stack: ${stackIdentifier}`);
|
|
|
|
// Update progress - start at 25% if we came from a 504 timeout, otherwise start at 0%
|
|
const elapsed = Date.now() - startTime;
|
|
const baseProgress = progressBar && progressBar.style.width === '25%' ? 25 : 0;
|
|
const progress = Math.min(baseProgress + (elapsed / maxWaitTime) * 70, 95); // Cap at 95% until complete
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = `${progress}%`;
|
|
progressBar.textContent = `${Math.round(progress)}%`;
|
|
}
|
|
|
|
try {
|
|
// Determine if this is a stack ID (numeric) or stack name
|
|
const isStackId = /^\d+$/.test(stackIdentifier);
|
|
const requestBody = isStackId ?
|
|
{ stack_id: stackIdentifier } :
|
|
{ stack_name: stackIdentifier };
|
|
|
|
console.log(`Sending stack status check request:`, requestBody);
|
|
|
|
const response = await fetch('/api/admin/check-stack-status', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
timeout: 30000 // 30 second timeout for status checks
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log(`Stack status check result:`, result);
|
|
|
|
if (result.data && result.data.status === 'active') {
|
|
console.log(`Stack ${stackIdentifier} is now active!`);
|
|
// Update progress to 100%
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '100%';
|
|
progressBar.classList.remove('progress-bar-animated');
|
|
progressBar.classList.add('bg-success');
|
|
progressText.textContent = 'Stack is now active and running!';
|
|
}
|
|
return {
|
|
success: true,
|
|
data: {
|
|
name: result.data.name || stackIdentifier,
|
|
id: result.data.stack_id || stackIdentifier,
|
|
status: 'active'
|
|
}
|
|
};
|
|
} else if (result.data && result.data.status === 'partial') {
|
|
console.log(`Stack ${stackIdentifier} is partially running, continuing to poll...`);
|
|
lastKnownStatus = 'partial';
|
|
if (progressText) {
|
|
progressText.textContent = `Stack is partially running (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
} else if (result.data && result.data.status === 'inactive') {
|
|
console.log(`Stack ${stackIdentifier} is inactive, continuing to poll...`);
|
|
lastKnownStatus = 'inactive';
|
|
if (progressText) {
|
|
progressText.textContent = `Stack is starting up (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
} else if (result.data && result.data.status === 'starting') {
|
|
console.log(`Stack ${stackIdentifier} exists and is starting up - continuing to next step`);
|
|
// Stack exists, we can continue - no need to wait for all services
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '100%';
|
|
progressBar.textContent = '100%';
|
|
progressBar.classList.remove('progress-bar-animated');
|
|
progressBar.classList.add('bg-success');
|
|
progressText.textContent = 'Stack created successfully!';
|
|
}
|
|
return {
|
|
success: true,
|
|
data: {
|
|
name: result.data.name || stackIdentifier,
|
|
id: result.data.stack_id || stackIdentifier,
|
|
status: 'starting'
|
|
}
|
|
};
|
|
} else {
|
|
console.log(`Stack ${stackIdentifier} status unknown, continuing to poll...`);
|
|
lastKnownStatus = 'unknown';
|
|
if (progressText) {
|
|
progressText.textContent = `Checking stack status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
}
|
|
} else if (response.status === 404) {
|
|
console.log(`Stack ${stackIdentifier} not found yet, continuing to poll...`);
|
|
lastKnownStatus = 'not_found';
|
|
if (progressText) {
|
|
progressText.textContent = `Stack not found yet (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
} else {
|
|
console.log(`Stack status check failed with status ${response.status}, continuing to poll...`);
|
|
if (progressText) {
|
|
progressText.textContent = `Status check failed (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error polling stack status (attempt ${attempts}):`, error);
|
|
if (progressText) {
|
|
progressText.textContent = `Error checking status (${attempts} attempts, ${Math.round(elapsed / 1000)}s elapsed)...`;
|
|
}
|
|
}
|
|
|
|
// Wait before next poll
|
|
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
}
|
|
|
|
// If we get here, we've timed out
|
|
console.error(`Stack status polling timed out after ${maxWaitTime / 1000} seconds`);
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '100%';
|
|
progressBar.classList.remove('progress-bar-animated');
|
|
progressBar.classList.add('bg-warning');
|
|
progressText.textContent = `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)}s. Last known status: ${lastKnownStatus}`;
|
|
}
|
|
|
|
// Return a more informative error message
|
|
const statusMessage = lastKnownStatus !== 'unknown' ? ` (last known status: ${lastKnownStatus})` : '';
|
|
return {
|
|
success: false,
|
|
error: `Stack deployment timed out after ${Math.round(maxWaitTime / 1000)} seconds${statusMessage}. The stack may still be deploying in the background.`,
|
|
data: {
|
|
name: stackIdentifier,
|
|
status: lastKnownStatus
|
|
}
|
|
};
|
|
}
|
|
|
|
// Helper function to generate unique stack names with timestamp
|
|
function generateStackName(port) {
|
|
const now = new Date();
|
|
const timestamp = now.getFullYear().toString() +
|
|
(now.getMonth() + 1).toString().padStart(2, '0') +
|
|
now.getDate().toString().padStart(2, '0') + '_' +
|
|
now.getHours().toString().padStart(2, '0') +
|
|
now.getMinutes().toString().padStart(2, '0') +
|
|
now.getSeconds().toString().padStart(2, '0');
|
|
return `docupulse_${port}_${timestamp}`;
|
|
}
|
|
|
|
// Add new function to update existing stack
|
|
async function updateStack(dockerComposeContent, stackId, port, instanceData = null) {
|
|
try {
|
|
console.log('Updating existing stack:', stackId);
|
|
console.log('Port:', port);
|
|
console.log('Modified docker-compose content length:', dockerComposeContent.length);
|
|
|
|
// For updates, we only need to update version-related environment variables
|
|
// All other environment variables (pricing tiers, quotas, etc.) should be preserved
|
|
// We also preserve the existing docker-compose configuration
|
|
const envVars = [
|
|
{
|
|
name: 'APP_VERSION',
|
|
value: window.currentDeploymentVersion || 'unknown'
|
|
},
|
|
{
|
|
name: 'GIT_COMMIT',
|
|
value: window.currentDeploymentCommit || 'unknown'
|
|
},
|
|
{
|
|
name: 'GIT_BRANCH',
|
|
value: window.currentDeploymentBranch || 'unknown'
|
|
},
|
|
{
|
|
name: 'DEPLOYED_AT',
|
|
value: new Date().toISOString()
|
|
}
|
|
];
|
|
|
|
console.log('Updating stack with version environment variables:', envVars);
|
|
console.log('Preserving existing docker-compose configuration');
|
|
|
|
const response = await fetch('/api/admin/update-stack', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
stack_id: stackId,
|
|
// Don't send StackFileContent during updates - preserve existing configuration
|
|
Env: envVars
|
|
})
|
|
});
|
|
|
|
console.log('Update response status:', response.status);
|
|
console.log('Update response ok:', response.ok);
|
|
console.log('Update response headers:', Object.fromEntries(response.headers.entries()));
|
|
|
|
// Handle 504 Gateway Timeout as successful initiation
|
|
if (response.status === 504) {
|
|
console.log('Received 504 Gateway Timeout - stack update may still be in progress');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack update initiated (timed out, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack update was initiated
|
|
console.log('Starting to poll for stack status after 504 timeout...');
|
|
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = 'Failed to update stack';
|
|
console.log('Response not ok, status:', response.status);
|
|
|
|
// Handle 502 Bad Gateway and 504 Gateway Timeout as potential success cases
|
|
if (response.status === 502 || response.status === 504) {
|
|
console.log(`Received HTTP ${response.status} - stack update may still be in progress`);
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = `Stack update initiated (HTTP ${response.status}, but continuing to monitor)...`;
|
|
}
|
|
|
|
// Start polling immediately since the stack update was initiated
|
|
console.log(`Starting to poll for stack status after HTTP ${response.status}...`);
|
|
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
try {
|
|
const errorData = await response.json();
|
|
errorMessage = errorData.error || errorMessage;
|
|
console.log('Parsed error data:', errorData);
|
|
} catch (parseError) {
|
|
console.log('Failed to parse JSON error, trying text:', parseError);
|
|
// If JSON parsing fails, try to get text content
|
|
try {
|
|
const errorText = await response.text();
|
|
console.log('Error text content:', errorText);
|
|
if (errorText.includes('504 Gateway Time-out') || errorText.includes('504 Gateway Timeout')) {
|
|
console.log('Received 504 Gateway Timeout - stack update may still be in progress');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack update initiated (timed out, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack update was initiated
|
|
console.log('Starting to poll for stack status after 504 timeout...');
|
|
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
} else {
|
|
errorMessage = `HTTP ${response.status}: ${errorText}`;
|
|
}
|
|
} catch (textError) {
|
|
console.log('Failed to get error text:', textError);
|
|
errorMessage = `HTTP ${response.status}: Failed to parse response`;
|
|
}
|
|
}
|
|
console.log('Throwing error:', errorMessage);
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Stack update initiated:', result);
|
|
|
|
// If stack is being updated, poll for status
|
|
if (result.data.status === 'updating') {
|
|
console.log('Stack is being updated, polling for status...');
|
|
const pollResult = await pollStackStatus(stackId, 10 * 60 * 1000); // 10 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
// Return success result with response data
|
|
return {
|
|
success: true,
|
|
data: result.data
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error updating stack:', error);
|
|
|
|
// Check if this is a 502 or 504 error that should be handled as a success
|
|
if (error.message && (
|
|
error.message.includes('504 Gateway Time-out') ||
|
|
error.message.includes('504 Gateway Timeout') ||
|
|
error.message.includes('timed out') ||
|
|
error.message.includes('502') ||
|
|
error.message.includes('Bad Gateway')
|
|
)) {
|
|
console.log('Detected HTTP 502/504 error in catch block - treating as successful initiation');
|
|
|
|
// Update progress to show that we're now polling
|
|
const progressBar = document.getElementById('launchProgress');
|
|
const progressText = document.getElementById('stepDescription');
|
|
if (progressBar && progressText) {
|
|
progressBar.style.width = '25%';
|
|
progressBar.textContent = '25%';
|
|
progressText.textContent = 'Stack update initiated (HTTP error, but continuing to monitor)...';
|
|
}
|
|
|
|
// Start polling immediately since the stack update was initiated
|
|
console.log('Starting to poll for stack status after HTTP 502/504 error from catch block...');
|
|
const pollResult = await pollStackStatus(stackId, 15 * 60 * 1000); // 15 minutes max
|
|
return pollResult;
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
async function authenticateInstance(instanceUrl, instanceId) {
|
|
try {
|
|
// First check if instance is already authenticated
|
|
const instancesResponse = await fetch('/instances');
|
|
const text = await instancesResponse.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
|
|
// Find the instance with matching URL
|
|
const instanceRow = Array.from(doc.querySelectorAll('table tbody tr')).find(row => {
|
|
const urlCell = row.querySelector('td:nth-child(7) a'); // URL is in the 7th column
|
|
return urlCell && urlCell.textContent.trim() === instanceUrl;
|
|
});
|
|
|
|
if (!instanceRow) {
|
|
throw new Error('Instance not found in database');
|
|
}
|
|
|
|
// Get the instance ID from the status badge's data attribute
|
|
const statusBadge = instanceRow.querySelector('[data-instance-id]');
|
|
if (!statusBadge) {
|
|
throw new Error('Could not find instance ID');
|
|
}
|
|
|
|
const dbInstanceId = statusBadge.dataset.instanceId;
|
|
|
|
// Check if already authenticated
|
|
const authStatusResponse = await fetch(`/instances/${dbInstanceId}/auth-status`);
|
|
if (!authStatusResponse.ok) {
|
|
throw new Error('Failed to check authentication status');
|
|
}
|
|
|
|
const authStatus = await authStatusResponse.json();
|
|
if (authStatus.authenticated) {
|
|
console.log('Instance is already authenticated');
|
|
return {
|
|
success: true,
|
|
message: 'Instance is already authenticated',
|
|
alreadyAuthenticated: true
|
|
};
|
|
}
|
|
|
|
console.log('Attempting login to:', `${instanceUrl}/api/admin/login`);
|
|
|
|
// First login to get token
|
|
const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
email: 'administrator@docupulse.com',
|
|
password: 'changeme'
|
|
})
|
|
});
|
|
|
|
if (!loginResponse.ok) {
|
|
const errorText = await loginResponse.text();
|
|
throw new Error(`Login failed: ${errorText}`);
|
|
}
|
|
|
|
const loginData = await loginResponse.json();
|
|
if (loginData.status !== 'success' || !loginData.token) {
|
|
throw new Error('Login failed: Invalid response from server');
|
|
}
|
|
|
|
const token = loginData.token;
|
|
|
|
// Then create management API key
|
|
const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: `Connection from ${window.location.hostname}`
|
|
})
|
|
});
|
|
|
|
if (!keyResponse.ok) {
|
|
const errorText = await keyResponse.text();
|
|
throw new Error(`Failed to create API key: ${errorText}`);
|
|
}
|
|
|
|
const keyData = await keyResponse.json();
|
|
if (!keyData.api_key) {
|
|
throw new Error('No API key received from server');
|
|
}
|
|
|
|
// Save the token to our database
|
|
const saveResponse = await fetch(`/instances/${dbInstanceId}/save-token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({ token: keyData.api_key })
|
|
});
|
|
|
|
if (!saveResponse.ok) {
|
|
const errorText = await saveResponse.text();
|
|
throw new Error(`Failed to save token: ${errorText}`);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Successfully authenticated instance',
|
|
alreadyAuthenticated: false
|
|
};
|
|
} catch (error) {
|
|
console.error('Authentication error:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
} |