Feat : Various features
Some checks failed
Build and Push Docker Images / docker (push) Failing after 9s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 9s
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
2317
|
5812
|
||||||
2396
|
5870
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ Agrège les données de tous les agents et expose une API REST pour le frontend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -56,12 +57,22 @@ class VpsConfig(BaseModel):
|
|||||||
port: int = 8001
|
port: int = 8001
|
||||||
api_key: str
|
api_key: str
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class ActionRequest(BaseModel):
|
class ActionRequest(BaseModel):
|
||||||
action: str # start | stop | restart
|
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):
|
class ComposeUpdateRequest(BaseModel):
|
||||||
project: str
|
project: str
|
||||||
|
|
||||||
@@ -108,9 +119,15 @@ def init_db() -> None:
|
|||||||
host TEXT NOT NULL,
|
host TEXT NOT NULL,
|
||||||
port INTEGER NOT NULL DEFAULT 8001,
|
port INTEGER NOT NULL DEFAULT 8001,
|
||||||
api_key TEXT NOT NULL,
|
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()
|
init_db()
|
||||||
@@ -133,14 +150,21 @@ def add_user(user: dict) -> None:
|
|||||||
|
|
||||||
def load_vps() -> list[dict]:
|
def load_vps() -> list[dict]:
|
||||||
with get_db() as conn:
|
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:
|
def insert_vps(vps: dict) -> None:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO vps (id, name, host, port, api_key, description) VALUES (?, ?, ?, ?, ?, ?)",
|
"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", "")),
|
(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
|
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 ─────────────────────────────────────────────────────────────
|
# ─── Auth helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_token(username: str, role: str) -> str:
|
def create_token(username: str, role: str) -> str:
|
||||||
@@ -229,6 +263,7 @@ async def fetch_vps_status(vps: dict) -> dict:
|
|||||||
"online": True,
|
"online": True,
|
||||||
"containers": containers_res,
|
"containers": containers_res,
|
||||||
"system": system,
|
"system": system,
|
||||||
|
"tags": vps.get("tags", []),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -240,6 +275,7 @@ async def fetch_vps_status(vps: dict) -> dict:
|
|||||||
"error": str(e),
|
"error": str(e),
|
||||||
"containers": [],
|
"containers": [],
|
||||||
"system": None,
|
"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)]):
|
def list_vps(_: Annotated[dict, Depends(get_current_user)]):
|
||||||
"""Liste les VPS configurés (sans les clés API)."""
|
"""Liste les VPS configurés (sans les clés API)."""
|
||||||
return [
|
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()
|
for v in load_vps()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -315,6 +352,24 @@ def delete_vps(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
|
|||||||
return {"status": "ok"}
|
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")
|
@app.get("/api/status")
|
||||||
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
|
async def all_status(_: Annotated[dict, Depends(get_current_user)]):
|
||||||
"""Retourne l'état de tous les VPS en parallèle."""
|
"""Retourne l'état de tous les VPS en parallèle."""
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate } from './api/client'
|
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps } from './api/client'
|
||||||
import Header from './components/Header'
|
import Header from './components/Header'
|
||||||
import VpsCard from './components/VpsCard'
|
import VpsCard from './components/VpsCard'
|
||||||
import LogsModal from './components/LogsModal'
|
import LogsModal from './components/LogsModal'
|
||||||
import AddVpsModal from './components/AddVpsModal'
|
import AddVpsModal from './components/AddVpsModal'
|
||||||
|
import EditVpsModal from './components/EditVpsModal'
|
||||||
import LoginPage from './components/LoginPage'
|
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() {
|
export default function App() {
|
||||||
const [token, setTokenState] = useState(() => getToken())
|
const [token, setTokenState] = useState(() => getToken())
|
||||||
@@ -23,6 +31,16 @@ export default function App() {
|
|||||||
const [logsContent, setLogsContent] = useState('')
|
const [logsContent, setLogsContent] = useState('')
|
||||||
const [logsLoading, setLogsLoading] = useState(false)
|
const [logsLoading, setLogsLoading] = useState(false)
|
||||||
const [showAddVps, setShowAddVps] = 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 [updateModal, setUpdateModal] = useState(null) // { vpsId, project }
|
||||||
const [updateContent, setUpdateContent] = useState('')
|
const [updateContent, setUpdateContent] = useState('')
|
||||||
@@ -85,9 +103,10 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
refresh()
|
refresh()
|
||||||
const id = setInterval(() => refresh(), REFRESH_INTERVAL)
|
if (!refreshInterval) return
|
||||||
|
const id = setInterval(() => refresh(), refreshInterval)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [refresh, token])
|
}, [refresh, token, refreshInterval])
|
||||||
|
|
||||||
// Extrait le username du token stocké au rechargement de page
|
// Extrait le username du token stocké au rechargement de page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -145,6 +164,12 @@ export default function App() {
|
|||||||
await refresh(true)
|
await refresh(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditVps = async (vpsId, data) => {
|
||||||
|
await updateVps(vpsId, data)
|
||||||
|
setEditVps(null)
|
||||||
|
await refresh(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Attente vérification auth
|
// Attente vérification auth
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
|
|
||||||
@@ -172,6 +197,9 @@ export default function App() {
|
|||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
username={username}
|
username={username}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
onIntervalChange={handleIntervalChange}
|
||||||
|
intervalOptions={INTERVAL_OPTIONS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
@@ -189,7 +217,7 @@ export default function App() {
|
|||||||
{[
|
{[
|
||||||
{ label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' },
|
{ label: 'VPS en ligne', value: `${totalOnline}/${vpsList.length}`, color: 'text-emerald-400' },
|
||||||
{ label: 'Conteneurs actifs', value: `${totalRunning}/${totalContainers}`, color: 'text-indigo-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 }) => (
|
].map(({ label, value, color }) => (
|
||||||
<div key={label} className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
|
<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-2xl font-bold ${color}`}>{value}</p>
|
||||||
@@ -234,6 +262,7 @@ export default function App() {
|
|||||||
onLogs={openLogs}
|
onLogs={openLogs}
|
||||||
onDelete={handleDeleteVps}
|
onDelete={handleDeleteVps}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
|
onEdit={setEditVps}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,6 +296,15 @@ export default function App() {
|
|||||||
onClose={() => setShowAddVps(false)}
|
onClose={() => setShowAddVps(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal édition VPS */}
|
||||||
|
{editVps && (
|
||||||
|
<EditVpsModal
|
||||||
|
vps={editVps}
|
||||||
|
onSave={handleEditVps}
|
||||||
|
onClose={() => setEditVps(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,3 +99,12 @@ export async function composeUpdate(vpsId, project) {
|
|||||||
})
|
})
|
||||||
return handleResponse(res)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { X } from 'lucide-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 = [
|
const FIELDS = [
|
||||||
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
|
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
|
||||||
@@ -73,6 +74,12 @@ export default function AddVpsModal({ onSave, onClose }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Tags</label>
|
||||||
|
<TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
|
||||||
|
<p className="text-xs text-gray-600 mt-1">Entrée ou virgule pour valider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
110
vps-monitor/frontend/src/components/EditVpsModal.jsx
Normal file
110
vps-monitor/frontend/src/components/EditVpsModal.jsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import TagInput from './TagInput'
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon 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: 'Laisser vide pour conserver la clé actuelle', required: false, type: 'password' },
|
||||||
|
{ key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function EditVpsModal({ vps, onSave, onClose }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: vps.name ?? '',
|
||||||
|
host: vps.host ?? '',
|
||||||
|
port: String(vps.port ?? 8001),
|
||||||
|
api_key: '',
|
||||||
|
description: vps.description ?? '',
|
||||||
|
tags: vps.tags ?? [],
|
||||||
|
})
|
||||||
|
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(vps.id, { ...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">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-sm">Modifier le VPS</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 font-mono">{vps.id}</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">Tags</label>
|
||||||
|
<TagInput tags={form.tags} onChange={tags => setForm(f => ({ ...f, tags }))} />
|
||||||
|
<p className="text-xs text-gray-600 mt-1">Entrée ou virgule pour valider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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…' : 'Enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Monitor, LogOut } from 'lucide-react'
|
import { Monitor, LogOut, Timer } from 'lucide-react'
|
||||||
|
|
||||||
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, onLogout }) {
|
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, username, onLogout, refreshInterval, onIntervalChange, intervalOptions }) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm">
|
<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="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||||
@@ -17,6 +17,21 @@ export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing, us
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Sélecteur d'intervalle */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-gray-800 text-sm text-gray-400">
|
||||||
|
<Timer size={13} className="flex-shrink-0" />
|
||||||
|
<select
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={e => onIntervalChange(Number(e.target.value))}
|
||||||
|
className="bg-transparent text-gray-300 text-xs outline-none cursor-pointer"
|
||||||
|
title="Intervalle d'actualisation automatique"
|
||||||
|
>
|
||||||
|
{intervalOptions.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
|
|||||||
73
vps-monitor/frontend/src/components/TagInput.jsx
Normal file
73
vps-monitor/frontend/src/components/TagInput.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'bg-indigo-500/20 border-indigo-500/40 text-indigo-300',
|
||||||
|
'bg-violet-500/20 border-violet-500/40 text-violet-300',
|
||||||
|
'bg-sky-500/20 border-sky-500/40 text-sky-300',
|
||||||
|
'bg-emerald-500/20 border-emerald-500/40 text-emerald-300',
|
||||||
|
'bg-amber-500/20 border-amber-500/40 text-amber-300',
|
||||||
|
'bg-rose-500/20 border-rose-500/40 text-rose-300',
|
||||||
|
'bg-teal-500/20 border-teal-500/40 text-teal-300',
|
||||||
|
'bg-pink-500/20 border-pink-500/40 text-pink-300',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function tagColor(tag) {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < tag.length; i++) hash = (hash * 31 + tag.charCodeAt(i)) >>> 0
|
||||||
|
return TAG_COLORS[hash % TAG_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagInput({ tags = [], onChange }) {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
const addTag = (raw) => {
|
||||||
|
const tag = raw.trim().toLowerCase().replace(/\s+/g, '-')
|
||||||
|
if (!tag || tags.includes(tag)) return
|
||||||
|
onChange([...tags, tag])
|
||||||
|
setInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (tag) => onChange(tags.filter(t => t !== tag))
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag(input)
|
||||||
|
} else if (e.key === 'Backspace' && !input && tags.length) {
|
||||||
|
removeTag(tags[tags.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5 px-2.5 py-2 rounded-lg bg-gray-800 border border-gray-700 focus-within:border-indigo-500 transition-colors cursor-text min-h-[40px]"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${tagColor(tag)}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeTag(tag) }}
|
||||||
|
className="opacity-60 hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={tags.length === 0 ? 'Ajouter un tag…' : ''}
|
||||||
|
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown } from 'lucide-react'
|
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ContainerRow from './ContainerRow'
|
import ContainerRow from './ContainerRow'
|
||||||
|
import { tagColor } from './TagInput'
|
||||||
|
|
||||||
function formatBytes(bps) {
|
function formatBytes(bps) {
|
||||||
if (bps < 1024) return `${bps.toFixed(0)} B/s`
|
if (bps < 1024) return `${bps.toFixed(0)} B/s`
|
||||||
@@ -13,7 +14,7 @@ function formatRam(bytes) {
|
|||||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate }) {
|
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [updatingProject, setUpdatingProject] = useState(null)
|
const [updatingProject, setUpdatingProject] = useState(null)
|
||||||
|
|
||||||
@@ -61,6 +62,14 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate }) {
|
|||||||
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(vps)}
|
||||||
|
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title="Modifier ce VPS"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(vps.id)}
|
onClick={() => onDelete(vps.id)}
|
||||||
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
|
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
|
||||||
@@ -97,6 +106,20 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{vps.tags?.length > 0 && !collapsed && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-800/60 flex flex-wrap gap-1.5">
|
||||||
|
{vps.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${tagColor(tag)}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{vps.description && !collapsed && (
|
{vps.description && !collapsed && (
|
||||||
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
|
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ log "Démarrage du backend (port $BACKEND_PORT)…"
|
|||||||
"$VENV_DIR/bin/uvicorn" main:app \
|
"$VENV_DIR/bin/uvicorn" main:app \
|
||||||
--host 0.0.0.0 \
|
--host 0.0.0.0 \
|
||||||
--port "$BACKEND_PORT" \
|
--port "$BACKEND_PORT" \
|
||||||
|
--reload \
|
||||||
--log-level warning \
|
--log-level warning \
|
||||||
2>&1 | sed 's/^/ [backend] /'
|
2>&1 | sed 's/^/ [backend] /'
|
||||||
) &
|
) &
|
||||||
|
|||||||
Reference in New Issue
Block a user