Skip to content

Commit e424811

Browse files
committed
feat(dashboard): fixes #50 - git ops
1 parent e4d6dd1 commit e424811

23 files changed

Lines changed: 1230 additions & 204 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# Changelog
22

3+
## [0.5.0] — 2026-05-19
4+
5+
### Phase 5 — GitOps Dashboard
6+
7+
#### Added
8+
9+
- `app/api/services/git.js` : service GitOps — `listEditableFiles()` (scan `nginx/sites-enabled/` + `apps/*/docker-compose.yml`), `getFileContent(path)`, `writeAndCommit(path, content)` (écrit + `git add` + `git commit` + `git push`) — utilise `execFile` (pas de shell) + validation path traversal
10+
- `app/api/services/nginx.js` : `testConfig()` (`sudo nginx -t`, retourne `{ ok, output }`) et `reload()` (`sudo systemctl reload nginx`) — capture stdout + stderr car nginx -t écrit sur stderr même en succès
11+
- `app/api/services/deploy.js` : `listApps()`, `getAppStatus(name)`, `cloneApp(name, url)`, `updateApp(name)` — détection déployée/running via `docker compose ps --quiet`, `execFile` avec `cwd` pour éviter `-f`, whitelist `[a-zA-Z0-9_-]` sur les noms d'app
12+
- Routes `/api/config/*` dans `server.js` (protégées `requireAuth`) : `GET /api/config/files`, `GET /api/config/file?path=`, `POST /api/config/file`, `POST /api/config/nginx/reload` (teste avant de recharger, 422 si config invalide)
13+
- Routes `/api/deploy/*` dans `server.js` (protégées `requireAuth`) : `GET /api/deploy/apps`, `GET /api/deploy/status/:app`, `POST /api/deploy/clone`, `POST /api/deploy/update`
14+
- Onglet "Configs" dans le dashboard : liste des fichiers éditables, clic → modale textarea pré-remplie, bouton "Sauvegarder" (commit + push automatique), bouton "Recharger Nginx" visible uniquement sur les fichiers `nginx/…`
15+
- Onglet "Déploiement" dans le dashboard : cards avec badge `running` / `stopped` / `absent`, bouton "Mettre à jour" sur les apps déployées, formulaire "Déployer une nouvelle app" (nom + URL git)
16+
- Navigation par onglets (Monitoring / Configs / Déploiement) avec chargement lazy des données à l'ouverture de chaque onglet
17+
18+
#### Changed
19+
20+
- `vps-monitor-app/` renommé en `app/` — mise à jour du `docker-compose.yml` et du workflow de déploiement en conséquence
21+
- Variable d'environnement `VPSCONFIG_PATH` ajoutée (chemin racine du repo sur le VPS, défaut `/var/www/vps-monitor`) — utilisée par `git.js` et `deploy.js`
22+
- `staging.yml` : chemin de déploiement SSH corrigé (`/var/www/VPS-monitor``/var/www/vps-monitor`)
23+
24+
#### Infrastructure (hors code)
25+
26+
- Fusion `vps-config``VPS-monitor` : `nginx/sites-enabled/` et `apps/*/docker-compose.yml` désormais versionnés dans ce repo
27+
- GitHub PAT configuré via `GITHUB_TOKEN` dans `.env` et encodé dans le remote URL pour les push automatiques depuis le VPS
28+
- Règle `sudoers` ajoutée pour `nginx -t` et `systemctl reload nginx` sans mot de passe
29+
30+
---
31+
332
## [0.4.2] — 2026-05-19
433

534
### Phase 4 — Logs en temps réel via WebSocket
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { fileURLToPath } from 'url';
77
import { dirname, join } from 'path';
88
import { getContainers, restartContainer, stopContainer, startContainer, streamContainerLogs } from './services/docker.js';
99
import { checkWebsites } from './services/http.js';
10+
import { listEditableFiles, getFileContent, writeAndCommit } from './services/git.js';
11+
import { testConfig, reload as reloadNginx } from './services/nginx.js';
12+
import { listApps, cloneApp, updateApp, getAppStatus } from './services/deploy.js';
1013

1114
const __dirname = dirname(fileURLToPath(import.meta.url));
1215

@@ -117,6 +120,89 @@ app.post('/api/container/start', requireAuth, async (req, res) => {
117120
}
118121
});
119122

123+
// --- API Config (protégée) ---
124+
125+
app.get('/api/config/files', requireAuth, async (_req, res) => {
126+
try {
127+
res.json(await listEditableFiles());
128+
} catch (err) {
129+
res.status(500).json({ error: err.message });
130+
}
131+
});
132+
133+
app.get('/api/config/file', requireAuth, async (req, res) => {
134+
const { path } = req.query;
135+
if (!path) return res.status(400).json({ error: 'path requis' });
136+
try {
137+
res.json({ content: await getFileContent(path) });
138+
} catch (err) {
139+
res.status(err.message === 'Chemin non autorisé' ? 403 : 500).json({ error: err.message });
140+
}
141+
});
142+
143+
app.post('/api/config/file', requireAuth, async (req, res) => {
144+
const { path, content } = req.body;
145+
if (!path || content === undefined) return res.status(400).json({ error: 'path et content requis' });
146+
try {
147+
await writeAndCommit(path, content);
148+
res.json({ ok: true });
149+
} catch (err) {
150+
res.status(err.message === 'Chemin non autorisé' ? 403 : 500).json({ error: err.message });
151+
}
152+
});
153+
154+
app.post('/api/config/nginx/reload', requireAuth, async (_req, res) => {
155+
try {
156+
const test = await testConfig();
157+
if (!test.ok) return res.status(422).json({ ok: false, output: test.output });
158+
await reloadNginx();
159+
res.json({ ok: true, output: test.output });
160+
} catch (err) {
161+
res.status(500).json({ error: err.message });
162+
}
163+
});
164+
165+
// --- API Deploy (protégée) ---
166+
167+
app.get('/api/deploy/apps', requireAuth, async (_req, res) => {
168+
try {
169+
res.json(await listApps());
170+
} catch (err) {
171+
res.status(500).json({ error: err.message });
172+
}
173+
});
174+
175+
app.get('/api/deploy/status/:app', requireAuth, async (req, res) => {
176+
try {
177+
res.json(await getAppStatus(req.params.app));
178+
} catch (err) {
179+
res.status(err.message === 'Nom d\'app invalide' ? 400 : 500).json({ error: err.message });
180+
}
181+
});
182+
183+
app.post('/api/deploy/clone', requireAuth, async (req, res) => {
184+
const { name, url } = req.body;
185+
if (!name || !url) return res.status(400).json({ error: 'name et url requis' });
186+
try {
187+
await cloneApp(name, url);
188+
res.json({ ok: true });
189+
} catch (err) {
190+
const status = ['Nom d\'app invalide', 'URL invalide'].includes(err.message) ? 400 : 500;
191+
res.status(status).json({ error: err.message });
192+
}
193+
});
194+
195+
app.post('/api/deploy/update', requireAuth, async (req, res) => {
196+
const { name } = req.body;
197+
if (!name) return res.status(400).json({ error: 'name requis' });
198+
try {
199+
await updateApp(name);
200+
res.json({ ok: true });
201+
} catch (err) {
202+
res.status(err.message === 'Nom d\'app invalide' ? 400 : 500).json({ error: err.message });
203+
}
204+
});
205+
120206
// --- WebSocket : logs en temps réel ---
121207

122208
const wsTokens = new Map();

app/api/services/deploy.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { readdir, access } from 'fs/promises';
2+
import { join } from 'path';
3+
import { execFile as execFileCb } from 'child_process';
4+
import { promisify } from 'util';
5+
6+
const execFile = promisify(execFileCb);
7+
8+
const REPO_ROOT = process.env.VPSCONFIG_PATH || '/var/www/vps-monitor';
9+
const APPS_ROOT = '/var/www';
10+
11+
function safeName(name) {
12+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) throw new Error('Nom d\'app invalide');
13+
return name;
14+
}
15+
16+
function compose(appPath, ...args) {
17+
return execFile('docker', ['compose', ...args], { cwd: appPath });
18+
}
19+
20+
export async function getAppStatus(name) {
21+
safeName(name);
22+
const appPath = join(APPS_ROOT, name);
23+
24+
try {
25+
await access(appPath);
26+
} catch {
27+
return { name, deployed: false, running: false };
28+
}
29+
30+
try {
31+
const { stdout } = await compose(appPath, 'ps', '--quiet');
32+
return { name, deployed: true, running: stdout.trim().length > 0 };
33+
} catch {
34+
return { name, deployed: true, running: false };
35+
}
36+
}
37+
38+
export async function listApps() {
39+
try {
40+
const entries = await readdir(join(REPO_ROOT, 'apps'));
41+
return Promise.all(entries.map(getAppStatus));
42+
} catch {
43+
return [];
44+
}
45+
}
46+
47+
export async function cloneApp(name, url) {
48+
safeName(name);
49+
if (!/^https?:\/\//.test(url)) throw new Error('URL invalide');
50+
const appPath = join(APPS_ROOT, name);
51+
await execFile('git', ['clone', url, appPath]);
52+
await compose(appPath, 'up', '-d');
53+
}
54+
55+
export async function updateApp(name) {
56+
safeName(name);
57+
const appPath = join(APPS_ROOT, name);
58+
await execFile('git', ['-C', appPath, 'pull']);
59+
await compose(appPath, 'up', '-d', '--build');
60+
}

app/api/services/git.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { readFile, writeFile, readdir } from 'fs/promises';
2+
import { join, normalize } from 'path';
3+
import { execFile as execFileCb } from 'child_process';
4+
import { promisify } from 'util';
5+
6+
const execFile = promisify(execFileCb);
7+
8+
const REPO_ROOT = process.env.VPSCONFIG_PATH || '/var/www/vps-monitor';
9+
10+
function safePath(relativePath) {
11+
const abs = normalize(join(REPO_ROOT, relativePath));
12+
if (!abs.startsWith(REPO_ROOT + '/')) {
13+
throw new Error('Chemin non autorisé');
14+
}
15+
return abs;
16+
}
17+
18+
function git(...args) {
19+
return execFile('git', ['-C', REPO_ROOT, ...args]);
20+
}
21+
22+
export async function getFileContent(relativePath) {
23+
return readFile(safePath(relativePath), 'utf8');
24+
}
25+
26+
export async function writeAndCommit(relativePath, content) {
27+
await writeFile(safePath(relativePath), content, 'utf8');
28+
await git('add', relativePath);
29+
await git('commit', '-m', `chore(config): update ${relativePath}`);
30+
await git('push');
31+
}
32+
33+
export async function listEditableFiles() {
34+
const files = [];
35+
36+
try {
37+
const entries = await readdir(join(REPO_ROOT, 'nginx', 'sites-enabled'));
38+
for (const entry of entries) {
39+
files.push(`nginx/sites-enabled/${entry}`);
40+
}
41+
} catch {
42+
// dossier absent
43+
}
44+
45+
try {
46+
const apps = await readdir(join(REPO_ROOT, 'apps'));
47+
for (const app of apps) {
48+
const rel = `apps/${app}/docker-compose.yml`;
49+
try {
50+
await readFile(join(REPO_ROOT, rel));
51+
files.push(rel);
52+
} catch {
53+
// pas de compose pour cette app
54+
}
55+
}
56+
} catch {
57+
// dossier absent
58+
}
59+
60+
return files;
61+
}

0 commit comments

Comments
 (0)