Feat: add VPS export functionality and import JSON feature in UI
All checks were successful
Build and Push Docker Images / docker (push) Successful in 28s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 28s
This commit is contained in:
Binary file not shown.
@@ -727,6 +727,23 @@ def edit_vps(vps_id: str, body: VpsUpdateRequest, _: Annotated[dict, Depends(get
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/vps/{vps_id}/export")
|
||||
def export_vps(vps_id: str, _: Annotated[dict, Depends(get_current_user)]):
|
||||
"""Exporte la configuration complète d'un VPS (y compris la clé API) pour import ailleurs."""
|
||||
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 {
|
||||
"id": vps["id"],
|
||||
"name": vps["name"],
|
||||
"host": vps["host"],
|
||||
"port": vps["port"],
|
||||
"api_key": vps["api_key"],
|
||||
"description": vps.get("description", ""),
|
||||
"tags": vps.get("tags", []),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/vps/{vps_id}/stats")
|
||||
def get_vps_stats(
|
||||
vps_id: str,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
services:
|
||||
backend:
|
||||
image: git.jeanbonapp.com/jeanbon/scriptvps/backend:latest
|
||||
AppliSurveillance-api:
|
||||
image: git.jeanbonapp.com/jeanbon/vps-monitor-backend:latest
|
||||
hostname: backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8020:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file: ./backend/.env
|
||||
env_file:
|
||||
- path: ./.env
|
||||
required: false
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: git.jeanbonapp.com/jeanbon/scriptvps/frontend:latest
|
||||
AppliSurveillance-ui:
|
||||
image: git.jeanbonapp.com/jeanbon/vps-monitor-frontend:latest
|
||||
ports:
|
||||
- "3000:80"
|
||||
- "3020:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- AppliSurveillance-api
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent } from './api/client'
|
||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate, updateVps, updateAgent, exportVps } from './api/client'
|
||||
import Header from './components/Header'
|
||||
import VpsCard from './components/VpsCard'
|
||||
import LogsModal from './components/LogsModal'
|
||||
@@ -198,6 +198,11 @@ export default function App() {
|
||||
await refresh(true)
|
||||
}
|
||||
|
||||
const handleExportVps = async (vpsId) => {
|
||||
const config = await exportVps(vpsId)
|
||||
await navigator.clipboard.writeText(JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
// Attente vérification auth
|
||||
if (!authChecked) return null
|
||||
|
||||
@@ -305,6 +310,7 @@ export default function App() {
|
||||
onEdit={setEditVps}
|
||||
onStats={(vpsId, vpsName) => setStatsModal({ vpsId, vpsName })}
|
||||
onUpdateAgent={handleUpdateAgent}
|
||||
onExport={handleExportVps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,11 @@ export async function updateVps(vpsId, data) {
|
||||
return handleResponse(res)
|
||||
}
|
||||
|
||||
export async function exportVps(vpsId) {
|
||||
const res = await fetch(`${BASE}/vps/${vpsId}/export`, { headers: authHeaders() })
|
||||
return handleResponse(res)
|
||||
}
|
||||
|
||||
export async function fetchVpsStats(vpsId, duration = 600) {
|
||||
const res = await fetch(`${BASE}/vps/${vpsId}/stats?duration=${duration}`, { headers: authHeaders() })
|
||||
return handleResponse(res)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Upload } from 'lucide-react'
|
||||
import TagInput from './TagInput'
|
||||
|
||||
const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '', tags: [] }
|
||||
@@ -14,9 +14,12 @@ const FIELDS = [
|
||||
]
|
||||
|
||||
export default function AddVpsModal({ onSave, onClose }) {
|
||||
const [mode, setMode] = useState('manual') // 'manual' | 'import'
|
||||
const [form, setForm] = useState(DEFAULTS)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [json, setJson] = useState('')
|
||||
const [jsonError, setJsonError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose() }
|
||||
@@ -26,6 +29,31 @@ export default function AddVpsModal({ onSave, onClose }) {
|
||||
|
||||
const set = (key) => (e) => setForm(f => ({ ...f, [key]: e.target.value }))
|
||||
|
||||
const handleImportJson = () => {
|
||||
setJsonError('')
|
||||
try {
|
||||
const parsed = JSON.parse(json.trim())
|
||||
const required = ['id', 'name', 'host', 'port', 'api_key']
|
||||
const missing = required.filter(k => !parsed[k])
|
||||
if (missing.length > 0) {
|
||||
setJsonError(`Champs manquants : ${missing.join(', ')}`)
|
||||
return
|
||||
}
|
||||
setForm({
|
||||
id: String(parsed.id),
|
||||
name: String(parsed.name),
|
||||
host: String(parsed.host),
|
||||
port: String(parsed.port),
|
||||
api_key: String(parsed.api_key),
|
||||
description: String(parsed.description ?? ''),
|
||||
tags: Array.isArray(parsed.tags) ? parsed.tags : [],
|
||||
})
|
||||
setMode('manual')
|
||||
} catch {
|
||||
setJsonError('JSON invalide. Assurez-vous d\'avoir collé la config exportée.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
@@ -45,58 +73,115 @@ export default function AddVpsModal({ onSave, 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">Ajouter un VPS</h3>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-700 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('manual')}
|
||||
className={`px-2.5 py-1 transition-colors ${mode === 'manual' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`}
|
||||
>
|
||||
Manuel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('import')}
|
||||
className={`px-2.5 py-1 transition-colors flex items-center gap-1 ${mode === 'import' ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-gray-800'}`}
|
||||
>
|
||||
<Upload size={11} />
|
||||
Importer
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
{mode === 'import' ? (
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Collez le JSON copié via le bouton <strong className="text-gray-300">Exporter</strong> d'une autre instance.
|
||||
</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>
|
||||
<textarea
|
||||
value={json}
|
||||
onChange={(e) => setJson(e.target.value)}
|
||||
rows={10}
|
||||
placeholder='{"id": "vps-1", "name": "Mon VPS", ...}'
|
||||
className="w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-xs font-mono placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/40 rounded-lg px-3 py-2">
|
||||
{jsonError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<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="button"
|
||||
onClick={handleImportJson}
|
||||
disabled={!json.trim()}
|
||||
className="flex-1 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-sm transition-colors font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Upload size={13} />
|
||||
Importer
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<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>
|
||||
{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…' : 'Ajouter'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload } from 'lucide-react'
|
||||
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown, Pencil, BarChart2, CloudDownload, Copy, Check } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import ContainerRow from './ContainerRow'
|
||||
import { tagColor } from './TagInput'
|
||||
@@ -14,10 +14,17 @@ function formatRam(bytes) {
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent }) {
|
||||
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onEdit, onStats, onUpdateAgent, onExport }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [updatingProject, setUpdatingProject] = useState(null)
|
||||
const [updatingAgent, setUpdatingAgent] = useState(false)
|
||||
const [exported, setExported] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
await onExport(vps.id)
|
||||
setExported(true)
|
||||
setTimeout(() => setExported(false), 2000)
|
||||
}
|
||||
|
||||
const running = vps.containers.filter(c => c.status === 'running').length
|
||||
const total = vps.containers.length
|
||||
@@ -78,6 +85,14 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate, onE
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className={`p-1.5 rounded hover:bg-gray-800 transition-colors ${exported ? 'text-emerald-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
title={exported ? 'Config copiée !' : 'Exporter la config (copier JSON)'}
|
||||
>
|
||||
{exported ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onEdit(vps)}
|
||||
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user