feat: découverte dynamique apps - portail dynamique - fix URLs

This commit is contained in:
Manus Deploy
2026-07-24 13:04:21 +00:00
parent 120567a6e5
commit 7a7b9fde47
43 changed files with 10213 additions and 14 deletions

View File

@@ -0,0 +1,679 @@
import React, { useState, useEffect } from 'react';
import {
X,
Upload,
GitBranch,
Server,
Database,
Globe,
Package,
FileText,
CheckCircle,
AlertCircle,
Loader2,
ChevronDown,
} from 'lucide-react';
import { getAvailableStacks, createApp, getGiteaRepos } from '../utils/api';
const STEPS = [
{ id: 1, title: 'Informations', icon: FileText },
{ id: 2, title: 'Source', icon: GitBranch },
{ id: 3, title: 'Stack & Options', icon: Server },
{ id: 4, title: 'Confirmation', icon: CheckCircle },
];
export default function AddAppModal({ isOpen, onClose, onSuccess }) {
const [currentStep, setCurrentStep] = useState(1);
const [stacks, setStacks] = useState([]);
const [giteaRepos, setGiteaRepos] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitResult, setSubmitResult] = useState(null);
const [errors, setErrors] = useState({});
const [formData, setFormData] = useState({
name: '',
description: '',
subdomain: '',
sourceType: 'zip',
zipFile: null,
giteaRepoUrl: '',
stack: 'nodejs',
port: '',
needsDb: false,
dbType: 'mysql',
});
useEffect(() => {
if (isOpen) {
getAvailableStacks()
.then((res) => setStacks(res.data))
.catch(console.error);
getGiteaRepos()
.then((res) => setGiteaRepos(res.data))
.catch(console.error);
// Reset
setCurrentStep(1);
setSubmitResult(null);
setErrors({});
setFormData({
name: '',
description: '',
subdomain: '',
sourceType: 'zip',
zipFile: null,
giteaRepoUrl: '',
stack: 'nodejs',
port: '',
needsDb: false,
dbType: 'mysql',
});
}
}, [isOpen]);
if (!isOpen) return null;
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: null }));
};
const autoSubdomain = (name) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
const handleNameChange = (value) => {
updateField('name', value);
if (!formData.subdomain || formData.subdomain === autoSubdomain(formData.name)) {
updateField('subdomain', autoSubdomain(value));
}
};
const validateStep = (step) => {
const newErrors = {};
if (step === 1) {
if (!formData.name.trim()) newErrors.name = 'Le nom est requis';
if (!formData.subdomain.trim()) newErrors.subdomain = 'Le sous-domaine est requis';
else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(formData.subdomain)) {
newErrors.subdomain = 'Lettres minuscules, chiffres et tirets uniquement';
}
}
if (step === 2) {
if (formData.sourceType === 'zip' && !formData.zipFile) {
newErrors.zipFile = 'Un fichier ZIP est requis';
}
if (formData.sourceType === 'gitea' && !formData.giteaRepoUrl) {
newErrors.giteaRepoUrl = 'Sélectionnez un dépôt Gitea';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const nextStep = () => {
if (validateStep(currentStep)) {
setCurrentStep((prev) => Math.min(prev + 1, 4));
}
};
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitResult(null);
try {
const fd = new FormData();
fd.append('name', formData.name);
fd.append('description', formData.description);
fd.append('subdomain', formData.subdomain);
fd.append('stack', formData.stack);
fd.append('sourceType', formData.sourceType);
fd.append('needsDb', formData.needsDb.toString());
fd.append('dbType', formData.dbType);
if (formData.port) fd.append('port', formData.port);
if (formData.sourceType === 'zip' && formData.zipFile) {
fd.append('zipFile', formData.zipFile);
}
if (formData.sourceType === 'gitea') {
fd.append('giteaRepoUrl', formData.giteaRepoUrl);
}
const res = await createApp(fd);
setSubmitResult({ success: true, data: res.data });
// Rafraîchir après un délai
setTimeout(() => {
if (onSuccess) onSuccess();
}, 3000);
} catch (err) {
setSubmitResult({
success: false,
error: err.response?.data?.error || err.message,
});
} finally {
setIsSubmitting(false);
}
};
const selectedStack = stacks.find((s) => s.id === formData.stack);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-dark-800 border border-dark-600 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-600">
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Package className="w-5 h-5 text-primary-400" />
Ajouter une application
</h2>
<p className="text-sm text-gray-400 mt-0.5">
Déployez une nouvelle application sur le serveur de recette
</p>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Steps indicator */}
<div className="px-6 py-3 border-b border-dark-600 bg-dark-900/50">
<div className="flex items-center justify-between">
{STEPS.map((step, idx) => (
<div key={step.id} className="flex items-center">
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
currentStep === step.id
? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
: currentStep > step.id
? 'bg-green-500/20 text-green-400'
: 'text-gray-500'
}`}
>
<step.icon className="w-4 h-4" />
<span className="hidden sm:inline">{step.title}</span>
</div>
{idx < STEPS.length - 1 && (
<div
className={`w-8 h-0.5 mx-1 ${
currentStep > step.id ? 'bg-green-500' : 'bg-dark-600'
}`}
/>
)}
</div>
))}
</div>
</div>
{/* Content */}
<div className="px-6 py-5 overflow-y-auto max-h-[55vh]">
{/* Step 1: Informations */}
{currentStep === 1 && (
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Nom de l'application <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Mon Application"
className={`w-full px-4 py-2.5 bg-dark-700 border rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
errors.name ? 'border-red-500' : 'border-dark-500'
}`}
/>
{errors.name && (
<p className="text-red-400 text-xs mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Description de l'application..."
rows={3}
className="w-full px-4 py-2.5 bg-dark-700 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Sous-domaine <span className="text-red-400">*</span>
</label>
<div className="flex items-center gap-0">
<input
type="text"
value={formData.subdomain}
onChange={(e) => updateField('subdomain', e.target.value.toLowerCase())}
placeholder="mon-app"
className={`flex-1 px-4 py-2.5 bg-dark-700 border rounded-l-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
errors.subdomain ? 'border-red-500' : 'border-dark-500'
}`}
/>
<span className="px-3 py-2.5 bg-dark-600 border border-dark-500 border-l-0 rounded-r-lg text-gray-400 text-sm whitespace-nowrap">
.recette.santinova-soft.org
</span>
</div>
{errors.subdomain && (
<p className="text-red-400 text-xs mt-1">{errors.subdomain}</p>
)}
{formData.subdomain && !errors.subdomain && (
<p className="text-gray-500 text-xs mt-1 flex items-center gap-1">
<Globe className="w-3 h-3" />
URL: https://{formData.subdomain}.recette.santinova-soft.org
</p>
)}
</div>
</div>
)}
{/* Step 2: Source */}
{currentStep === 2 && (
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Source du code
</label>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => updateField('sourceType', 'zip')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
formData.sourceType === 'zip'
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<Upload className={`w-6 h-6 mb-2 ${formData.sourceType === 'zip' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`font-medium ${formData.sourceType === 'zip' ? 'text-white' : 'text-gray-300'}`}>
Upload ZIP
</div>
<div className="text-xs text-gray-500 mt-1">
Uploadez une archive ZIP contenant les sources
</div>
</button>
<button
onClick={() => updateField('sourceType', 'gitea')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
formData.sourceType === 'gitea'
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<GitBranch className={`w-6 h-6 mb-2 ${formData.sourceType === 'gitea' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`font-medium ${formData.sourceType === 'gitea' ? 'text-white' : 'text-gray-300'}`}>
Dépôt Gitea
</div>
<div className="text-xs text-gray-500 mt-1">
Utilisez un dépôt Gitea existant
</div>
</button>
</div>
</div>
{formData.sourceType === 'zip' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Fichier ZIP <span className="text-red-400">*</span>
</label>
<div
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
formData.zipFile
? 'border-green-500/50 bg-green-500/5'
: errors.zipFile
? 'border-red-500/50 bg-red-500/5'
: 'border-dark-500 hover:border-dark-400 bg-dark-700'
}`}
>
<input
type="file"
accept=".zip"
onChange={(e) => updateField('zipFile', e.target.files[0])}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
{formData.zipFile ? (
<div>
<CheckCircle className="w-8 h-8 text-green-400 mx-auto mb-2" />
<p className="text-green-400 font-medium">{formData.zipFile.name}</p>
<p className="text-gray-500 text-sm mt-1">
{(formData.zipFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
) : (
<div>
<Upload className="w-8 h-8 text-gray-500 mx-auto mb-2" />
<p className="text-gray-400">Cliquez ou glissez un fichier ZIP ici</p>
<p className="text-gray-600 text-sm mt-1">Maximum 500 MB</p>
</div>
)}
</div>
{errors.zipFile && (
<p className="text-red-400 text-xs mt-1">{errors.zipFile}</p>
)}
</div>
)}
{formData.sourceType === 'gitea' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Dépôt Gitea <span className="text-red-400">*</span>
</label>
<div className="space-y-2">
{giteaRepos.length === 0 ? (
<div className="p-4 bg-dark-700 rounded-lg text-center text-gray-500">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
Chargement des dépôts...
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{giteaRepos.map((repo) => (
<button
key={repo.id}
onClick={() => updateField('giteaRepoUrl', repo.cloneUrl)}
className={`w-full p-3 rounded-lg border text-left transition-all ${
formData.giteaRepoUrl === repo.cloneUrl
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-white text-sm">{repo.name}</div>
{repo.description && (
<div className="text-xs text-gray-500 mt-0.5">{repo.description}</div>
)}
</div>
{repo.language && (
<span className="text-xs px-2 py-0.5 bg-dark-600 rounded text-gray-400">
{repo.language}
</span>
)}
</div>
</button>
))}
</div>
)}
</div>
{errors.giteaRepoUrl && (
<p className="text-red-400 text-xs mt-1">{errors.giteaRepoUrl}</p>
)}
</div>
)}
</div>
)}
{/* Step 3: Stack & Options */}
{currentStep === 3 && (
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Type de stack <span className="text-red-400">*</span>
</label>
<div className="grid grid-cols-3 gap-2">
{stacks.map((s) => (
<button
key={s.id}
onClick={() => updateField('stack', s.id)}
className={`p-3 rounded-lg border text-center transition-all ${
formData.stack === s.id
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<div className={`text-sm font-medium ${formData.stack === s.id ? 'text-white' : 'text-gray-300'}`}>
{s.name}
</div>
</button>
))}
</div>
{selectedStack && (
<p className="text-gray-500 text-xs mt-2">{selectedStack.description}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Port de l'application (optionnel)
</label>
<input
type="number"
value={formData.port}
onChange={(e) => updateField('port', e.target.value)}
placeholder="Auto-détecté selon la stack"
className="w-full px-4 py-2.5 bg-dark-700 border border-dark-500 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div className="border border-dark-500 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className={`w-5 h-5 ${formData.needsDb ? 'text-primary-400' : 'text-gray-500'}`} />
<div>
<div className="text-sm font-medium text-white">Base de données</div>
<div className="text-xs text-gray-500">Ajouter un conteneur de base de données</div>
</div>
</div>
<button
onClick={() => updateField('needsDb', !formData.needsDb)}
className={`relative w-12 h-6 rounded-full transition-colors ${
formData.needsDb ? 'bg-primary-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
formData.needsDb ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{formData.needsDb && (
<div className="mt-4 pt-4 border-t border-dark-500">
<label className="block text-sm font-medium text-gray-300 mb-2">
Type de base de données
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => updateField('dbType', 'mysql')}
className={`p-3 rounded-lg border text-center transition-all ${
formData.dbType === 'mysql'
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<div className={`text-sm font-medium ${formData.dbType === 'mysql' ? 'text-white' : 'text-gray-300'}`}>
MySQL 8.0
</div>
</button>
<button
onClick={() => updateField('dbType', 'postgres')}
className={`p-3 rounded-lg border text-center transition-all ${
formData.dbType === 'postgres'
? 'border-primary-500 bg-primary-500/10'
: 'border-dark-500 bg-dark-700 hover:border-dark-400'
}`}
>
<div className={`text-sm font-medium ${formData.dbType === 'postgres' ? 'text-white' : 'text-gray-300'}`}>
PostgreSQL 16
</div>
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Step 4: Confirmation */}
{currentStep === 4 && (
<div className="space-y-5">
{submitResult ? (
<div
className={`p-6 rounded-xl border ${
submitResult.success
? 'border-green-500/30 bg-green-500/10'
: 'border-red-500/30 bg-red-500/10'
}`}
>
{submitResult.success ? (
<div className="text-center">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
<h3 className="text-lg font-bold text-green-400">
Création lancée avec succès !
</h3>
<p className="text-gray-400 mt-2 text-sm">
L'application est en cours de déploiement. Suivez la progression dans l'onglet Déploiements.
</p>
<p className="text-gray-500 mt-2 text-xs">
URL: https://{formData.subdomain}.recette.santinova-soft.org
</p>
</div>
) : (
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<h3 className="text-lg font-bold text-red-400">Erreur</h3>
<p className="text-gray-400 mt-2 text-sm">{submitResult.error}</p>
</div>
)}
</div>
) : (
<>
<h3 className="text-lg font-semibold text-white">Récapitulatif</h3>
<div className="bg-dark-700 rounded-xl divide-y divide-dark-600">
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Nom</span>
<span className="text-white font-medium">{formData.name}</span>
</div>
{formData.description && (
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Description</span>
<span className="text-white text-sm text-right max-w-xs truncate">{formData.description}</span>
</div>
)}
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">URL</span>
<span className="text-primary-400 text-sm">
https://{formData.subdomain}.recette.santinova-soft.org
</span>
</div>
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Source</span>
<span className="text-white text-sm">
{formData.sourceType === 'zip'
? `ZIP: ${formData.zipFile?.name || '-'}`
: `Gitea: ${formData.giteaRepoUrl.split('/').pop()}`}
</span>
</div>
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Stack</span>
<span className="text-white text-sm">
{selectedStack?.name || formData.stack}
</span>
</div>
{formData.port && (
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Port</span>
<span className="text-white text-sm">{formData.port}</span>
</div>
)}
<div className="flex justify-between items-center px-4 py-3">
<span className="text-gray-400 text-sm">Base de données</span>
<span className="text-white text-sm">
{formData.needsDb
? formData.dbType === 'mysql'
? 'MySQL 8.0'
: 'PostgreSQL 16'
: 'Aucune'}
</span>
</div>
</div>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-yellow-400 text-sm font-medium">
Le déploiement peut prendre plusieurs minutes
</p>
<p className="text-gray-400 text-xs mt-1">
Le build Docker et le démarrage des conteneurs seront exécutés en arrière-plan.
Vous pourrez suivre la progression dans l'onglet Déploiements.
</p>
</div>
</div>
</div>
</>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-dark-600 bg-dark-900/50">
<div>
{currentStep > 1 && !submitResult && (
<button
onClick={prevStep}
className="px-4 py-2 text-gray-400 hover:text-white transition-colors text-sm"
>
Retour
</button>
)}
</div>
<div className="flex items-center gap-3">
{submitResult ? (
<button
onClick={onClose}
className="btn-primary px-6"
>
Fermer
</button>
) : currentStep < 4 ? (
<button onClick={nextStep} className="btn-primary px-6">
Suivant
</button>
) : (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="btn-primary px-6 flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Création en cours...
</>
) : (
<>
<Package className="w-4 h-4" />
Créer et déployer
</>
)}
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
import React, { useState } from 'react';
import {
ExternalLink,
RefreshCw,
Rocket,
Clock,
GitBranch,
Activity,
ChevronDown,
ChevronUp,
Terminal,
AlertTriangle,
Play,
Square,
RotateCcw,
} from 'lucide-react';
import StatusBadge from './StatusBadge';
import { deployApp, checkApp, getAppLogs, startApp, stopApp, restartApp } from '../utils/api';
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
return date.toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
}
function formatResponseTime(ms) {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export default function AppCard({ app, commits, onRefresh }) {
const [deploying, setDeploying] = useState(false);
const [checking, setChecking] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [containerLogs, setContainerLogs] = useState('');
const [loadingLogs, setLoadingLogs] = useState(false);
const [deployMessage, setDeployMessage] = useState(null);
const [loadingControl, setLoadingControl] = useState(null);
const handleContainerControl = async (action) => {
setLoadingControl(action);
try {
if (action === 'start') await startApp(app.id);
else if (action === 'stop') await stopApp(app.id);
else if (action === 'restart') await restartApp(app.id);
setTimeout(() => { if (onRefresh) onRefresh(); }, 2000);
} catch (err) {
console.error('Erreur controle conteneur:', err);
} finally {
setLoadingControl(null);
}
};
const handleDeploy = async () => {
if (!confirm(`Voulez-vous redéployer ${app.name} ?`)) return;
setDeploying(true);
setDeployMessage(null);
try {
const res = await deployApp(app.id);
setDeployMessage({
type: 'info',
text: res.data.message || 'Redéploiement lancé...',
});
// Rafraîchir après un délai
setTimeout(() => {
if (onRefresh) onRefresh();
setDeploying(false);
}, 10000);
} catch (err) {
setDeployMessage({
type: 'error',
text: err.response?.data?.error || 'Erreur lors du redéploiement',
});
setDeploying(false);
}
};
const handleCheck = async () => {
setChecking(true);
try {
await checkApp(app.id);
if (onRefresh) onRefresh();
} catch (err) {
console.error('Erreur health check:', err);
} finally {
setChecking(false);
}
};
const handleShowLogs = async () => {
if (showLogs) {
setShowLogs(false);
return;
}
setLoadingLogs(true);
setShowLogs(true);
try {
const res = await getAppLogs(app.id, 50);
setContainerLogs(res.data.logs || 'Aucun log disponible');
} catch (err) {
setContainerLogs('Erreur lors de la récupération des logs');
} finally {
setLoadingLogs(false);
}
};
const latestCommit = commits && commits.length > 0 ? commits[0] : null;
// Apps d'infrastructure : portail et dashboard
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return (
<div className={`card overflow-hidden${isInfra ? ' border border-amber-500/30 bg-amber-950/10' : ''}`}>
{/* Badge infra */}
{isInfra && (
<div className="px-6 pt-3 pb-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
⚙ Infrastructure
</span>
</div>
)}
{/* Header */}
<div className="p-6 border-b border-dark-700">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-white">{app.name}</h3>
<StatusBadge status={app.status} />
</div>
<p className="text-gray-400 text-sm mt-1">
{app.description || app.id}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCheck}
disabled={checking}
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1.5"
title="Vérifier l'état"
>
<RefreshCw
className={`w-3.5 h-3.5 ${checking ? 'animate-spin' : ''}`}
/>
Vérifier
</button>
<button
onClick={handleDeploy}
disabled={deploying}
className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5"
title="Redéployer l'application"
>
<Rocket
className={`w-3.5 h-3.5 ${deploying ? 'animate-bounce' : ''}`}
/>
{deploying ? 'Déploiement...' : 'Redéployer'}
</button>
{/* Boutons contrôle conteneur */}
<div className="flex items-center gap-1 ml-1 border-l border-dark-600 pl-2">
<button
onClick={() => handleContainerControl('start')}
disabled={loadingControl !== null || app.container?.state === 'running'}
title="Démarrer le conteneur"
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 border border-emerald-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{loadingControl === 'start' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => handleContainerControl('stop')}
disabled={loadingControl !== null || app.container?.state !== 'running'}
title="Arrêter le conteneur"
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 border border-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{loadingControl === 'stop' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Square className="w-3.5 h-3.5" />}
</button>
<button
onClick={() => handleContainerControl('restart')}
disabled={loadingControl !== null}
title="Redémarrer le conteneur"
className="p-1.5 rounded-lg text-amber-400 hover:bg-amber-500/10 border border-amber-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
>
{loadingControl === 'restart' ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <RotateCcw className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
</div>
{/* Deploy message */}
{deployMessage && (
<div
className={`px-6 py-3 text-sm flex items-center gap-2 ${
deployMessage.type === 'error'
? 'bg-red-500/10 text-red-400'
: 'bg-primary-500/10 text-primary-400'
}`}
>
{deployMessage.type === 'error' ? (
<AlertTriangle className="w-4 h-4" />
) : (
<Rocket className="w-4 h-4" />
)}
{deployMessage.text}
</div>
)}
{/* Info Grid */}
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* URLs */}
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
URLs
</span>
<div className="mt-1.5 space-y-1">
{app.urls?.recette && (
<a
href={app.urls.recette}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
Recette
</a>
)}
{app.urls?.prod ? (
<a
href={app.urls.prod}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
Production
</a>
) : (
<span className="text-sm text-gray-600">Pas de prod</span>
)}
</div>
</div>
{/* Container */}
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Conteneur
</span>
<div className="mt-1.5">
{app.container ? (
<div className="space-y-1">
<p className="text-sm text-gray-300">
<span className="text-gray-500">ID:</span>{' '}
<code className="text-xs bg-dark-700 px-1.5 py-0.5 rounded">
{app.container.id}
</code>
</p>
<p className="text-sm text-gray-300">
<span className="text-gray-500">État:</span>{' '}
{app.container.state}
</p>
</div>
) : (
<p className="text-sm text-gray-600">Non trouvé</p>
)}
</div>
</div>
{/* Health */}
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Santé
</span>
<div className="mt-1.5 space-y-1">
{app.health?.recette && (
<div className="flex items-center gap-2">
<Activity
className={`w-3.5 h-3.5 ${
app.health.recette.online
? 'text-emerald-400'
: 'text-red-400'
}`}
/>
<span className="text-sm text-gray-300">
{app.health.recette.online ? (
<>
HTTP {app.health.recette.statusCode} &mdash;{' '}
{formatResponseTime(app.health.recette.responseTime)}
</>
) : (
<span className="text-red-400">
{app.health.recette.error || 'Inaccessible'}
</span>
)}
</span>
</div>
)}
<p className="text-xs text-gray-600">
<Clock className="w-3 h-3 inline mr-1" />
{formatDate(app.lastCheck)}
</p>
</div>
</div>
</div>
{/* Latest Commit */}
{latestCommit && (
<div className="px-6 py-3 border-t border-dark-700 bg-dark-800/50">
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-gray-500" />
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Dernier commit
</span>
</div>
<div className="mt-1.5 flex items-center gap-3">
<code className="text-xs bg-primary-500/10 text-primary-400 px-2 py-0.5 rounded font-mono">
{latestCommit.shortSha}
</code>
<span className="text-sm text-gray-300 truncate flex-1">
{latestCommit.message}
</span>
<span className="text-xs text-gray-500 flex-shrink-0">
{latestCommit.author} &mdash; {formatDate(latestCommit.date)}
</span>
</div>
</div>
)}
{/* Logs Toggle */}
<div className="border-t border-dark-700">
<button
onClick={handleShowLogs}
className="w-full px-6 py-3 flex items-center justify-between text-sm text-gray-400 hover:text-gray-300 hover:bg-dark-700/50 transition-colors"
>
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Logs du conteneur
</span>
{showLogs ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{showLogs && (
<div className="px-6 pb-4">
<div className="bg-dark-950 rounded-lg p-4 max-h-64 overflow-auto">
{loadingLogs ? (
<div className="flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
Chargement des logs...
</div>
) : (
<pre className="log-viewer text-xs text-gray-400">
{containerLogs}
</pre>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { LayoutList, TerminalSquare,
Server,
LayoutDashboard,
Box,
GitBranch,
ScrollText,
Container,
LogOut,
Wifi,
WifiOff,
Activity,
BookOpen,
} from 'lucide-react';
const navItems = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'apps', label: 'Applications', icon: Box },
{ id: 'deployments', label: 'Déploiements', icon: ScrollText },
{ id: 'gitea', label: 'Gitea', icon: GitBranch },
{ id: 'docker', label: 'Docker', icon: Container },
{ id: 'monitoring', label: 'Monitoring', icon: Activity },
{ id: 'documentation', label: 'Documentation Infra', icon: BookOpen },
{ id: 'inventaire', label: 'Inventaire Apps', icon: LayoutList },
{ id: 'terminal', label: 'Terminal SSH', icon: TerminalSquare },
];
export default function Sidebar({
currentPage,
onNavigate,
onLogout,
wsConnected,
}) {
return (
<aside className="w-64 bg-dark-900 border-r border-dark-700 flex flex-col h-screen fixed left-0 top-0">
{/* Logo */}
<div className="p-6 border-b border-dark-700">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-600 flex items-center justify-center">
<Server className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white">Dashboard</h1>
<p className="text-xs text-gray-500">Recette</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isDoc = item.id === 'documentation';
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? isDoc
? 'bg-purple-600/10 text-purple-400 border border-purple-500/20'
: 'bg-primary-600/10 text-primary-400 border border-primary-500/20'
: isDoc
? 'text-purple-400/70 hover:text-purple-300 hover:bg-purple-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-dark-800'
}`}
>
<Icon className="w-4.5 h-4.5" />
{item.label}
</button>
);
})}
</nav>
{/* Footer */}
<div className="p-4 border-t border-dark-700 space-y-3">
{/* WebSocket status */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-dark-800">
{wsConnected ? (
<>
<Wifi className="w-4 h-4 text-emerald-400" />
<span className="text-xs text-emerald-400">Temps réel actif</span>
</>
) : (
<>
<WifiOff className="w-4 h-4 text-gray-500" />
<span className="text-xs text-gray-500">Hors ligne</span>
</>
)}
</div>
<button
onClick={onLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<LogOut className="w-4.5 h-4.5" />
Déconnexion
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
const statusConfig = {
online: {
label: 'En ligne',
className: 'status-online',
dotColor: 'bg-emerald-400',
},
offline: {
label: 'Hors ligne',
className: 'status-offline',
dotColor: 'bg-gray-400',
},
error: {
label: 'Erreur',
className: 'status-error',
dotColor: 'bg-red-400',
},
unknown: {
label: 'Inconnu',
className: 'status-warning',
dotColor: 'bg-amber-400',
},
running: {
label: 'En cours',
className: 'status-warning',
dotColor: 'bg-amber-400',
},
success: {
label: 'Succès',
className: 'status-online',
dotColor: 'bg-emerald-400',
},
failed: {
label: 'Échoué',
className: 'status-error',
dotColor: 'bg-red-400',
},
};
export default function StatusBadge({ status, size = 'sm' }) {
const config = statusConfig[status] || statusConfig.unknown;
return (
<span className={config.className}>
<span
className={`w-2 h-2 rounded-full ${config.dotColor} mr-1.5 ${
status === 'online' || status === 'running' ? 'animate-pulse-dot' : ''
}`}
/>
{config.label}
</span>
);
}