Skip to content

Commit 500e01b

Browse files
RustyRoryclaude
andcommitted
feat(v2): refonte complète vps-monitor — mini-Railway
Merge de feat/v2-redesign → staging Sprint 1 — Backend : - registry.js : CRUD apps.json v2 avec historique déploiements (10 max) - build.js : capture logs build sur disque + EventEmitter WS temps réel - metrics.js : CPU/RAM via Docker stats API - deploy.js : machine d'état PENDING→BUILDING→STARTING→RUNNING/FAILED avec logs persistés, git spawn streamé, healthcheck HTTP post-deploy - Nouvelles routes /api/projects CRUD + déploiements + métriques - WS build logs (/ws?deployId=) + container logs (/ws?container=) Sprint 2 — Frontend : - SPA Vanilla JS avec router hash-based - Sidebar projets avec dots de statut temps réel - Vue projet unifiée : Overview | Deployments | Variables | Paramètres - Logs de build en streaming WebSocket pendant le deploy - Métriques CPU/RAM par container - Modal nouveau projet, exec, logs container Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 364e0de + 500ab9e commit 500e01b

17 files changed

Lines changed: 2624 additions & 1509 deletions

app/api/server.js

Lines changed: 266 additions & 184 deletions
Large diffs are not rendered by default.

app/api/server.test.js

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
import { jest } from '@jest/globals';
22

3-
// Définis avant l'import pour que dotenv ne les écrase pas
43
process.env.AUTH_USER = 'admin';
54
process.env.AUTH_PASS = 'testpass';
65
process.env.SESSION_SECRET = 'test-secret';
76

7+
const mockProjects = [
8+
{ id: 'lucky7', name: 'lucky7', gitUrl: 'https://github.com/RustyRory/Lucky7.git', status: 'running', deployments: [] },
9+
];
10+
811
jest.unstable_mockModule('./services/docker.js', () => ({
912
getContainers: jest.fn().mockResolvedValue([
10-
{ name: 'app1', status: 'running', image: 'img', ports: ['3000'], uptime: 'Up 1 hour' },
13+
{ name: 'lucky7', status: 'running', image: 'img', ports: ['3002'], uptime: 'Up 1 hour' },
14+
]),
15+
getContainersByNames: jest.fn().mockResolvedValue([
16+
{ name: 'lucky7', status: 'running', image: 'img', ports: ['3002'], uptime: 'Up 1 hour' },
1117
]),
1218
restartContainer: jest.fn().mockResolvedValue(),
1319
stopContainer: jest.fn().mockResolvedValue(),
1420
startContainer: jest.fn().mockResolvedValue(),
21+
removeContainer: jest.fn().mockResolvedValue(),
1522
streamContainerLogs: jest.fn().mockResolvedValue({ destroy: jest.fn() }),
1623
}));
1724

1825
jest.unstable_mockModule('./services/http.js', () => ({
1926
checkWebsites: jest.fn().mockResolvedValue([
20-
{ name: 'SaintBarth Volley', url: '/saintbarthvolley/', httpCode: 200, status: 'OK' },
27+
{ name: 'lucky7', url: '/lucky7/', httpCode: 200, status: 'OK' },
2128
]),
2229
}));
2330

@@ -32,6 +39,45 @@ jest.unstable_mockModule('./services/nginx.js', () => ({
3239
removeApp: jest.fn().mockResolvedValue(),
3340
}));
3441

42+
jest.unstable_mockModule('./services/deploy.js', () => ({
43+
getProjects: jest.fn().mockResolvedValue(mockProjects),
44+
getProject: jest.fn().mockImplementation((id) => {
45+
const p = mockProjects.find((x) => x.id === id);
46+
if (!p) throw new Error(`Projet "${id}" introuvable`);
47+
return Promise.resolve(p);
48+
}),
49+
addProject: jest.fn().mockResolvedValue(mockProjects[0]),
50+
updateProject: jest.fn().mockResolvedValue(mockProjects[0]),
51+
deleteProject: jest.fn().mockResolvedValue(mockProjects[0]),
52+
deployProject: jest.fn().mockResolvedValue('d-test-abc'),
53+
createDeploymentRecord: jest.fn().mockResolvedValue('d-test-abc'),
54+
runDeployment: jest.fn().mockResolvedValue(),
55+
deleteProjectFiles: jest.fn().mockResolvedValue(),
56+
syncProjectStatus: jest.fn().mockResolvedValue('running'),
57+
readEnvFile: jest.fn().mockResolvedValue('KEY=value'),
58+
writeEnvFile: jest.fn().mockResolvedValue(),
59+
readEnvExample: jest.fn().mockResolvedValue('KEY='),
60+
}));
61+
62+
jest.unstable_mockModule('./services/compose.js', () => ({
63+
getAllServiceNames: jest.fn().mockResolvedValue(['lucky7']),
64+
composeFullRestart: jest.fn().mockResolvedValue({ services: ['lucky7'], network: 'lucky7-net', connections: [] }),
65+
ensureInfraInclude: jest.fn().mockResolvedValue(),
66+
composeUp: jest.fn().mockResolvedValue(),
67+
}));
68+
69+
jest.unstable_mockModule('./services/metrics.js', () => ({
70+
getProjectMetrics: jest.fn().mockResolvedValue([
71+
{ name: 'lucky7', cpu_percent: 1.2, mem_usage: 50000, mem_limit: 2000000, mem_percent: 2.5 },
72+
]),
73+
}));
74+
75+
jest.unstable_mockModule('./services/build.js', () => ({
76+
readBuildLog: jest.fn().mockResolvedValue('build log content'),
77+
subscribeBuildSession: jest.fn().mockReturnValue(null),
78+
isBuildActive: jest.fn().mockReturnValue(false),
79+
}));
80+
3581
const { default: app } = await import('./server.js');
3682
const { default: request } = await import('supertest');
3783

@@ -77,3 +123,45 @@ describe('GET /api/status', () => {
77123
expect(res.body.globalStatus).toBe('OK');
78124
});
79125
});
126+
127+
describe('GET /api/projects', () => {
128+
let agent;
129+
130+
beforeEach(async () => {
131+
agent = request.agent(app);
132+
await agent.post('/auth/login').send(CREDENTIALS);
133+
});
134+
135+
it('retourne la liste des projets', async () => {
136+
const res = await agent.get('/api/projects');
137+
expect(res.statusCode).toBe(200);
138+
expect(Array.isArray(res.body)).toBe(true);
139+
expect(res.body[0]).toHaveProperty('id');
140+
});
141+
142+
it('retourne un projet par id', async () => {
143+
const res = await agent.get('/api/projects/lucky7');
144+
expect(res.statusCode).toBe(200);
145+
expect(res.body.id).toBe('lucky7');
146+
});
147+
148+
it('retourne 404 pour un projet inexistant', async () => {
149+
const res = await agent.get('/api/projects/does-not-exist');
150+
expect(res.statusCode).toBe(404);
151+
});
152+
});
153+
154+
describe('GET /api/projects/:id/deployments', () => {
155+
let agent;
156+
157+
beforeEach(async () => {
158+
agent = request.agent(app);
159+
await agent.post('/auth/login').send(CREDENTIALS);
160+
});
161+
162+
it('retourne la liste des déploiements', async () => {
163+
const res = await agent.get('/api/projects/lucky7/deployments');
164+
expect(res.statusCode).toBe(200);
165+
expect(Array.isArray(res.body)).toBe(true);
166+
});
167+
});

app/api/services/build.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createWriteStream } from 'fs';
2+
import { readFile, mkdir } from 'fs/promises';
3+
import { join } from 'path';
4+
import { EventEmitter } from 'events';
5+
6+
const REPO_ROOT = process.env.VPSCONFIG_PATH || '/var/www/vps-monitor';
7+
const LOGS_DIR = join(REPO_ROOT, 'data', 'logs');
8+
9+
// Active build sessions: deployId → { emitter, fileStream }
10+
const activeSessions = new Map();
11+
12+
export function getBuildLogPath(projectId, deployId) {
13+
return join(LOGS_DIR, `${projectId}-${deployId}.log`);
14+
}
15+
16+
export async function startBuildSession(projectId, deployId) {
17+
await mkdir(LOGS_DIR, { recursive: true });
18+
const logPath = getBuildLogPath(projectId, deployId);
19+
const fileStream = createWriteStream(logPath, { flags: 'a' });
20+
const emitter = new EventEmitter();
21+
emitter.setMaxListeners(20);
22+
23+
const session = {
24+
emitter,
25+
fileStream,
26+
write(text) {
27+
fileStream.write(text);
28+
emitter.emit('data', text);
29+
},
30+
end() {
31+
fileStream.end();
32+
emitter.emit('end');
33+
activeSessions.delete(deployId);
34+
},
35+
};
36+
37+
activeSessions.set(deployId, session);
38+
return session;
39+
}
40+
41+
// Returns an unsubscribe function, or null if session is not active (build already ended).
42+
export function subscribeBuildSession(deployId, onData, onEnd) {
43+
const session = activeSessions.get(deployId);
44+
if (!session) return null;
45+
46+
session.emitter.on('data', onData);
47+
session.emitter.once('end', onEnd);
48+
49+
return () => {
50+
session.emitter.off('data', onData);
51+
session.emitter.off('end', onEnd);
52+
};
53+
}
54+
55+
export function isBuildActive(deployId) {
56+
return activeSessions.has(deployId);
57+
}
58+
59+
export async function readBuildLog(projectId, deployId) {
60+
const logPath = getBuildLogPath(projectId, deployId);
61+
try {
62+
return await readFile(logPath, 'utf8');
63+
} catch {
64+
return '';
65+
}
66+
}

app/api/services/compose.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,35 @@ export async function composeRebuild(serviceNames, forceBuild = false) {
114114
await execFile('docker', args, { cwd: APPS_ROOT });
115115
}
116116

117+
export async function composeRebuildStreaming(serviceNames, forceBuild = false, onOutput = null) {
118+
const { spawn } = await import('child_process');
119+
const names = Array.isArray(serviceNames) ? serviceNames : [serviceNames];
120+
const build = forceBuild || !(await hasImages(names));
121+
122+
const emit = (text) => { if (onOutput) onOutput(text); };
123+
124+
emit(`[vps] Arrêt des containers existants...\n`);
125+
await execFile('docker', ['compose', '-f', MAIN_COMPOSE, 'rm', '-sf', ...names], { cwd: APPS_ROOT }).catch(() => {});
126+
await Promise.all(names.map((n) => execFile('docker', ['rm', '-f', n]).catch(() => {})));
127+
128+
const args = ['compose', '-f', MAIN_COMPOSE, 'up', '-d'];
129+
if (build) args.push('--build');
130+
args.push(...names);
131+
132+
emit(`[vps] docker ${args.join(' ')}\n`);
133+
134+
await new Promise((resolve, reject) => {
135+
const child = spawn('docker', args, { cwd: APPS_ROOT });
136+
child.stdout.on('data', (chunk) => emit(chunk.toString('utf8')));
137+
child.stderr.on('data', (chunk) => emit(chunk.toString('utf8')));
138+
child.on('close', (code) => {
139+
if (code === 0) resolve();
140+
else reject(new Error(`docker compose exited with code ${code}`));
141+
});
142+
child.on('error', reject);
143+
});
144+
}
145+
117146
async function freePortsFromCompose(appName) {
118147
const relPath = await findComposePath(appName);
119148
try {

0 commit comments

Comments
 (0)