feat : update agent 2
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 8s
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
1456
|
2317
|
||||||
1540
|
2396
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ Expose une API REST utilisée par le backend central pour interroger les contene
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
import psutil
|
||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, NotFound
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Security
|
from fastapi import Depends, FastAPI, HTTPException, Security
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -59,14 +62,18 @@ def list_containers(_: None = Depends(require_api_key)):
|
|||||||
result = []
|
result = []
|
||||||
for c in client.containers.list(all=True):
|
for c in client.containers.list(all=True):
|
||||||
image_tag = c.image.tags[0] if c.image.tags else c.image.short_id
|
image_tag = c.image.tags[0] if c.image.tags else c.image.short_id
|
||||||
|
health_state = c.attrs.get("State", {}).get("Health", {})
|
||||||
|
health = health_state.get("Status", "none") if health_state else "none"
|
||||||
result.append({
|
result.append({
|
||||||
"id": c.short_id,
|
"id": c.short_id,
|
||||||
"name": c.name.lstrip("/"),
|
"name": c.name.lstrip("/"),
|
||||||
"status": c.status,
|
"status": c.status,
|
||||||
|
"health": health,
|
||||||
"image": image_tag,
|
"image": image_tag,
|
||||||
"created": c.attrs.get("Created", ""),
|
"created": c.attrs.get("Created", ""),
|
||||||
"compose_project": c.labels.get("com.docker.compose.project", ""),
|
"compose_project": c.labels.get("com.docker.compose.project", ""),
|
||||||
"compose_service": c.labels.get("com.docker.compose.service", ""),
|
"compose_service": c.labels.get("com.docker.compose.service", ""),
|
||||||
|
"compose_working_dir": c.labels.get("com.docker.compose.project.working_dir", ""),
|
||||||
"ports": {
|
"ports": {
|
||||||
host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings]
|
host: [{"HostIp": b["HostIp"], "HostPort": b["HostPort"]} for b in bindings]
|
||||||
for host, bindings in (c.ports or {}).items()
|
for host, bindings in (c.ports or {}).items()
|
||||||
@@ -102,6 +109,69 @@ def container_action(container_id: str, action: str, _: None = Depends(require_a
|
|||||||
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
raise HTTPException(status_code=404, detail="Conteneur introuvable")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/system")
|
||||||
|
def system_info(_: None = Depends(require_api_key)):
|
||||||
|
"""Retourne les informations système : CPU, RAM et bande passante."""
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.5)
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
|
||||||
|
net1 = psutil.net_io_counters()
|
||||||
|
time.sleep(0.5)
|
||||||
|
net2 = psutil.net_io_counters()
|
||||||
|
net_sent_per_sec = (net2.bytes_sent - net1.bytes_sent) * 2
|
||||||
|
net_recv_per_sec = (net2.bytes_recv - net1.bytes_recv) * 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cpu_percent": cpu_percent,
|
||||||
|
"ram_used": mem.used,
|
||||||
|
"ram_total": mem.total,
|
||||||
|
"ram_percent": mem.percent,
|
||||||
|
"net_sent_per_sec": net_sent_per_sec,
|
||||||
|
"net_recv_per_sec": net_recv_per_sec,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/compose/update")
|
||||||
|
def compose_update(project: str, _: None = Depends(require_api_key)):
|
||||||
|
"""Pull les nouvelles images et recrée les conteneurs d'un projet compose."""
|
||||||
|
client = get_docker_client()
|
||||||
|
|
||||||
|
working_dir = None
|
||||||
|
for c in client.containers.list(all=True):
|
||||||
|
if c.labels.get("com.docker.compose.project") == project:
|
||||||
|
working_dir = c.labels.get("com.docker.compose.project.working_dir")
|
||||||
|
if working_dir:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not working_dir:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Projet compose '{project}' introuvable ou sans répertoire de travail",
|
||||||
|
)
|
||||||
|
|
||||||
|
output = ""
|
||||||
|
|
||||||
|
pull = subprocess.run(
|
||||||
|
["docker", "compose", "pull"],
|
||||||
|
cwd=working_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
output += pull.stdout + pull.stderr
|
||||||
|
|
||||||
|
up = subprocess.run(
|
||||||
|
["docker", "compose", "up", "-d", "--remove-orphans"],
|
||||||
|
cwd=working_dir,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
output += up.stdout + up.stderr
|
||||||
|
|
||||||
|
return {"output": output, "project": project, "working_dir": working_dir}
|
||||||
|
|
||||||
|
|
||||||
# ─── Entrée ───────────────────────────────────────────────────────────────────
|
# ─── Entrée ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi>=0.111.0
|
fastapi>=0.111.0
|
||||||
uvicorn[standard]>=0.30.0
|
uvicorn[standard]>=0.30.0
|
||||||
docker>=7.1.0
|
docker>=7.1.0
|
||||||
|
psutil>=5.9.0
|
||||||
|
|||||||
Binary file not shown.
@@ -62,6 +62,10 @@ class ActionRequest(BaseModel):
|
|||||||
action: str # start | stop | restart
|
action: str # start | stop | restart
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeUpdateRequest(BaseModel):
|
||||||
|
project: str
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
@@ -209,14 +213,22 @@ async def agent_post(vps: dict, path: str, payload: dict | None = None):
|
|||||||
async def fetch_vps_status(vps: dict) -> dict:
|
async def fetch_vps_status(vps: dict) -> dict:
|
||||||
"""Interroge un agent et retourne son état complet."""
|
"""Interroge un agent et retourne son état complet."""
|
||||||
try:
|
try:
|
||||||
containers = await agent_get(vps, "/containers")
|
containers_res, system_res = await asyncio.gather(
|
||||||
|
agent_get(vps, "/containers"),
|
||||||
|
agent_get(vps, "/system"),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
if isinstance(containers_res, Exception):
|
||||||
|
raise containers_res
|
||||||
|
system = system_res if not isinstance(system_res, Exception) else None
|
||||||
return {
|
return {
|
||||||
"id": vps["id"],
|
"id": vps["id"],
|
||||||
"name": vps["name"],
|
"name": vps["name"],
|
||||||
"host": vps["host"],
|
"host": vps["host"],
|
||||||
"description": vps.get("description", ""),
|
"description": vps.get("description", ""),
|
||||||
"online": True,
|
"online": True,
|
||||||
"containers": containers,
|
"containers": containers_res,
|
||||||
|
"system": system,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
@@ -227,6 +239,7 @@ async def fetch_vps_status(vps: dict) -> dict:
|
|||||||
"online": False,
|
"online": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"containers": [],
|
"containers": [],
|
||||||
|
"system": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -347,3 +360,24 @@ async def container_action(
|
|||||||
return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}")
|
return await agent_post(vps, f"/containers/{container_id}/action?action={body.action}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=502, detail=str(e))
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/vps/{vps_id}/compose/update")
|
||||||
|
async def compose_update(
|
||||||
|
vps_id: str, body: ComposeUpdateRequest,
|
||||||
|
_: Annotated[dict, Depends(get_current_user)] = None
|
||||||
|
):
|
||||||
|
"""Lance docker compose pull + up sur un projet 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:
|
||||||
|
url = f"http://{vps['host']}:{vps['port']}/compose/update?project={body.project}"
|
||||||
|
headers = {"X-API-Key": vps["api_key"]}
|
||||||
|
timeout = aiohttp.ClientTimeout(total=600)
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(url, headers=headers, timeout=timeout) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
return await r.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken } from './api/client'
|
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs, authStatus, getToken, setToken, composeUpdate } 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'
|
||||||
@@ -24,6 +24,10 @@ export default function App() {
|
|||||||
const [logsLoading, setLogsLoading] = useState(false)
|
const [logsLoading, setLogsLoading] = useState(false)
|
||||||
const [showAddVps, setShowAddVps] = useState(false)
|
const [showAddVps, setShowAddVps] = useState(false)
|
||||||
|
|
||||||
|
const [updateModal, setUpdateModal] = useState(null) // { vpsId, project }
|
||||||
|
const [updateContent, setUpdateContent] = useState('')
|
||||||
|
const [updateLoading, setUpdateLoading] = useState(false)
|
||||||
|
|
||||||
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
// Vérifie si des utilisateurs existent (pour afficher login ou register)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authStatus()
|
authStatus()
|
||||||
@@ -114,6 +118,21 @@ export default function App() {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (vpsId, project) => {
|
||||||
|
setUpdateModal({ vpsId, project })
|
||||||
|
setUpdateLoading(true)
|
||||||
|
setUpdateContent('')
|
||||||
|
try {
|
||||||
|
const data = await composeUpdate(vpsId, project)
|
||||||
|
setUpdateContent(data.output || '(aucune sortie)')
|
||||||
|
} catch (e) {
|
||||||
|
setUpdateContent(`Erreur lors de la mise à jour :\n${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setUpdateLoading(false)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddVps = async (formData) => {
|
const handleAddVps = async (formData) => {
|
||||||
await addVps(formData)
|
await addVps(formData)
|
||||||
setShowAddVps(false)
|
setShowAddVps(false)
|
||||||
@@ -214,6 +233,7 @@ export default function App() {
|
|||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
onLogs={openLogs}
|
onLogs={openLogs}
|
||||||
onDelete={handleDeleteVps}
|
onDelete={handleDeleteVps}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -230,6 +250,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modal mise à jour compose */}
|
||||||
|
{updateModal && (
|
||||||
|
<LogsModal
|
||||||
|
name={`🔄 Update — ${updateModal.project}`}
|
||||||
|
logs={updateContent}
|
||||||
|
loading={updateLoading}
|
||||||
|
onClose={() => setUpdateModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modal ajout VPS */}
|
{/* Modal ajout VPS */}
|
||||||
{showAddVps && (
|
{showAddVps && (
|
||||||
<AddVpsModal
|
<AddVpsModal
|
||||||
|
|||||||
@@ -90,3 +90,12 @@ export async function deleteVps(vpsId) {
|
|||||||
})
|
})
|
||||||
return handleResponse(res)
|
return handleResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function composeUpdate(vpsId, project) {
|
||||||
|
const res = await fetch(`${BASE}/vps/${vpsId}/compose/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ project }),
|
||||||
|
})
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Play, Square, RotateCcw, FileText, Loader2 } from 'lucide-react'
|
import { Play, Square, RotateCcw, FileText, Loader2, Heart } from 'lucide-react'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
|
|
||||||
|
const HEALTH_STYLES = {
|
||||||
|
healthy: { dot: 'bg-emerald-400', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'healthy' },
|
||||||
|
unhealthy: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20', label: 'unhealthy' },
|
||||||
|
starting: { dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20', label: 'starting' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthBadge({ health }) {
|
||||||
|
const s = HEALTH_STYLES[health]
|
||||||
|
if (!s) return null
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs font-medium border ${s.bg} ${s.text}`}>
|
||||||
|
<Heart size={9} className="flex-shrink-0" />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContainerRow({ container, onAction, onLogs }) {
|
export default function ContainerRow({ container, onAction, onLogs }) {
|
||||||
const [pending, setPending] = useState(null)
|
const [pending, setPending] = useState(null)
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
@@ -16,8 +33,7 @@ export default function ContainerRow({ container, onAction, onLogs }) {
|
|||||||
<div className="min-w-0 flex-1 pr-3">
|
<div className="min-w-0 flex-1 pr-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
|
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
|
||||||
<StatusBadge status={container.status} />
|
<StatusBadge status={container.status} /> <HealthBadge health={container.health} /> {container.compose_project && (
|
||||||
{container.compose_project && (
|
|
||||||
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
|
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
|
||||||
{container.compose_project}
|
{container.compose_project}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp, RefreshCw, Cpu, MemoryStick, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ContainerRow from './ContainerRow'
|
import ContainerRow from './ContainerRow'
|
||||||
|
|
||||||
export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
|
function formatBytes(bps) {
|
||||||
|
if (bps < 1024) return `${bps.toFixed(0)} B/s`
|
||||||
|
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`
|
||||||
|
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRam(bytes) {
|
||||||
|
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
|
||||||
|
return `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VpsCard({ vps, onAction, onLogs, onDelete, onUpdate }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [updatingProject, setUpdatingProject] = useState(null)
|
||||||
|
|
||||||
const running = vps.containers.filter(c => c.status === 'running').length
|
const running = vps.containers.filter(c => c.status === 'running').length
|
||||||
const total = vps.containers.length
|
const total = vps.containers.length
|
||||||
|
|
||||||
|
const composeProjects = [...new Set(vps.containers.map(c => c.compose_project).filter(Boolean))]
|
||||||
|
|
||||||
|
const handleUpdate = async (project) => {
|
||||||
|
setUpdatingProject(project)
|
||||||
|
try { await onUpdate(vps.id, project) } finally { setUpdatingProject(null) }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -59,6 +78,25 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Informations système */}
|
||||||
|
{vps.online && vps.system && !collapsed && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-800/60 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-400 bg-gray-900/50">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Cpu size={11} className="text-indigo-400" />
|
||||||
|
CPU <span className={`font-medium ml-0.5 ${vps.system.cpu_percent > 80 ? 'text-red-400' : vps.system.cpu_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{vps.system.cpu_percent.toFixed(1)}%</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MemoryStick size={11} className="text-indigo-400" />
|
||||||
|
RAM <span className={`font-medium ml-0.5 ${vps.system.ram_percent > 80 ? 'text-red-400' : vps.system.ram_percent > 60 ? 'text-yellow-400' : 'text-emerald-400'}`}>{formatRam(vps.system.ram_used)}/{formatRam(vps.system.ram_total)}</span>
|
||||||
|
<span className="text-gray-600">({vps.system.ram_percent.toFixed(0)}%)</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ArrowUp size={11} className="text-sky-400" />{formatBytes(vps.system.net_sent_per_sec)}
|
||||||
|
<ArrowDown size={11} className="text-violet-400 ml-1" />{formatBytes(vps.system.net_recv_per_sec)}
|
||||||
|
</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>
|
||||||
@@ -82,6 +120,24 @@ export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Boutons de mise à jour par projet compose */}
|
||||||
|
{!collapsed && vps.online && composeProjects.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-t border-gray-800/60 flex flex-wrap gap-2">
|
||||||
|
{composeProjects.map(project => (
|
||||||
|
<button
|
||||||
|
key={project}
|
||||||
|
onClick={() => handleUpdate(project)}
|
||||||
|
disabled={!!updatingProject}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-indigo-600/20 border border-indigo-500/30 text-indigo-300 hover:bg-indigo-600/40 hover:text-indigo-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={`docker compose pull && up -d (${project})`}
|
||||||
|
>
|
||||||
|
<RefreshCw size={11} className={updatingProject === project ? 'animate-spin' : ''} />
|
||||||
|
Update {project}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer stats */}
|
{/* Footer stats */}
|
||||||
{!collapsed && vps.online && total > 0 && (
|
{!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">
|
<div className="px-4 py-2 border-t border-gray-800/60 flex gap-4 text-xs text-gray-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user