Add real-time pipeline status and health monitoring

Features:
- Fix pipeline visualization to show real deployment status
- Add project sidebar with status indicators
- Implement container restart without rebuild (Start button)
- Add health checker to detect port mismatches and bad gateway errors
- Fix deployment API routes (/deployments/:id/start, /stop)

Backend changes:
- Add health_status column to deployments table
- Create healthChecker service for monitoring deployment health
- Fix missing database columns (error_message, error_type, health_status)
- Update projects API to include deployments array
- Add smart container restart logic (start existing or create new)

Frontend changes:
- Add left sidebar with minimal project list and status dots
- Update pipeline to show Traefik errors based on health checks
- Fix button event handlers with stopPropagation
- Change navbar "Projects" to "Home"
- Keep original vertical card layout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-13 03:31:58 -05:00
parent 3da3504152
commit d74e4ac5a2
7 changed files with 384 additions and 19 deletions

View File

@@ -120,7 +120,7 @@ router.post('/projects/:projectId/deploy', ensureAuthenticated, async (req, res,
}
});
router.get('/:id', ensureAuthenticated, async (req, res, next) => {
router.get('/deployments/:id', ensureAuthenticated, async (req, res, next) => {
try {
const result = await db.query(
`SELECT d.*, p.name as project_name, p.subdomain
@@ -140,7 +140,7 @@ router.get('/:id', ensureAuthenticated, async (req, res, next) => {
}
});
router.post('/:id/stop', ensureAuthenticated, async (req, res, next) => {
router.post('/deployments/:id/stop', ensureAuthenticated, async (req, res, next) => {
try {
const deployment = await db.query(
`SELECT d.* FROM deployments d
@@ -161,7 +161,86 @@ router.post('/:id/stop', ensureAuthenticated, async (req, res, next) => {
}
});
router.delete('/:id', ensureAuthenticated, async (req, res, next) => {
router.post('/deployments/:id/start', ensureAuthenticated, async (req, res, next) => {
try {
const deployment = await db.query(
`SELECT d.*, p.subdomain, p.port FROM deployments d
JOIN projects p ON d.project_id = p.id
WHERE d.id = $1 AND p.user_id = $2`,
[req.params.id, req.user.id]
);
if (deployment.rows.length === 0) {
return res.status(404).json({ error: 'Deployment not found' });
}
const dep = deployment.rows[0];
if (!dep.docker_image_id) {
return res.status(400).json({ error: 'No image available for this deployment' });
}
// Check if container exists and is stopped
const docker = require('../config/docker');
if (dep.docker_container_id) {
try {
const container = docker.getContainer(dep.docker_container_id);
const containerInfo = await container.inspect();
// If container exists and is not running, start it
if (!containerInfo.State.Running) {
await container.start();
await db.query(
'UPDATE deployments SET status = $1, started_at = NOW() WHERE id = $2',
['running', dep.id]
);
return res.json({ success: true, message: 'Container started' });
} else {
return res.json({ success: true, message: 'Container already running' });
}
} catch (containerError) {
// Container doesn't exist anymore, need to remove it first then create new one
if (containerError.statusCode === 404) {
console.log('Container not found, will create new one');
try {
const oldContainer = docker.getContainer(dep.docker_container_id);
await oldContainer.remove({ force: true });
} catch (removeError) {
// Ignore errors removing non-existent container
}
} else {
throw containerError;
}
}
}
// Container doesn't exist, create a new one
const envVars = await db.query(
'SELECT key, value FROM env_vars WHERE project_id = $1',
[dep.project_id]
);
const envObject = {};
envVars.rows.forEach(row => {
envObject[row.key] = decrypt(row.value) || row.value;
});
await deploymentService.startContainer(
dep.id,
dep.project_id,
dep.docker_image_id,
dep.subdomain,
envObject,
dep.port
);
res.json({ success: true, message: 'Container started' });
} catch (error) {
next(error);
}
});
router.delete('/deployments/:id', ensureAuthenticated, async (req, res, next) => {
try {
const deployment = await db.query(
`SELECT d.* FROM deployments d

View File

@@ -11,7 +11,9 @@ router.get('/', ensureAuthenticated, async (req, res, next) => {
const result = await db.query(
`SELECT p.*,
(SELECT status FROM deployments WHERE project_id = p.id ORDER BY created_at DESC LIMIT 1) as latest_status,
(SELECT COUNT(*) FROM deployments WHERE project_id = p.id) as deployment_count
(SELECT COUNT(*) FROM deployments WHERE project_id = p.id) as deployment_count,
(SELECT json_agg(d ORDER BY d.created_at DESC)
FROM (SELECT * FROM deployments WHERE project_id = p.id ORDER BY created_at DESC LIMIT 5) d) as deployments
FROM projects p
WHERE p.user_id = $1
ORDER BY p.updated_at DESC`,

View File

@@ -24,6 +24,7 @@ const setupWebSocketServer = require('./websockets/logStreamer');
const analyticsCollector = require('./services/analyticsCollector');
const statusMonitor = require('./services/statusMonitor');
const healthMonitor = require('./services/healthMonitor');
const healthChecker = require('./services/healthChecker');
const logger = require('./services/logger');
const commitPoller = require('./services/commitPoller');
@@ -99,6 +100,7 @@ setupWebSocketServer(server);
analyticsCollector.startStatsCollection();
statusMonitor.start();
healthMonitor.start();
healthChecker.start(30000); // Check health every 30 seconds
commitPoller.start(60000);
logger.info('miniPaaS Control Plane initializing...');

View File

@@ -0,0 +1,126 @@
const db = require('../config/database');
const http = require('http');
class HealthChecker {
constructor() {
this.checkInterval = null;
this.isRunning = false;
}
start(intervalMs = 30000) {
if (this.isRunning) {
console.log('[Health Checker] Already running');
return;
}
console.log(`[Health Checker] Starting with ${intervalMs}ms interval`);
this.isRunning = true;
// Run immediately
this.checkAllDeployments();
// Then run periodically
this.checkInterval = setInterval(() => {
this.checkAllDeployments();
}, intervalMs);
}
stop() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
this.isRunning = false;
console.log('[Health Checker] Stopped');
}
}
async checkAllDeployments() {
try {
const deployments = await db.query(
`SELECT d.id, d.project_id, p.subdomain, p.port
FROM deployments d
JOIN projects p ON d.project_id = p.id
WHERE d.status = 'running'`
);
for (const deployment of deployments.rows) {
try {
await this.checkDeployment(deployment);
} catch (error) {
console.error(`[Health Checker] Error checking deployment ${deployment.id}:`, error.message);
}
}
} catch (error) {
console.error('[Health Checker] Error fetching deployments:', error);
}
}
async checkDeployment(deployment) {
// Check through Traefik from the host network
const url = `http://traefik/`;
const options = {
hostname: 'traefik',
port: 80,
path: '/',
method: 'GET',
headers: {
'Host': `${deployment.subdomain}.localhost`
}
};
return new Promise((resolve) => {
const timeout = setTimeout(() => {
req.destroy();
this.updateHealthStatus(deployment.id, 'timeout').then(resolve);
}, 5000);
const req = http.request(options, (res) => {
clearTimeout(timeout);
let status = 'healthy';
if (res.statusCode === 502 || res.statusCode === 503) {
status = 'bad_gateway';
} else if (res.statusCode === 404) {
status = 'not_found';
} else if (res.statusCode >= 500) {
status = 'error';
}
res.on('data', () => {}); // Consume response
res.on('end', () => {
this.updateHealthStatus(deployment.id, status).then(resolve);
});
});
req.on('error', (error) => {
clearTimeout(timeout);
let status = 'unreachable';
if (error.code === 'ECONNREFUSED') {
status = 'connection_refused';
} else if (error.code === 'ETIMEDOUT') {
status = 'timeout';
}
this.updateHealthStatus(deployment.id, status).then(resolve);
});
req.end();
});
}
async updateHealthStatus(deploymentId, status) {
try {
await db.query(
'UPDATE deployments SET health_status = $1, last_health_check = NOW() WHERE id = $2',
[status, deploymentId]
);
} catch (error) {
console.error(`[Health Checker] Error updating health status for deployment ${deploymentId}:`, error);
}
}
}
const healthChecker = new HealthChecker();
module.exports = healthChecker;

View File

@@ -1,3 +1,89 @@
.dashboard-layout {
display: flex;
gap: 0;
min-height: calc(100vh - 200px);
}
.project-sidebar {
width: 250px;
background-color: var(--bg-secondary);
border-right: 2px solid var(--border-color);
padding: 24px 16px;
position: fixed;
top: 72px;
left: 0;
height: calc(100vh - 72px);
overflow-y: auto;
z-index: 50;
}
.sidebar-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
text-transform: uppercase;
color: var(--text-secondary);
}
.project-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-list-item {
padding: 8px 12px;
border: 2px solid var(--border-color);
background-color: var(--bg-tertiary);
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.project-list-item:hover {
background-color: var(--bg-elevated);
}
.project-list-item.active {
background-color: var(--bg-primary);
font-weight: 700;
}
.project-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.project-status-dot.running {
background-color: #008000;
}
.project-status-dot.stopped {
background-color: #ffff00;
}
.project-status-dot.failed {
background-color: #ff0000;
}
.project-status-dot.building {
background-color: #0000ff;
}
.project-status-dot.idle {
background-color: #666666;
}
.dashboard-main {
flex: 1;
margin-left: 250px;
overflow-y: auto;
}
.projects-grid {
display: flex;
flex-direction: column;

View File

@@ -15,7 +15,7 @@
<h1>miniPaaS</h1>
</div>
<div class="nav-menu">
<a href="/" class="nav-link active">Projects</a>
<a href="/" class="nav-link active">Home</a>
<div class="nav-user" id="userMenu">
<span id="username"></span>
<button class="btn-text" onclick="logout()">Logout</button>
@@ -26,17 +26,28 @@
</nav>
<main class="main-content">
<div class="container">
<div class="page-header">
<div>
<h2 class="page-title">Your Projects</h2>
<p class="page-subtitle">Manage and deploy your applications</p>
<div class="dashboard-layout">
<aside class="project-sidebar">
<h3 class="sidebar-title">Projects</h3>
<div id="projectList" class="project-list">
<div class="loading">Loading...</div>
</div>
<button class="btn btn-primary" onclick="showNewProjectModal()">New Project</button>
</div>
</aside>
<div id="projectsGrid" class="projects-grid">
<div class="loading">Loading projects...</div>
<div class="dashboard-main">
<div class="container">
<div class="page-header">
<div>
<h2 class="page-title">Your Projects</h2>
<p class="page-subtitle">Manage and deploy your applications</p>
</div>
<button class="btn btn-primary" onclick="showNewProjectModal()">New Project</button>
</div>
<div id="projectsGrid" class="projects-grid">
<div class="loading">Loading projects...</div>
</div>
</div>
</div>
</div>
</main>

View File

@@ -39,6 +39,7 @@ function stopProjectsAutoRefresh() {
function renderProjects(projects) {
const grid = document.getElementById('projectsGrid');
const sidebar = document.getElementById('projectList');
if (projects.length === 0) {
grid.innerHTML = `
@@ -47,13 +48,30 @@ function renderProjects(projects) {
<p>Create your first project to get started</p>
</div>
`;
sidebar.innerHTML = '<div class="empty-state"><p>No projects</p></div>';
return;
}
// Render sidebar
sidebar.innerHTML = projects.map(project => {
const status = project.deployments?.[0]?.status || 'idle';
const statusClass = status === 'running' ? 'running' :
status === 'stopped' ? 'stopped' :
status === 'failed' ? 'failed' :
status === 'building' || status === 'pending' || status === 'queued' ? 'building' : 'idle';
return `
<div class="project-list-item" onclick="scrollToProject(${project.id})">
<div class="project-status-dot ${statusClass}"></div>
<span>${escapeHtml(project.name)}</span>
</div>
`;
}).join('');
// Render main grid
grid.innerHTML = projects.map(project => {
const pipeline = getPipelineStatus(project);
return `
<div class="project-card">
<div class="project-card" id="project-${project.id}">
<div class="project-card-header" onclick="openProjectDetail(${project.id})">
<div>
<h3 class="project-card-title">${escapeHtml(project.name)}</h3>
@@ -86,6 +104,8 @@ function getPipelineStatus(project) {
const latestDeployment = project.deployments?.[0];
const status = latestDeployment?.status || 'inactive';
console.log('Project:', project.name, 'Latest deployment:', latestDeployment, 'Status:', status);
let pipeline = {
github: { status: 'idle', message: 'Repository connected', action: null },
build: { status: 'idle', message: 'Waiting', action: null },
@@ -134,8 +154,28 @@ function getPipelineStatus(project) {
pipeline.docker.message = 'Image ready';
pipeline.deploy.status = 'success';
pipeline.deploy.message = 'Container running';
pipeline.traefik.status = 'success';
pipeline.traefik.message = 'Routed';
// Check health status for Traefik
const healthStatus = latestDeployment?.health_status;
if (healthStatus === 'bad_gateway' || healthStatus === 'connection_refused') {
pipeline.traefik.status = 'error';
pipeline.traefik.message = 'Bad Gateway';
pipeline.traefik.error = 'Port mismatch - check project settings';
} else if (healthStatus === 'timeout' || healthStatus === 'unreachable') {
pipeline.traefik.status = 'error';
pipeline.traefik.message = 'Unreachable';
pipeline.traefik.error = 'Cannot connect to container';
} else if (healthStatus === 'error') {
pipeline.traefik.status = 'error';
pipeline.traefik.message = 'Server Error';
} else if (healthStatus === 'healthy') {
pipeline.traefik.status = 'success';
pipeline.traefik.message = 'Routed';
} else {
// Unknown or checking
pipeline.traefik.status = 'success';
pipeline.traefik.message = 'Routed';
}
} else if (status === 'stopped') {
pipeline.github.status = 'success';
pipeline.github.message = 'Code available';
@@ -145,7 +185,7 @@ function getPipelineStatus(project) {
pipeline.docker.message = 'Image exists';
pipeline.deploy.status = 'warning';
pipeline.deploy.message = 'Stopped';
pipeline.deploy.action = { text: 'Start?', fn: `deployProject(${project.id})` };
pipeline.deploy.action = { text: 'Start?', fn: `startDeployment(${latestDeployment.id})` };
}
if (hasNewCommit && (status === 'running' || status === 'stopped')) {
@@ -165,7 +205,7 @@ function renderPipelineStage(title, stage) {
const statusClass = `status-${stage.status}`;
const icon = getStageIcon(title, stage.status);
const actionHtml = stage.action ?
`<button class="pipeline-stage-action" onclick="${stage.action.fn}">${stage.action.text}</button>` : '';
`<button class="pipeline-stage-action" onclick="event.stopPropagation(); ${stage.action.fn}">${stage.action.text}</button>` : '';
const errorHtml = stage.error ?
`<div class="pipeline-error-message">${escapeHtml(stage.error)}</div>` : '';
@@ -211,6 +251,18 @@ async function deployProject(projectId) {
}
}
async function startDeployment(deploymentId) {
try {
showNotification('Starting container...', 'info');
await api.post(`/api/deployments/${deploymentId}/start`, {});
showNotification('Container started successfully', 'success');
setTimeout(() => loadProjects(), 2000);
} catch (error) {
console.error('Error starting container:', error);
showNotification(error.message, 'error');
}
}
async function showNewProjectModal() {
const modal = document.getElementById('newProjectModal');
modal.classList.add('active');
@@ -275,6 +327,13 @@ async function createProject(event) {
}
}
function scrollToProject(projectId) {
const element = document.getElementById(`project-${projectId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
if (window.location.pathname === '/dashboard' || window.location.pathname === '/index.html') {
loadProjects();
startProjectsAutoRefresh();