feat: add VPS Monitor backend and frontend services
Some checks failed
Build and Push Docker Images / docker (push) Failing after 5s

- Create systemd service for VPS Monitor agent.
- Add FastAPI backend with endpoints for managing VPS configurations and statuses.
- Implement Dockerfile for backend service with required dependencies.
- Create frontend using React with Vite and Tailwind CSS for styling.
- Add API client for communicating with the backend.
- Implement components for displaying VPS information and logs.
- Set up Docker Compose for orchestrating backend and frontend services.
- Add environment configuration files for backend and agent.
- Implement CORS support in the backend for frontend communication.
This commit is contained in:
jeanotx32
2026-05-18 22:31:36 -04:00
parent f83f8f97fa
commit cf0b3f0acf
28 changed files with 1601 additions and 16 deletions

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPS Monitor</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy vers le backend FastAPI
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "vps-monitor-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.396.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useCallback } from 'react'
import { fetchAllStatus, containerAction, addVps, deleteVps, fetchLogs } from './api/client'
import Header from './components/Header'
import VpsCard from './components/VpsCard'
import LogsModal from './components/LogsModal'
import AddVpsModal from './components/AddVpsModal'
const REFRESH_INTERVAL = 30_000
export default function App() {
const [vpsList, setVpsList] = useState([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState(null)
const [lastUpdate, setLastUpdate] = useState(null)
const [logsModal, setLogsModal] = useState(null) // { vpsId, containerId, name }
const [logsContent, setLogsContent] = useState('')
const [logsLoading, setLogsLoading] = useState(false)
const [showAddVps, setShowAddVps] = useState(false)
const refresh = useCallback(async (showSpinner = false) => {
if (showSpinner) setRefreshing(true)
try {
const data = await fetchAllStatus()
setVpsList(data)
setLastUpdate(new Date())
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => {
refresh()
const id = setInterval(() => refresh(), REFRESH_INTERVAL)
return () => clearInterval(id)
}, [refresh])
const openLogs = async (vpsId, containerId, name) => {
setLogsModal({ vpsId, containerId, name })
setLogsLoading(true)
setLogsContent('')
try {
const data = await fetchLogs(vpsId, containerId)
setLogsContent(data.logs)
} catch (e) {
setLogsContent(`Erreur lors de la récupération des logs :\n${e.message}`)
} finally {
setLogsLoading(false)
}
}
const handleAction = async (vpsId, containerId, action) => {
await containerAction(vpsId, containerId, action)
await refresh()
}
const handleAddVps = async (formData) => {
await addVps(formData)
setShowAddVps(false)
await refresh(true)
}
const handleDeleteVps = async (vpsId) => {
if (!window.confirm('Supprimer ce VPS de la configuration ?')) return
await deleteVps(vpsId)
await refresh(true)
}
// Statistiques globales
const totalOnline = vpsList.filter(v => v.online).length
const totalContainers = vpsList.reduce((acc, v) => acc + v.containers.length, 0)
const totalRunning = vpsList.reduce((acc, v) => acc + v.containers.filter(c => c.status === 'running').length, 0)
return (
<div className="min-h-screen bg-gray-950 text-gray-100">
<Header
lastUpdate={lastUpdate}
onRefresh={() => refresh(true)}
onAddVps={() => setShowAddVps(true)}
refreshing={refreshing}
/>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Barre d'erreur backend */}
{error && (
<div className="mb-6 bg-red-950/40 border border-red-800/50 rounded-xl px-4 py-3 text-sm text-red-300">
Impossible de joindre le backend : <span className="font-mono">{error}</span>
</div>
)}
{/* Stats globales */}
{!loading && vpsList.length > 0 && (
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ 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' },
].map(({ label, value, color }) => (
<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-xs text-gray-500 mt-0.5">{label}</p>
</div>
))}
</div>
)}
{/* Chargement initial */}
{loading && (
<div className="text-center py-24 text-gray-600">
<svg className="w-8 h-8 animate-spin mx-auto mb-3 text-indigo-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement
</div>
)}
{/* Aucun VPS */}
{!loading && vpsList.length === 0 && !error && (
<div className="text-center py-24 text-gray-600">
<p className="text-lg font-medium text-gray-500">Aucun VPS configuré</p>
<p className="text-sm mt-1">Cliquez sur <strong className="text-gray-400">Ajouter un VPS</strong> pour commencer.</p>
<button
onClick={() => setShowAddVps(true)}
className="mt-6 px-4 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-sm transition-colors"
>
Ajouter un VPS
</button>
</div>
)}
{/* Grille de VPS */}
{!loading && vpsList.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2">
{vpsList.map(vps => (
<VpsCard
key={vps.id}
vps={vps}
onAction={handleAction}
onLogs={openLogs}
onDelete={handleDeleteVps}
/>
))}
</div>
)}
</main>
{/* Modal logs */}
{logsModal && (
<LogsModal
name={logsModal.name}
logs={logsContent}
loading={logsLoading}
onClose={() => setLogsModal(null)}
/>
)}
{/* Modal ajout VPS */}
{showAddVps && (
<AddVpsModal
onSave={handleAddVps}
onClose={() => setShowAddVps(false)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
const BASE = '/api'
export async function fetchAllStatus() {
const res = await fetch(`${BASE}/status`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function fetchLogs(vpsId, containerId, lines = 200) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/logs?lines=${lines}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function containerAction(vpsId, containerId, action) {
const res = await fetch(`${BASE}/vps/${vpsId}/containers/${containerId}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function addVps(data) {
const res = await fetch(`${BASE}/vps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail ?? `HTTP ${res.status}`)
}
return res.json()
}
export async function deleteVps(vpsId) {
const res = await fetch(`${BASE}/vps/${vpsId}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import { X } from 'lucide-react'
const DEFAULTS = { id: '', name: '', host: '', port: '8001', api_key: '', description: '' }
const FIELDS = [
{ key: 'name', label: 'Nom affiché', placeholder: 'Mon VPS 1', required: true, type: 'text' },
{ key: 'id', label: 'Identifiant unique', placeholder: '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: '••••••••', required: true, type: 'password' },
{ key: 'description', label: 'Description', placeholder: 'Optionnel', required: false, type: 'text' },
]
export default function AddVpsModal({ onSave, onClose }) {
const [form, setForm] = useState(DEFAULTS)
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({ ...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">
<h3 className="font-semibold text-sm">Ajouter un VPS</h3>
<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 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>
)
}

View File

@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Play, Square, RotateCcw, FileText, Loader2 } from 'lucide-react'
import StatusBadge from './StatusBadge'
export default function ContainerRow({ container, onAction, onLogs }) {
const [pending, setPending] = useState(null)
const isRunning = container.status === 'running'
const handle = async (action) => {
setPending(action)
try { await onAction(action) } finally { setPending(null) }
}
return (
<div className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/40 transition-colors group">
<div className="min-w-0 flex-1 pr-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate max-w-[160px]">{container.name}</span>
<StatusBadge status={container.status} />
{container.compose_project && (
<span className="hidden sm:inline text-xs text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{container.compose_project}
</span>
)}
</div>
<p className="text-xs text-gray-500 truncate mt-0.5">{container.image}</p>
</div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isRunning && (
<ActionBtn title="Démarrer" onClick={() => handle('start')} loading={pending === 'start'}>
<Play size={13} />
</ActionBtn>
)}
{isRunning && (
<ActionBtn title="Arrêter" onClick={() => handle('stop')} loading={pending === 'stop'} danger>
<Square size={13} />
</ActionBtn>
)}
<ActionBtn title="Redémarrer" onClick={() => handle('restart')} loading={pending === 'restart'}>
<RotateCcw size={13} />
</ActionBtn>
<ActionBtn title="Logs" onClick={onLogs}>
<FileText size={13} />
</ActionBtn>
</div>
</div>
)
}
function ActionBtn({ children, onClick, title, danger = false, loading = false }) {
return (
<button
onClick={onClick}
title={title}
disabled={loading}
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
danger
? 'hover:bg-red-500/20 text-gray-500 hover:text-red-400'
: 'hover:bg-gray-700 text-gray-500 hover:text-gray-200'
}`}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : children}
</button>
)
}

View File

@@ -0,0 +1,48 @@
import { Monitor } from 'lucide-react'
export default function Header({ lastUpdate, onRefresh, onAddVps, refreshing }) {
return (
<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="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-indigo-500/15">
<Monitor size={18} className="text-indigo-400" />
</div>
<span className="font-semibold">VPS Monitor</span>
{lastUpdate && (
<span className="hidden sm:block text-xs text-gray-500 ml-2">
· mis à jour {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-gray-800 hover:bg-gray-700 disabled:opacity-50 transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`}
fill="none" stroke="currentColor" strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualiser
</button>
<button
onClick={onAddVps}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-indigo-600 hover:bg-indigo-500 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Ajouter un VPS
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react'
import { X, Download } from 'lucide-react'
export default function LogsModal({ name, logs, loading, onClose }) {
const bottomRef = useRef(null)
useEffect(() => {
if (!loading && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, loading])
const handleDownload = () => {
const blob = new Blob([logs], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '_')}.log`
a.click()
URL.revokeObjectURL(url)
}
// Fermeture sur Échap
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
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-4xl bg-gray-900 border border-gray-700 rounded-xl flex flex-col max-h-[85vh] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 flex-shrink-0">
<h3 className="font-mono text-sm text-gray-300 truncate">📄 {name}</h3>
<div className="flex items-center gap-2">
{logs && (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
>
<Download size={12} />
Télécharger
</button>
)}
<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>
</div>
{/* Logs */}
<div className="flex-1 overflow-auto bg-gray-950 rounded-b-xl p-4">
{loading ? (
<div className="flex items-center gap-2 text-gray-500 text-sm">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Chargement des logs
</div>
) : (
<>
<pre className="text-xs font-mono text-gray-300 whitespace-pre-wrap leading-5 break-all">
{logs || '(aucun log disponible)'}
</pre>
<div ref={bottomRef} />
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
const STATUSES = {
running: { dot: 'bg-emerald-400 animate-pulse', text: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20' },
exited: { dot: 'bg-red-400', text: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20' },
paused: { dot: 'bg-yellow-400', text: 'text-yellow-400', bg: 'bg-yellow-500/10 border-yellow-500/20' },
restarting: { dot: 'bg-blue-400 animate-pulse', text: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
dead: { dot: 'bg-gray-500', text: 'text-gray-500', bg: 'bg-gray-500/10 border-gray-500/20' },
}
export default function StatusBadge({ status }) {
const s = STATUSES[status] ?? STATUSES.dead
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${s.bg} ${s.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${s.dot}`} />
{status}
</span>
)
}

View File

@@ -0,0 +1,95 @@
import { Server, Wifi, WifiOff, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import ContainerRow from './ContainerRow'
export default function VpsCard({ vps, onAction, onLogs, onDelete }) {
const [collapsed, setCollapsed] = useState(false)
const running = vps.containers.filter(c => c.status === 'running').length
const total = vps.containers.length
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<div className={`p-2 rounded-lg flex-shrink-0 ${vps.online ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
<Server size={15} className={vps.online ? 'text-emerald-400' : 'text-red-400'} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm truncate">{vps.name}</h3>
<p className="text-xs text-gray-500 truncate">{vps.host}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{vps.online ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
<Wifi size={12} />
<span className="hidden sm:inline">{running}/{total} actifs</span>
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<WifiOff size={12} />
<span className="hidden sm:inline">Hors ligne</span>
</span>
)}
<button
onClick={() => setCollapsed(c => !c)}
className="p-1.5 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition-colors"
title={collapsed ? 'Déplier' : 'Replier'}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
<button
onClick={() => onDelete(vps.id)}
className="p-1.5 rounded hover:bg-red-500/20 text-gray-500 hover:text-red-400 transition-colors"
title="Supprimer ce VPS"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Erreur de connexion */}
{!vps.online && vps.error && (
<div className="px-4 py-2.5 bg-red-950/30 border-b border-red-900/30 text-xs text-red-400 font-mono">
{vps.error}
</div>
)}
{/* Description */}
{vps.description && !collapsed && (
<p className="px-4 py-2 text-xs text-gray-500 border-b border-gray-800/60">{vps.description}</p>
)}
{/* Conteneurs */}
{!collapsed && vps.online && (
<div className="divide-y divide-gray-800/50 flex-1">
{total === 0 ? (
<p className="px-4 py-6 text-sm text-gray-600 text-center">Aucun conteneur détecté.</p>
) : (
vps.containers.map(c => (
<ContainerRow
key={c.id}
container={c}
onAction={(action) => onAction(vps.id, c.id, action)}
onLogs={() => onLogs(vps.id, c.id, `${vps.name} / ${c.name}`)}
/>
))
)}
</div>
)}
{/* Footer stats */}
{!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">
<span><span className="text-emerald-500">{running}</span> en cours</span>
<span><span className="text-gray-400">{total - running}</span> arrêtés</span>
<span><span className="text-gray-400">{total}</span> total</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scrollbar minimaliste */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
darkMode: 'class',
theme: { extend: {} },
plugins: [],
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})