Feat : Various features
Some checks failed
Build and Push Docker Images / docker (push) Failing after 9s

This commit is contained in:
jeanotx32
2026-05-18 23:48:50 -04:00
parent dfca25ab03
commit c7cc18101a
11 changed files with 353 additions and 22 deletions

View File

@@ -1,2 +1,2 @@
2317 5812
2396 5870

View File

@@ -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."""

View File

@@ -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>
) )
} }

View File

@@ -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)
}

View File

@@ -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"

View 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>
)
}

View File

@@ -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}

View 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>
)
}

View File

@@ -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>

View File

@@ -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] /'
) & ) &