fix logs, enable real-time status updates, and persist sessions

This commit is contained in:
2025-11-12 01:58:19 -05:00
parent b7a301dc20
commit 8f923bf603
15 changed files with 776 additions and 1039 deletions

1401
PLAN.md

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@octokit/rest": "^20.0.2",
"compression": "^1.7.4",
"connect-pg-simple": "^9.0.1",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dockerode": "^4.0.2",
@@ -759,6 +760,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/connect-pg-simple": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz",
"integrity": "sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==",
"license": "MIT",
"dependencies": {
"pg": "^8.8.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": {
"express": "^4.18.2",
"express-session": "^1.17.3",
"connect-pg-simple": "^9.0.1",
"pg": "^8.11.3",
"dockerode": "^4.0.2",
"ws": "^8.16.0",

View File

@@ -55,6 +55,17 @@ router.post('/projects/:projectId/deploy', ensureAuthenticated, async (req, res,
const buildResult = await buildEngine.buildImage(deployment.id, buildPath, imageName);
// Update project port if a port was detected during build
let projectPort = project.port;
if (buildResult.detectedPort) {
console.log(`[Deployment] Detected port ${buildResult.detectedPort} for project ${project.id}`);
await db.query(
'UPDATE projects SET port = $1 WHERE id = $2',
[buildResult.detectedPort, project.id]
);
projectPort = buildResult.detectedPort;
}
const envVars = await db.query(
'SELECT key, value FROM env_vars WHERE project_id = $1',
[project.id]
@@ -84,7 +95,7 @@ router.post('/projects/:projectId/deploy', ensureAuthenticated, async (req, res,
imageName,
project.subdomain,
envObject,
project.port
projectPort
);
await fs.remove(buildPath);

View File

@@ -1,6 +1,8 @@
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
const db = require('./config/database');
const passport = require('./config/github');
const cors = require('cors');
const helmet = require('helmet');
@@ -18,6 +20,7 @@ const envVarsRoutes = require('./routes/envVars');
const errorHandler = require('./middleware/errorHandler');
const setupWebSocketServer = require('./websockets/logStreamer');
const analyticsCollector = require('./services/analyticsCollector');
const statusMonitor = require('./services/statusMonitor');
const app = express();
const server = http.createServer(app);
@@ -35,12 +38,17 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
store: new pgSession({
pool: db.pool, // Use the existing database connection pool
tableName: 'session', // Table will be created automatically
createTableIfMissing: true
}),
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days instead of 1 day
}
}));
@@ -69,6 +77,7 @@ app.use(errorHandler);
setupWebSocketServer(server);
analyticsCollector.startStatsCollection();
statusMonitor.start();
const PORT = process.env.PORT || 3000;

View File

@@ -7,7 +7,8 @@ const path = require('path');
async function buildImage(deploymentId, repoPath, imageName) {
try {
await ensureDockerfile(repoPath);
const dockerfileInfo = await ensureDockerfile(repoPath);
console.log('[Build Engine] Dockerfile info:', dockerfileInfo);
await logBuild(deploymentId, 'Starting Docker build...');
await updateDeploymentStatus(deploymentId, 'building');
@@ -38,7 +39,7 @@ async function buildImage(deploymentId, repoPath, imageName) {
);
}
resolve({ imageId, imageName });
resolve({ imageId, imageName, detectedPort: dockerfileInfo.detectedPort });
}
},
async (event) => {

View File

@@ -23,14 +23,19 @@ class LogAggregator {
this.activeStreams.set(deploymentId, stream);
stream.on('data', async (chunk) => {
const logLine = chunk.toString('utf8').trim();
const logLine = this.sanitizeLogLine(chunk.toString('utf8')).trim();
if (logLine) {
const logLevel = this.detectLogLevel(logLine);
await db.query(
'INSERT INTO runtime_logs (deployment_id, log_line, log_level) VALUES ($1, $2, $3)',
[deploymentId, logLine, logLevel]
);
try {
await db.query(
'INSERT INTO runtime_logs (deployment_id, log_line, log_level) VALUES ($1, $2, $3)',
[deploymentId, logLine, logLevel]
);
} catch (error) {
console.error('Error inserting runtime log:', error.message);
// Continue processing logs even if one fails
}
if (onLog) {
onLog(logLine);
@@ -59,6 +64,15 @@ class LogAggregator {
}
}
sanitizeLogLine(logLine) {
// Remove null bytes and other characters that PostgreSQL can't handle
// Also strip ANSI color codes and control characters
return logLine
.replace(/\x00/g, '') // Remove null bytes
.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F]/g, '') // Remove other control chars except \n and \r
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); // Remove ANSI escape codes
}
detectLogLevel(logLine) {
const line = logLine.toLowerCase();

View File

@@ -0,0 +1,84 @@
const docker = require('../config/docker');
const db = require('../config/database');
class StatusMonitor {
constructor() {
this.interval = null;
}
start() {
// Check status every 10 seconds
this.interval = setInterval(() => this.checkAllStatuses(), 10000);
console.log('Status monitor started');
// Run immediately on start
this.checkAllStatuses();
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
console.log('Status monitor stopped');
}
}
async checkAllStatuses() {
try {
// Get all deployments with docker containers
const result = await db.query(
`SELECT id, docker_container_id, status
FROM deployments
WHERE docker_container_id IS NOT NULL
AND status IN ('running', 'building')`
);
for (const deployment of result.rows) {
await this.checkDeploymentStatus(deployment);
}
} catch (error) {
console.error('Error in status monitor:', error);
}
}
async checkDeploymentStatus(deployment) {
try {
const container = docker.getContainer(deployment.docker_container_id);
const info = await container.inspect();
let newStatus = deployment.status;
if (info.State.Running) {
newStatus = 'running';
} else if (info.State.Status === 'exited') {
newStatus = 'stopped';
} else if (info.State.Status === 'dead') {
newStatus = 'failed';
} else if (info.State.Restarting) {
newStatus = 'restarting';
}
// Update status if it changed
if (newStatus !== deployment.status) {
console.log(`Deployment ${deployment.id}: ${deployment.status}${newStatus}`);
await db.query(
'UPDATE deployments SET status = $1, updated_at = NOW() WHERE id = $2',
[newStatus, deployment.id]
);
}
} catch (error) {
// Container not found or other error - mark as stopped
if (error.statusCode === 404) {
console.log(`Deployment ${deployment.id}: Container not found, marking as stopped`);
await db.query(
'UPDATE deployments SET status = $1, updated_at = NOW() WHERE id = $2',
['stopped', deployment.id]
);
} else {
console.error(`Error checking deployment ${deployment.id}:`, error.message);
}
}
}
}
module.exports = new StatusMonitor();

View File

@@ -1,6 +1,94 @@
const fs = require('fs');
const path = require('path');
/**
* Detect Flask application entry point by checking common filenames
* and scanning for Flask app instantiation
*/
function detectFlaskEntryPoint(repoPath) {
console.log('[Flask Detection] Scanning repo path:', repoPath);
// Common Flask entry point filenames in order of preference
const commonEntryPoints = [
'app.py',
'main.py',
'application.py',
'run.py',
'wsgi.py',
'server.py',
'__init__.py'
];
// First, check if any of the common entry points exist
const existingEntryPoints = commonEntryPoints.filter(filename => {
const exists = fs.existsSync(path.join(repoPath, filename));
if (exists) {
console.log('[Flask Detection] Found:', filename);
}
return exists;
});
console.log('[Flask Detection] Existing entry points:', existingEntryPoints);
if (existingEntryPoints.length === 0) {
// No common entry points found, scan all Python files
console.log('[Flask Detection] No common entry points, scanning all .py files');
try {
const files = fs.readdirSync(repoPath);
const pythonFiles = files.filter(f => f.endsWith('.py'));
console.log('[Flask Detection] Python files found:', pythonFiles);
for (const file of pythonFiles) {
const content = fs.readFileSync(path.join(repoPath, file), 'utf8');
// Check if file contains Flask app instantiation
if (content.includes('Flask(__name__)') || content.includes('Flask(')) {
console.log('[Flask Detection] Found Flask app in:', file);
return file;
}
}
} catch (err) {
console.log('[Flask Detection] Error scanning files:', err.message);
// If scanning fails, use default
return 'app.py';
}
// No Flask app found, use default
console.log('[Flask Detection] No Flask app found, defaulting to app.py');
return 'app.py';
}
// If only one common entry point exists, use it
if (existingEntryPoints.length === 1) {
console.log('[Flask Detection] Using single entry point:', existingEntryPoints[0]);
return existingEntryPoints[0];
}
// Multiple entry points exist, scan them to find which one has Flask app
console.log('[Flask Detection] Multiple entry points, scanning for Flask app');
for (const file of existingEntryPoints) {
try {
const content = fs.readFileSync(path.join(repoPath, file), 'utf8');
// Look for Flask app instantiation patterns
if (
content.includes('Flask(__name__)') ||
content.includes('Flask(') ||
content.includes('create_app') ||
content.includes('app = ') ||
content.includes('application = ')
) {
console.log('[Flask Detection] Found Flask app in:', file);
return file;
}
} catch (err) {
console.log('[Flask Detection] Error reading', file, ':', err.message);
continue;
}
}
// Default to the first common entry point found
console.log('[Flask Detection] Defaulting to first entry point:', existingEntryPoints[0]);
return existingEntryPoints[0];
}
function detectProjectType(repoPath) {
if (fs.existsSync(path.join(repoPath, 'Dockerfile'))) {
return 'existing';
@@ -22,6 +110,7 @@ function detectProjectType(repoPath) {
function generateDockerfile(repoPath, projectType) {
let dockerfile = '';
let detectedPort = null; // Track the detected port
switch (projectType) {
case 'nodejs':
@@ -84,16 +173,33 @@ CMD ["${hasYarnLock ? 'yarn' : 'npm'}", "start"]
case 'python':
// Check if Flask/FastAPI exists in requirements
let cmd = '["python", "app.py"]';
let flaskApp = 'app.py';
let envVars = '';
let port = 8000; // Default Python app port
const requirementsPath = path.join(repoPath, 'requirements.txt');
if (fs.existsSync(requirementsPath)) {
const requirements = fs.readFileSync(requirementsPath, 'utf8').toLowerCase();
if (requirements.includes('flask')) {
cmd = '["python", "-m", "flask", "run", "--host=0.0.0.0"]';
// Dynamically detect Flask entry point
flaskApp = detectFlaskEntryPoint(repoPath);
port = 5000; // Flask default port
cmd = '["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000"]';
envVars = `ENV FLASK_APP=${flaskApp}
ENV FLASK_RUN_HOST=0.0.0.0
ENV FLASK_RUN_PORT=5000
`;
} else if (requirements.includes('fastapi') || requirements.includes('uvicorn')) {
cmd = '["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]';
// For FastAPI, try to detect the entry point too
const entryPoint = detectFlaskEntryPoint(repoPath); // Reuse function for detection
const moduleName = entryPoint.replace('.py', '');
port = 8000; // FastAPI/Uvicorn default port
cmd = `["uvicorn", "${moduleName}:app", "--host", "0.0.0.0", "--port", "8000"]`;
}
}
detectedPort = port; // Set the detected port
dockerfile = `FROM python:3.11-slim
WORKDIR /app
@@ -103,10 +209,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
EXPOSE ${port}
${envVars}
CMD ${cmd}
`;
break;
@@ -147,7 +251,7 @@ CMD ["nginx", "-g", "daemon off;"]
throw new Error('Unknown project type - cannot generate Dockerfile');
}
return dockerfile;
return { dockerfile, detectedPort };
}
function ensureDockerfile(repoPath) {
@@ -163,10 +267,10 @@ function ensureDockerfile(repoPath) {
throw new Error('Cannot detect project type');
}
const dockerfile = generateDockerfile(repoPath, projectType);
const { dockerfile, detectedPort } = generateDockerfile(repoPath, projectType);
fs.writeFileSync(dockerfilePath, dockerfile);
return { exists: false, generated: true, projectType };
return { exists: false, generated: true, projectType, detectedPort };
}
module.exports = {

View File

@@ -7,7 +7,38 @@
.log-filters {
display: flex;
gap: 8px;
gap: 12px;
align-items: center;
}
.log-type-tabs {
display: flex;
gap: 4px;
background-color: var(--bg-tertiary);
padding: 4px;
border-radius: var(--radius-sm);
}
.log-tab {
padding: 6px 16px;
background-color: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: var(--transition);
}
.log-tab:hover {
color: var(--text-primary);
background-color: var(--bg-secondary);
}
.log-tab.active {
background-color: var(--accent-primary);
color: white;
}
.log-filter-btn {

View File

@@ -1,4 +1,5 @@
let currentProject = null;
let projectDetailRefreshInterval = null;
async function openProjectDetail(projectId) {
try {
@@ -8,6 +9,9 @@ async function openProjectDetail(projectId) {
switchTab('overview');
renderOverviewTab();
// Start auto-refresh for project details
startProjectDetailAutoRefresh();
} catch (error) {
console.error('Error loading project:', error);
showNotification('Failed to load project details', 'error');
@@ -16,9 +20,46 @@ async function openProjectDetail(projectId) {
function closeProjectDetail() {
document.getElementById('projectDetailModal').classList.remove('active');
stopProjectDetailAutoRefresh();
currentProject = null;
}
async function refreshProjectDetail() {
if (!currentProject) return;
try {
const updated = await api.get(`/api/projects/${currentProject.id}`);
currentProject = updated;
// Re-render the current tab
const activeTab = document.querySelector('.tab-button.active');
if (activeTab) {
const tabName = activeTab.textContent.toLowerCase();
switchTab(tabName);
}
} catch (error) {
console.error('Error refreshing project:', error);
}
}
function startProjectDetailAutoRefresh() {
if (projectDetailRefreshInterval) {
clearInterval(projectDetailRefreshInterval);
}
// Refresh every 3 seconds
projectDetailRefreshInterval = setInterval(() => {
refreshProjectDetail();
}, 3000);
}
function stopProjectDetailAutoRefresh() {
if (projectDetailRefreshInterval) {
clearInterval(projectDetailRefreshInterval);
projectDetailRefreshInterval = null;
}
}
function switchTab(tabName) {
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');

View File

@@ -1,5 +1,6 @@
let logWebSocket = null;
let currentDeploymentId = null;
let currentLogType = 'all'; // 'all', 'build', or 'runtime'
function renderLogsTab() {
const tab = document.getElementById('logsTab');
@@ -16,6 +17,11 @@ function renderLogsTab() {
</option>
`).join('')}
</select>
<div class="log-type-tabs">
<button class="log-tab active" data-type="all" onclick="switchLogType('all')">All</button>
<button class="log-tab" data-type="build" onclick="switchLogType('build')">Build</button>
<button class="log-tab" data-type="runtime" onclick="switchLogType('runtime')">Runtime</button>
</div>
</div>
<div class="log-search">
<button class="btn btn-secondary btn-icon" onclick="clearLogs()" title="Clear">
@@ -100,12 +106,20 @@ function appendLog(logLine, level = 'info', source = 'runtime') {
const logViewer = document.getElementById('logViewer');
if (!logViewer) return;
// Filter logs based on current log type
if (currentLogType !== 'all' && source !== currentLogType) {
return;
}
const logElement = document.createElement('div');
logElement.className = `log-line log-${level}`;
logElement.dataset.source = source;
const cleanedLog = logLine.replace(/[\x00-\x1F\x7F-\x9F]/g, '').trim();
logElement.textContent = cleanedLog;
// Add source badge
const sourceBadge = source === 'build' ? '[BUILD] ' : '[RUNTIME] ';
logElement.textContent = sourceBadge + cleanedLog;
logViewer.appendChild(logElement);
@@ -116,6 +130,32 @@ function appendLog(logLine, level = 'info', source = 'runtime') {
logViewer.scrollTop = logViewer.scrollHeight;
}
function switchLogType(type) {
currentLogType = type;
// Update active tab styling
document.querySelectorAll('.log-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.type === type) {
tab.classList.add('active');
}
});
// Filter existing logs
const logViewer = document.getElementById('logViewer');
if (!logViewer) return;
const logLines = logViewer.querySelectorAll('.log-line');
logLines.forEach(logLine => {
const source = logLine.dataset.source;
if (type === 'all' || source === type || !source) {
logLine.style.display = '';
} else {
logLine.style.display = 'none';
}
});
}
function clearLogs() {
const logViewer = document.getElementById('logViewer');
if (logViewer) {

View File

@@ -1,5 +1,6 @@
let currentProjects = [];
let repositories = [];
let projectsRefreshInterval = null;
async function loadProjects() {
try {
@@ -17,6 +18,25 @@ async function loadProjects() {
}
}
function startProjectsAutoRefresh() {
// Clear any existing interval
if (projectsRefreshInterval) {
clearInterval(projectsRefreshInterval);
}
// Refresh every 5 seconds
projectsRefreshInterval = setInterval(() => {
loadProjects();
}, 5000);
}
function stopProjectsAutoRefresh() {
if (projectsRefreshInterval) {
clearInterval(projectsRefreshInterval);
projectsRefreshInterval = null;
}
}
function renderProjects(projects) {
const grid = document.getElementById('projectsGrid');
@@ -119,4 +139,5 @@ async function createProject(event) {
if (window.location.pathname === '/' || window.location.pathname === '/index.html') {
loadProjects();
startProjectsAutoRefresh();
}

View File

@@ -7,7 +7,7 @@ services:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=paas_network"
- "--providers.docker.network=1minipaas_paas_network"
- "--entrypoints.web.address=:80"
- "--accesslog=true"
- "--accesslog.filepath=/var/log/traefik/access.log"

View File

@@ -10,7 +10,7 @@ providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: "paas_network"
network: "1minipaas_paas_network"
accessLog:
filePath: "/var/log/traefik/access.log"