Project Browsers 1.0

This commit is contained in:
Jakub Zych
2026-03-07 23:51:53 +01:00
commit 3f4920a68f
31 changed files with 1433 additions and 0 deletions

200
server.js Normal file
View File

@@ -0,0 +1,200 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
const { scanProjects } = require('./lib/scanner');
const pm = require('./lib/process-manager');
const { takeScreenshot } = require('./lib/screenshotter');
const centro = require('./lib/centrifugo');
const app = express();
const PORT = 3000;
// Scan projects on startup
let projects = scanProjects();
const projectsBySlug = new Map(projects.map(p => [p.slug, p]));
console.log(`Found ${projects.length} projects:`);
projects.forEach(p => {
const fe = p.frontend ? ` + frontend(${p.frontend.dir} :${p.frontend.port} ${p.frontend.packageManager})` : '';
console.log(` ${p.slug} :${p.port} (${p.portSource || 'assigned'})${fe}`);
});
// Publish status changes to Centrifugo
pm.events.on('status', (slug, subtype, status) => {
centro.publishStatus(slug, status, { subtype });
});
// Static files
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// Serve project docs folders with directory listing
app.use('/docs/:slug', (req, res, next) => {
const project = projectsBySlug.get(req.params.slug);
if (!project) return res.status(404).send('Project not found');
const docsRoot = path.join(project.path, 'docs');
const reqPath = decodeURIComponent(req.path).replace(/\.\./g, '');
const fullPath = path.join(docsRoot, reqPath);
if (!fullPath.startsWith(docsRoot)) return res.status(403).send('Forbidden');
let stat;
try { stat = fs.statSync(fullPath); } catch { return res.status(404).send('Not found'); }
if (stat.isFile()) return res.sendFile(fullPath);
// Directory listing
const entries = fs.readdirSync(fullPath, { withFileTypes: true });
const basePath = `/docs/${project.slug}${reqPath}`.replace(/\/$/, '');
const dirs = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
const files = entries.filter(e => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
const items = [
...dirs.map(e => `<li><a href="${basePath}/${e.name}/">${e.name}/</a></li>`),
...files.map(e => `<li><a href="${basePath}/${e.name}">${e.name}</a></li>`),
].join('\n');
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8">
<title>docs/${project.slug}${reqPath}</title>
<style>body{font-family:system-ui;background:#0f1117;color:#e4e6ed;padding:2rem}
a{color:#6c8cff;text-decoration:none}a:hover{text-decoration:underline}
li{margin:4px 0;font-size:15px}h2{font-weight:500;margin-bottom:1rem}</style>
</head><body><h2>docs/${project.slug}${reqPath}/</h2>
${reqPath !== '/' && reqPath !== '' ? '<a href="../">&larr; back</a>' : ''}
<ul>${items}</ul></body></html>`);
});
// Centrifugo connection token
app.get('/api/centrifugo/token', (req, res) => {
const token = centro.createJWT('projects-browser');
res.json({ token });
});
// List all projects with live status
app.get('/api/projects', (req, res) => {
const statuses = pm.getAllStatuses();
const result = projects.map(p => {
const s = statuses[p.slug];
const hasFrontend = !!p.frontend;
return {
...p,
status: s ? pm.combinedStatus(p.slug, hasFrontend) : 'stopped',
apiStatus: s?.api?.status || 'stopped',
frontendStatus: hasFrontend ? (s?.frontend?.status || 'stopped') : null,
pid: s?.api?.pid || null,
};
});
res.json(result);
});
// Start a project
app.post('/api/projects/:slug/start', (req, res) => {
const project = projectsBySlug.get(req.params.slug);
if (!project) return res.status(404).json({ error: 'Project not found' });
const result = pm.start(project.slug, project.path, project.port, project.frontend || null);
res.json(result);
});
// Stop a project
app.post('/api/projects/:slug/stop', async (req, res) => {
const project = projectsBySlug.get(req.params.slug);
if (!project) return res.status(404).json({ error: 'Project not found' });
const result = await pm.stop(project.slug);
res.json(result);
});
// Screenshot a project
app.post('/api/projects/:slug/screenshot', async (req, res) => {
const project = projectsBySlug.get(req.params.slug);
if (!project) return res.status(404).json({ error: 'Project not found' });
const s = pm.getStatus(project.slug);
const hasFrontend = !!project.frontend;
// For frontend projects, prefer frontend being up; otherwise check API
if (hasFrontend) {
if (s.frontend?.status !== 'running') {
return res.status(400).json({ error: 'Frontend must be running to take screenshot' });
}
} else {
if (s.api.status !== 'running') {
return res.status(400).json({ error: 'Project must be running to take screenshot' });
}
}
// Screenshot the frontend port if available, otherwise API port
const screenshotPort = hasFrontend ? project.frontend.port : project.port;
try {
const imgPath = await takeScreenshot(project.slug, screenshotPort, hasFrontend);
centro.publishScreenshot(project.slug);
res.json({ screenshot: imgPath });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Screenshot all running projects
app.post('/api/screenshot-all', async (req, res) => {
const statuses = pm.getAllStatuses();
const results = {};
for (const p of projects) {
const s = statuses[p.slug];
if (!s) continue;
const hasFrontend = !!p.frontend;
const combined = pm.combinedStatus(p.slug, hasFrontend);
if (combined !== 'running') continue;
const screenshotPort = hasFrontend ? p.frontend.port : p.port;
try {
results[p.slug] = await takeScreenshot(p.slug, screenshotPort, hasFrontend);
centro.publishScreenshot(p.slug);
} catch (err) {
results[p.slug] = { error: err.message };
}
}
res.json(results);
});
// Get logs for a project
app.get('/api/projects/:slug/logs', (req, res) => {
const project = projectsBySlug.get(req.params.slug);
if (!project) return res.status(404).json({ error: 'Project not found' });
const s = pm.getStatus(project.slug);
res.json({
api: s.api.logs,
frontend: s.frontend?.logs || [],
});
});
// Rescan projects
app.post('/api/rescan', (req, res) => {
projects = scanProjects();
projectsBySlug.clear();
projects.forEach(p => projectsBySlug.set(p.slug, p));
res.json({ count: projects.length });
});
// Graceful shutdown
let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log('\nShutting down, stopping all child processes...');
pm.killAllSync();
console.log('All processes killed.');
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
app.listen(PORT, '0.0.0.0', () => {
console.log(`Projects Browser running at http://0.0.0.0:${PORT}`);
});