From c7cc18101a738bbc09867c242df2ca8540820eb0 Mon Sep 17 00:00:00 2001
From: jeanotx32
Date: Mon, 18 May 2026 23:48:50 -0400
Subject: [PATCH] Feat : Various features
---
vps-monitor/.pids | 4 +-
.../backend/__pycache__/main.cpython-313.pyc | Bin 22890 -> 26091 bytes
vps-monitor/backend/main.py | 65 ++++++++++-
vps-monitor/frontend/src/App.jsx | 58 +++++++--
vps-monitor/frontend/src/api/client.js | 9 ++
.../frontend/src/components/AddVpsModal.jsx | 9 +-
.../frontend/src/components/EditVpsModal.jsx | 110 ++++++++++++++++++
.../frontend/src/components/Header.jsx | 19 ++-
.../frontend/src/components/TagInput.jsx | 73 ++++++++++++
.../frontend/src/components/VpsCard.jsx | 27 ++++-
vps-monitor/start.sh | 1 +
11 files changed, 353 insertions(+), 22 deletions(-)
create mode 100644 vps-monitor/frontend/src/components/EditVpsModal.jsx
create mode 100644 vps-monitor/frontend/src/components/TagInput.jsx
diff --git a/vps-monitor/.pids b/vps-monitor/.pids
index b291492..cb8ea29 100644
--- a/vps-monitor/.pids
+++ b/vps-monitor/.pids
@@ -1,2 +1,2 @@
-2317
-2396
+5812
+5870
diff --git a/vps-monitor/backend/__pycache__/main.cpython-313.pyc b/vps-monitor/backend/__pycache__/main.cpython-313.pyc
index 0d87bb46cf548ae79d22f941e845fb5405866705..99e4c741d7696efa9aa697df0c2bda71c460eb18 100644
GIT binary patch
delta 8032
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
delta 5488
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
G$o~P1UeRj+
diff --git a/vps-monitor/backend/main.py b/vps-monitor/backend/main.py
index 7674604..4fdba3c 100644
--- a/vps-monitor/backend/main.py
+++ b/vps-monitor/backend/main.py
@@ -5,6 +5,7 @@ Agrège les données de tous les agents et expose une API REST pour le frontend.
"""
import asyncio
+import json
import os
import secrets
import sqlite3
@@ -56,12 +57,22 @@ class VpsConfig(BaseModel):
port: int = 8001
api_key: str
description: str = ""
+ tags: list[str] = []
class ActionRequest(BaseModel):
action: str # start | stop | restart
+class VpsUpdateRequest(BaseModel):
+ name: str
+ host: str
+ port: int = 8001
+ api_key: str = "" # vide = conserver la clé existante
+ description: str = ""
+ tags: list[str] = []
+
+
class ComposeUpdateRequest(BaseModel):
project: str
@@ -108,9 +119,15 @@ def init_db() -> None:
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 8001,
api_key TEXT NOT NULL,
- description TEXT NOT NULL DEFAULT ''
+ description TEXT NOT NULL DEFAULT '',
+ tags TEXT NOT NULL DEFAULT '[]'
)
""")
+ # Migration : ajoute la colonne tags si elle n'existe pas encore
+ try:
+ conn.execute("ALTER TABLE vps ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
+ except Exception:
+ pass # colonne déjà présente
init_db()
@@ -133,14 +150,21 @@ def add_user(user: dict) -> None:
def load_vps() -> list[dict]:
with get_db() as conn:
- return [dict(r) for r in conn.execute("SELECT * FROM vps").fetchall()]
+ rows = [dict(r) for r in conn.execute("SELECT * FROM vps").fetchall()]
+ for row in rows:
+ try:
+ row["tags"] = json.loads(row.get("tags") or "[]")
+ except Exception:
+ row["tags"] = []
+ return rows
def insert_vps(vps: dict) -> None:
with get_db() as conn:
conn.execute(
- "INSERT INTO vps (id, name, host, port, api_key, description) VALUES (?, ?, ?, ?, ?, ?)",
- (vps["id"], vps["name"], vps["host"], vps["port"], vps["api_key"], vps.get("description", "")),
+ "INSERT INTO vps (id, name, host, port, api_key, description, tags) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (vps["id"], vps["name"], vps["host"], vps["port"], vps["api_key"],
+ vps.get("description", ""), json.dumps(vps.get("tags", []))),
)
@@ -150,6 +174,16 @@ def remove_vps(vps_id: str) -> bool:
return cur.rowcount > 0
+def update_vps(vps_id: str, data: dict) -> bool:
+ with get_db() as conn:
+ cur = conn.execute(
+ "UPDATE vps SET name=?, host=?, port=?, api_key=?, description=?, tags=? WHERE id=?",
+ (data["name"], data["host"], data["port"], data["api_key"],
+ data["description"], json.dumps(data.get("tags", [])), vps_id),
+ )
+ return cur.rowcount > 0
+
+
# ─── Auth helpers ─────────────────────────────────────────────────────────────
def create_token(username: str, role: str) -> str:
@@ -229,6 +263,7 @@ async def fetch_vps_status(vps: dict) -> dict:
"online": True,
"containers": containers_res,
"system": system,
+ "tags": vps.get("tags", []),
}
except Exception as e:
return {
@@ -240,6 +275,7 @@ async def fetch_vps_status(vps: dict) -> dict:
"error": str(e),
"containers": [],
"system": None,
+ "tags": vps.get("tags", []),
}
@@ -293,7 +329,8 @@ def me(current_user: Annotated[dict, Depends(get_current_user)]):
def list_vps(_: Annotated[dict, Depends(get_current_user)]):
"""Liste les VPS configurés (sans les clés API)."""
return [
- {"id": v["id"], "name": v["name"], "host": v["host"], "description": v.get("description", "")}
+ {"id": v["id"], "name": v["name"], "host": v["host"],
+ "description": v.get("description", ""), "tags": v.get("tags", [])}
for v in load_vps()
]
@@ -315,6 +352,24 @@ def delete_vps(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
return {"status": "ok"}
+@app.put("/api/vps/{vps_id}")
+def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get_current_user)]):
+ """Met à jour les paramètres d'un VPS (name, host, port, api_key, description)."""
+ existing = next((v for v in load_vps() if v["id"] == vps_id), None)
+ if not existing:
+ raise HTTPException(status_code=404, detail="VPS introuvable")
+ data = {
+ "name": body.name,
+ "host": body.host,
+ "port": body.port,
+ "api_key": body.api_key.strip() or existing["api_key"],
+ "description": body.description,
+ "tags": body.tags,
+ }
+ update_vps(vps_id, data)
+ return {"status": "ok"}
+
+
@app.get("/api/status")
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
"""Retourne l'état de tous les VPS en parallèle."""
diff --git a/vps-monitor/frontend/src/App.jsx b/vps-monitor/frontend/src/App.jsx
index 7cfd995..e3763be 100644
--- a/vps-monitor/frontend/src/App.jsx
+++ b/vps-monitor/frontend/src/App.jsx
@@ -1,12 +1,20 @@
import { useState, useEffect, useCallback } from 'react'
-import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate } from './api/client'
-import Header from './components/Header'
-import VpsCard from './components/VpsCard'
-import LogsModal from './components/LogsModal'
-import AddVpsModal from './components/AddVpsModal'
-import LoginPage from './components/LoginPage'
+import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps } from './api/client'
+import Header from './components/Header'
+import VpsCard from './components/VpsCard'
+import LogsModal from './components/LogsModal'
+import AddVpsModal from './components/AddVpsModal'
+import EditVpsModal from './components/EditVpsModal'
+import LoginPage from './components/LoginPage'
-const REFRESH_INTERVAL = 30_000
+const INTERVAL_OPTIONS = [
+ { label: '10 s', value: 10_000 },
+ { label: '30 s', value: 30_000 },
+ { label: '1 min', value: 60_000 },
+ { label: '2 min', value: 120_000 },
+ { label: '5 min', value: 300_000 },
+ { label: 'Off', value: 0 },
+]
export default function App() {
const [token, setTokenState] = useState(() => getToken())
@@ -23,6 +31,16 @@ export default function App() {
const [logsContent, setLogsContent] = useState('')
const [logsLoading, setLogsLoading] = useState(false)
const [showAddVps, setShowAddVps] = useState(false)
+ const [editVps, setEditVps] = useState(null) // objet vps à éditer
+ const [refreshInterval, setRefreshInterval] = useState(() => {
+ const stored = localStorage.getItem('refreshInterval')
+ return stored ? parseInt(stored, 10) : 30_000
+ })
+
+ const handleIntervalChange = (val) => {
+ setRefreshInterval(val)
+ localStorage.setItem('refreshInterval', val)
+ }
const [updateModal, setUpdateModal] = useState(null) // { vpsId, project }
const [updateContent, setUpdateContent] = useState('')
@@ -85,9 +103,10 @@ export default function App() {
useEffect(() => {
if (!token) return
refresh()
- const id = setInterval(() => refresh(), REFRESH_INTERVAL)
+ if (!refreshInterval) return
+ const id = setInterval(() => refresh(), refreshInterval)
return () => clearInterval(id)
- }, [refresh, token])
+ }, [refresh, token, refreshInterval])
// Extrait le username du token stocké au rechargement de page
useEffect(() => {
@@ -145,6 +164,12 @@ export default function App() {
await refresh(true)
}
+ const handleEditVps = async (vpsId, data) => {
+ await updateVps(vpsId, data)
+ setEditVps(null)
+ await refresh(true)
+ }
+
// Attente vérification auth
if (!authChecked) return null
@@ -172,6 +197,9 @@ export default function App() {
refreshing={refreshing}
username={username}
onLogout={handleLogout}
+ refreshInterval={refreshInterval}
+ onIntervalChange={handleIntervalChange}
+ intervalOptions={INTERVAL_OPTIONS}
/>
@@ -189,7 +217,7 @@ export default function App() {
{[
{ 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' },
+ { label: 'Actualisation auto', value: INTERVAL_OPTIONS.find(o => o.value === refreshInterval)?.label ?? 'Off', color: 'text-gray-400' },
].map(({ label, value, color }) => (
{value}
@@ -234,6 +262,7 @@ export default function App() {
onLogs={openLogs}
onDelete={handleDeleteVps}
onUpdate={handleUpdate}
+ onEdit={setEditVps}
/>
))}
@@ -267,6 +296,15 @@ export default function App() {
onClose={() => setShowAddVps(false)}
/>
)}
+
+ {/* Modal édition VPS */}
+ {editVps && (
+ setEditVps(null)}
+ />
+ )}
)
}
diff --git a/vps-monitor/frontend/src/api/client.js b/vps-monitor/frontend/src/api/client.js
index ffcc613..a56ef78 100644
--- a/vps-monitor/frontend/src/api/client.js
+++ b/vps-monitor/frontend/src/api/client.js
@@ -99,3 +99,12 @@ export async function composeUpdate(vpsId, project) {
})
return handleResponse(res)
}
+
+export async function updateVps(vpsId, data) {
+ const res = await fetch(`${BASE}/vps/${vpsId}`, {
+ method: 'PUT',
+ headers: authHeaders(),
+ body: JSON.stringify(data),
+ })
+ return handleResponse(res)
+}
diff --git a/vps-monitor/frontend/src/components/AddVpsModal.jsx b/vps-monitor/frontend/src/components/AddVpsModal.jsx
index 59cecef..63eea07 100644
--- a/vps-monitor/frontend/src/components/AddVpsModal.jsx
+++ b/vps-monitor/frontend/src/components/AddVpsModal.jsx
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
+import TagInput from './TagInput'
-const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '' }
+const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '', tags: [] }
const FIELDS = [
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
@@ -73,6 +74,12 @@ export default function AddVpsModal({ onSave, onClose }) {
)}
+
+
+
setForm(f => ({ ...f, tags }))} />
+ Entrée ou virgule pour valider
+
+