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:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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...');
|
||||
|
||||
126
control-plane/services/healthChecker.js
Normal file
126
control-plane/services/healthChecker.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user