feat: add VPS Monitor backend and frontend services
Some checks failed
Build and Push Docker Images / docker (push) Failing after 5s

- Create systemd service for VPS Monitor agent.
- Add FastAPI backend with endpoints for managing VPS configurations and statuses.
- Implement Dockerfile for backend service with required dependencies.
- Create frontend using React with Vite and Tailwind CSS for styling.
- Add API client for communicating with the backend.
- Implement components for displaying VPS information and logs.
- Set up Docker Compose for orchestrating backend and frontend services.
- Add environment configuration files for backend and agent.
- Implement CORS support in the backend for frontend communication.
This commit is contained in:
jeanotx32
2026-05-18 22:31:36 -04:00
parent f83f8f97fa
commit cf0b3f0acf
28 changed files with 1601 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
AGENT_API_KEY=changeme-please
AGENT_PORT=8001

109
vps-monitor/agent/agent.py Normal file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
VPS Monitor Agent — à déployer sur chaque VPS.
Expose une API REST utilisée par le backend central pour interroger les conteneurs Docker.
"""
import os
from datetime import datetime, timezone
import docker
from docker.errors import DockerException, NotFound
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import APIKeyHeader
# ─── Config ───────────────────────────────────────────────────────────────────
API_KEY = os.getenv("AGENT_API_KEY", "changeme-please")
AGENT_PORT = int(os.getenv("AGENT_PORT", "8001"))
# ─── App ──────────────────────────────────────────────────────────────────────
app = FastAPI(title="VPS Monitor Agent", version="1.0.0", docs_url=None, redoc_url=None)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
def require_api_key(key: str = Security(api_key_header)) -> None:
if key != API_KEY:
raise HTTPException(status_code=403, detail="Clé API invalide")
def get_docker_client():
try:
return docker.from_env()
except DockerException as e:
raise HTTPException(status_code=503, detail=f"Docker inaccessible : {e}")
# ─── Routes ───────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
"""Vérification de disponibilité — sans authentification."""
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
@app.get("/containers")
def list_containers(_: None = Depends(require_api_key)):
"""Retourne tous les conteneurs (actifs et arrêtés)."""
client = get_docker_client()
result = []
for c in client.containers.list(all=True):
image_tag = c.image.tags[0] if c.image.tags else c.image.short_id
result.append({
"id": c.short_id,
"name": c.name.lstrip("/"),
"status": c.status,
"image": image_tag,
"created": c.attrs.get("Created", ""),
"compose_project": c.labels.get("com.docker.compose.project", ""),
"compose_service": c.labels.get("com.docker.compose.service", ""),
"ports": {
host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings]
for host, bindings in (c.ports or {}).items()
if bindings
},
})
return sorted(result, key=lambda x: x["name"])
@app.get("/containers/{container_id}/logs")
def get_logs(container_id: str, lines: int = 100, _: None = Depends(require_api_key)):
"""Retourne les N dernières lignes de logs d'un conteneur."""
client = get_docker_client()
try:
c = client.containers.get(container_id)
raw = c.logs(tail=lines, timestamps=True)
return {"logs": raw.decode("utf-8", errors="replace")}
except NotFound:
raise HTTPException(status_code=404, detail="Conteneur introuvable")
@app.post("/containers/{container_id}/action")
def container_action(container_id: str, action: str, _: None = Depends(require_api_key)):
"""Effectue une action sur un conteneur : start, stop, restart."""
if action not in ("start", "stop", "restart"):
raise HTTPException(status_code=400, detail=f"Action invalide : {action}")
client = get_docker_client()
try:
c = client.containers.get(container_id)
getattr(c, action)()
return {"status": "ok", "action": action, "container": container_id}
except NotFound:
raise HTTPException(status_code=404, detail="Conteneur introuvable")
# ─── Entrée ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=AGENT_PORT)

View File

@@ -0,0 +1,16 @@
[Unit]
Description=VPS Monitor Agent
After=docker.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/vps-monitor-agent
EnvironmentFile=/opt/vps-monitor-agent/.env
ExecStart=/opt/vps-monitor-agent/venv/bin/uvicorn agent:app --host 0.0.0.0 --port 8001
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
docker>=7.1.0

View File

@@ -0,0 +1,3 @@
CONFIG_FILE=data/vps.json
AGENT_TIMEOUT=5
CORS_ORIGINS=http://localhost:5173,http://localhost:3000

View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

179
vps-monitor/backend/main.py Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
VPS Monitor Backend — serveur central.
Agrège les données de tous les agents et expose une API REST pour le frontend.
"""
import asyncio
import json
import os
from pathlib import Path
import aiohttp
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# ─── Config ───────────────────────────────────────────────────────────────────
CONFIG_FILE = Path(os.getenv("CONFIG_FILE", "data/vps.json"))
AGENT_TIMEOUT = int(os.getenv("AGENT_TIMEOUT", "5"))
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
CONFIG_FILE.write_text("[]")
# ─── Modèles ──────────────────────────────────────────────────────────────────
class VpsConfig(BaseModel):
id: str
name: str
host: str
port: int = 8001
api_key: str
description: str = ""
class ActionRequest(BaseModel):
action: str # start | stop | restart
# ─── Persistance ──────────────────────────────────────────────────────────────
def load_vps() -> list[dict]:
return json.loads(CONFIG_FILE.read_text())
def save_vps(data: list[dict]) -> None:
CONFIG_FILE.write_text(json.dumps(data, indent=2))
# ─── App ──────────────────────────────────────────────────────────────────────
app = FastAPI(title="VPS Monitor Backend", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("CORS_ORIGINS", "*").split(","),
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers HTTP ─────────────────────────────────────────────────────────────
async def agent_get(vps: dict, path: str):
url = f"http://{vps['host']}:{vps['port']}{path}"
headers = {"X-API-Key": vps["api_key"]}
timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT)
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=timeout) as r:
r.raise_for_status()
return await r.json()
async def agent_post(vps: dict, path: str, payload: dict | None = None):
url = f"http://{vps['host']}:{vps['port']}{path}"
headers = {"X-API-Key": vps["api_key"]}
timeout = aiohttp.ClientTimeout(total=AGENT_TIMEOUT)
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload, timeout=timeout) as r:
r.raise_for_status()
return await r.json()
async def fetch_vps_status(vps: dict) -> dict:
"""Interroge un agent et retourne son état complet."""
try:
containers = await agent_get(vps, "/containers")
return {
"id": vps["id"],
"name": vps["name"],
"host": vps["host"],
"description": vps.get("description", ""),
"online": True,
"containers": containers,
}
except Exception as e:
return {
"id": vps["id"],
"name": vps["name"],
"host": vps["host"],
"description": vps.get("description", ""),
"online": False,
"error": str(e),
"containers": [],
}
# ─── Routes VPS ───────────────────────────────────────────────────────────────
@app.get("/api/vps")
def list_vps():
"""Liste les VPS configurés (sans les clés API)."""
return [
{"id": v["id"], "name": v["name"], "host": v["host"], "description": v.get("description", "")}
for v in load_vps()
]
@app.post("/api/vps", status_code=201)
def add_vps(vps: VpsConfig):
"""Ajoute un nouveau VPS."""
data = load_vps()
if any(v["id"] == vps.id for v in data):
raise HTTPException(status_code=409, detail="Un VPS avec cet ID existe déjà")
data.append(vps.model_dump())
save_vps(data)
return {"status": "ok", "id": vps.id}
@app.delete("/api/vps/{vps_id}")
def delete_vps(vps_id: str):
"""Supprime un VPS de la configuration."""
data = load_vps()
filtered = [v for v in data if v["id"] != vps_id]
if len(filtered) == len(data):
raise HTTPException(status_code=404, detail="VPS introuvable")
save_vps(filtered)
return {"status": "ok"}
@app.get("/api/status")
async def all_status():
"""Retourne l'état de tous les VPS en parallèle."""
vps_list = load_vps()
results = await asyncio.gather(*[fetch_vps_status(v) for v in vps_list])
return list(results)
@app.get("/api/vps/{vps_id}/status")
async def vps_status(vps_id: str):
"""Retourne l'état d'un VPS spécifique."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
return await fetch_vps_status(vps)
@app.get("/api/vps/{vps_id}/containers/{container_id}/logs")
async def container_logs(vps_id: str, container_id: str, lines: int = 100):
"""Récupère les logs d'un conteneur via l'agent."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_get(vps, f"/containers/{container_id}/logs?lines={lines}")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/vps/{vps_id}/containers/{container_id}/action")
async def container_action(vps_id: str, container_id: str, body: ActionRequest):
"""Effectue une action sur un conteneur via l'agent."""
vps = next((v for v in load_vps() if v["id"] == vps_id), None)
if not vps:
raise HTTPException(status_code=404, detail="VPS introuvable")
try:
return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}")
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))

View File

@@ -0,0 +1,4 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
aiohttp>=3.9.0
pydantic>=2.0.0

View File

@@ -0,0 +1,17 @@
services:
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- ./backend/data:/app/data
env_file: ./backend/.env
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPS Monitor</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy vers le backend FastAPI
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "vps-monitor-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.396.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useCallback } from 'react'
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs } from './api/client'
import Header from './components/Header'
import VpsCard from './components/VpsCard'
import LogsModal from './components/LogsModal'
import AddVpsModal from './components/AddVpsModal'
const REFRESH_INTERVAL = 30_000
export default function App() {
const [vpsList, setVpsList] = useState([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null)
const [logsModal, setLogsModal] = useState(null) // { vpsId, containerId, name }
const [logsContent, setLogsContent] = useState('')
const [logsLoading, setLogsLoading] = useState(false)
const [showAddVps, setShowAddVps] = useState(false)
const refresh = useCallback(async (showSpinner = false) => {
if (showSpinner) setRefreshing(true)
try {
const data = await fetchAllStatus()
setVpsList(data)
setLastUpdate(new Date())
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
refresh()
const id = setInterval(() => refresh(), REFRESH_INTERVAL)
return () => clearInterval(id)
}, [refresh])
const openLogs = async (vpsId, containerId, name) => {
setLogsModal({ vpsId, containerId, name })
setLogsLoading(true)
setLogsContent('')
try {
const data = await fetchLogs(vpsId, containerId)
setLogsContent(data.logs)
} catch (e) {
setLogsContent(`Erreur lors de la récupération des logs :\n${e.message}`)
} finally {
setLogsLoading(false)
}
}
const handleAction = async (vpsId, containerId, action) => {
await containerAction(vpsId, containerId, action)
await refresh()
}
const handleAddVps = async (formData) => {
await addVps(formData)
setShowAddVps(false)
await refresh(true)
}
const handleDeleteVps = async (vpsId) => {
if (!window.confirm('Supprimer ce VPS de la configuration ?')) return
await deleteVps(vpsId)
await refresh(true)
}
// Statistiques globales
const totalOnline = vpsList.filter(v => v.online).length
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0)
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<Header
lastUpdate={lastUpdate}
onRefresh={() => refresh(true)}
onAddVps={() => setShowAddVps(true)}
refreshing={refreshing}
/>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Barre d'erreur backend */}
{error && (
<div className="mb-6 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
Impossible de joindre le backend : <span className="font-mono">{error}</span>
</div>
)}
{/* Stats globales */}
{!loading && vpsList.length > 0 && (
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' },
{ label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-400' },
{ label: 'Actualisation auto', value: '30s', color: 'text-gray-400' },
].map(({ label, value, color }) => (
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
))}
</div>
)}
{/* Chargement initial */}
{loading && (
<div className="text-center py-24 text-gray-600">
<svg className="w-8 h-8 animate-spin mx-auto mb-3 text-indigo-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement
</div>
)}
{/* Aucun VPS */}
{!loading && vpsList.length === 0 && !error && (
<div className="text-center py-24 text-gray-600">
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p>
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p>
<button
onClick={() => setShowAddVps(true)}
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors"
>
Ajouter un VPS
</button>
</div>
)}
{/* Grille de VPS */}
{!loading && vpsList.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2">
{vpsList.map(vps => (
<VpsCard
key={vps.id}
vps={vps}
onAction={handleAction}
onLogs={openLogs}
onDelete={handleDeleteVps}
/>
))}
</div>
)}
</main>
{/* Modal logs */}
{logsModal && (
<LogsModal
name={logsModal.name}
logs={logsContent}
loading={logsLoading}
onClose={() => setLogsModal(null)}
/>
)}
{/* Modal ajout VPS */}
{showAddVps && (
<AddVpsModal
onSave={handleAddVps}
onClose={() => setShowAddVps(false)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
const BASE = '/api'
export async function fetchAllStatus() {
const res = await fetch(`${BASE}/status`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function fetchLogs(vpsId, containerId, lines = 200) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/logs?lines=${lines}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function containerAction(vpsId, containerId, action) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function addVps(data) {
const res = await fetch(`${BASE}/vps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail ?? `HTTP ${res.status}`)
}
return res.json()
}
export async function deleteVps(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '' }
const FIELDS = [
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
{ key: 'id', label: 'Identifiant unique', placeholder: 'vps-1', required: true, type: 'text' },
{ key: 'host', label: 'IP ou hostname', placeholder: '192.168.1.10', required: true, type: 'text' },
{ key: 'port', label: 'Port agent', placeholder: '8001', required: true, type: 'number' },
{ key: 'api_key', label: 'Clé API agent', placeholder: '••••••••', required: true, type: 'password' },
{ key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' },
]
export default function AddVpsModal({ onSave, onClose }) {
const [form, setForm] = useState(DEFAULTS)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError('')
try {
await onSave({ ...form, port: parseInt(form.port, 10) })
} catch (err) {
setError(err.message)
setSaving(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-md bg-gray-900 border border-gray-700 rounded-xl shadow-2xl">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<h3 className="font-semibold text-sm">Ajouter un VPS</h3>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors">
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-3">
{FIELDS.map(({ key, label, placeholder, required, type }) => (
<div key={key}>
<label className="block text-xs text-gray-400 mb-1">
{label} {required && <span className="text-red-400">*</span>}
</label>
<input
type={type}
value={form[key]}
onChange={set(key)}
placeholder={placeholder}
required={required}
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-sm placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
))}
{error && (
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
{error}
</p>
)}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 rounded-lg border border-gray-700 hover:bg-gray-800 text-sm transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium"
>
{saving ? 'Enregistrement…' : 'Ajouter'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Play, Square, RotateCcw, FileText, Loader2 } from 'lucide-react'
import StatusBadge from './StatusBadge'
export default function ContainerRow({ container, onAction, onLogs }) {
const [pending, setPending] = useState(null)
const isRunning = container.status === 'running'
const handle = async (action) => {
setPending(action)
try { await onAction(action) } finally { setPending(null) }
}
return (
<div className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/40 transition-colors group">
<div className="min-w-0 flex-1 pr-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
<StatusBadge status={container.status} />
{container.compose_project && (
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{container.compose_project}
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate mt-0.5">{container.image}</p>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isRunning && (
<ActionBtn title="Démarrer" onClick={() => handle('start')} loading={pending === 'start'}>
<Play size={13} />
</ActionBtn>
)}
{isRunning && (
<ActionBtn title="Arrêter" onClick={() => handle('stop')} loading={pending === 'stop'} danger>
<Square size={13} />
</ActionBtn>
)}
<ActionBtn title="Redémarrer" onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} />
</ActionBtn>
<ActionBtn title="Logs" onClick={onLogs}>
<FileText size={13} />
</ActionBtn>
</div>
</div>
)
}
function ActionBtn({ children, onClick, title, danger = false, loading = false }) {
return (
<button
onClick={onClick}
title={title}
disabled={loading}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'
: 'hover:bg-gray-700 text-gray-500 hover:text-gray-200'
}`}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : children}
</button>
)
}

View File

@@ -0,0 +1,48 @@
import { Monitor } from 'lucide-react'
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing }) {
return (
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-indigo-500/15">
<Monitor size={18} className="text-indigo-400" />
</div>
<span className="font-semibold">VPS Monitor</span>
{lastUpdate && (
<span className="hidden sm:block text-xs text-gray-500 ml-2">
· mis à jour {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`}
fill="none" stroke="currentColor" strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualiser
</button>
<button
onClick={onAddVps}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-indigo-600 hover:bg-indigo-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Ajouter un VPS
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react'
import { X, Download } from 'lucide-react'
export default function LogsModal({ name, logs, loading, onClose }) {
const bottomRef = useRef(null)
useEffect(() => {
if (!loading && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, loading])
const handleDownload = () => {
const blob = new Blob([logs], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '_')}.log`
a.click()
URL.revokeObjectURL(url)
}
// Fermeture sur Échap
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-4xl bg-gray-900 border border-gray-700 rounded-xl flex flex-col max-h-[85vh] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 flex-shrink-0">
<h3 className="font-mono text-sm text-gray-300 truncate">📄 {name}</h3>
<div className="flex items-center gap-2">
{logs && (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
>
<Download size={12} />
Télécharger
</button>
)}
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-800 text-gray-500 hover:text-gray-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Logs */}
<div className="flex-1 overflow-auto bg-gray-950 rounded-b-xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement des logs
</div>
) : (
<>
<pre className="text-xs font-mono text-gray-300 whitespace-pre-wrap leading-5 break-all">
{logs || '(aucun log disponible)'}
</pre>
<div ref={bottomRef} />
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
const STATUSES = {
running: { dot: 'bg-emerald-400 animate-pulse', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20' },
exited: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20' },
paused: { dot: 'bg-yellow-400', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20' },
restarting: { dot: 'bg-blue-400 animate-pulse', text: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
dead: { dot: 'bg-gray-500', text: 'text-gray-500', bg: 'bg-gray-500/10 border-gray-500/20' },
}
export default function StatusBadge({ status }) {
const s = STATUSES[status] ?? STATUSES.dead
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${s.bg} ${s.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.dot}`} />
{status}
</span>
)
}

View File

@@ -0,0 +1,95 @@
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import ContainerRow from './ContainerRow'
export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
const [collapsed, setCollapsed] = useState(false)
const running = vps.containers.filter(c => c.status === 'running').length
const total = vps.containers.length
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<div className={`p-2 rounded-lg flex-shrink-0 ${vps.online ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
<Server size={15} className={vps.online ? 'text-emerald-400' : 'text-red-400'} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">{vps.name}</h3>
<p className="text-xs text-gray-500 truncate">{vps.host}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{vps.online ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
<Wifi size={12} />
<span className="hidden sm:inline">{running}/{total} actifs</span>
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<WifiOff size={12} />
<span className="hidden sm:inline">Hors ligne</span>
</span>
)}
<button
onClick={() => setCollapsed(c => !c)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
title={collapsed ? 'Déplier' : 'Replier'}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
<button
onClick={() => onDelete(vps.id)}
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
title="Supprimer ce VPS"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Erreur de connexion */}
{!vps.online && vps.error && (
<div className="px-4 py-2.5 bg-red-950/30 border-b border-red-900/30 text-xs text-red-400 font-mono">
{vps.error}
</div>
)}
{/* Description */}
{vps.description && !collapsed && (
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
)}
{/* Conteneurs */}
{!collapsed && vps.online && (
<div className="divide-y divide-gray-800/50 flex-1">
{total === 0 ? (
<p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p>
) : (
vps.containers.map(c => (
<ContainerRow
key={c.id}
container={c}
onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
/>
))
)}
</div>
)}
{/* Footer stats */}
{!collapsed && vps.online && total > 0 && (
<div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600">
<span><span className="text-emerald-500">{running}</span> en cours</span>
<span><span className="text-gray-400">{total - running}</span> arrêtés</span>
<span><span className="text-gray-400">{total}</span> total</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scrollbar minimaliste */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
darkMode: 'class',
theme: { extend: {} },
plugins: [],
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})