import { Fragment, useCallback, useEffect, useState } from 'react' import { Badge, Box, Button, Collapse, Flex, Heading, HStack, Icon, IconButton, Link, Spacer, Spinner, Stack, Table, TableContainer, Tbody, Td, Text, Th, Thead, Tr, useToast, } from '@chakra-ui/react' import { EditIcon } from '@chakra-ui/icons' import { useNavigate } from 'react-router-dom' import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api' import { statusColor, statusLabel, timeRemaining } from '../../lib/format' import { ConfirmDialog } from '../../components/ConfirmDialog' import { CreateDemoModal } from '../../components/CreateDemoModal' import { EditDomainModal } from '../../components/EditDomainModal' import { DemoCard } from '../../components/DemoCard' import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel' // Un provisioning en cours => on rafraîchit régulièrement. const POLL_MS = 5000 // Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié. const DETAILS_POLL_MS = 5000 export function Demos() { const toast = useToast() const navigate = useNavigate() const [demos, setDemos] = useState([]) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(null) const [toDelete, setToDelete] = useState(null) const [createModalOpen, setCreateModalOpen] = useState(false) const [editingDomain, setEditingDomain] = useState(null) // --- Ligne dépliée (état live des pods) --- const [expandedId, setExpandedId] = useState(null) const [details, setDetails] = useState(null) const [detailsLoading, setDetailsLoading] = useState(false) const load = useCallback(async () => { try { const res = await api.listDemos() // Les démos passées en abonnement payant vivent désormais dans le // dashboard Premium (n'expirent plus, stockage persistant) — ne plus // les mélanger avec les démos d'essai ici. setDemos((res.items ?? []).filter((d) => d.type_abonnement !== 'premium')) } catch (err) { if (err instanceof ApiError && err.status === 401) navigate('/admin/login') else toast({ status: 'error', title: 'Chargement des démos impossible' }) } finally { setLoading(false) } }, [navigate, toast]) useEffect(() => { void load() const id = setInterval(() => void load(), POLL_MS) return () => clearInterval(id) }, [load]) const extend = async (d: Demo) => { setBusy(d.id) try { await api.extendDemo(d.id) toast({ status: 'success', title: 'Démo prolongée de 30 jours' }) await load() } catch (err) { const msg = err instanceof ApiError ? err.message : 'Erreur' toast({ status: 'error', title: 'Prolongation impossible', description: msg }) } finally { setBusy(null) } } const confirmDestroy = async () => { if (!toDelete) return const d = toDelete setBusy(d.id) try { await api.deleteDemo(d.id) toast({ status: 'success', title: 'Démo détruite' }) setToDelete(null) await load() } catch (err) { const msg = err instanceof ApiError ? err.message : 'Erreur' toast({ status: 'error', title: 'Destruction impossible', description: msg }) } finally { setBusy(null) } } const toggleRow = async (d: Demo) => { if (expandedId === d.id) { setExpandedId(null) setDetails(null) return } setExpandedId(d.id) setDetails(null) setDetailsLoading(true) try { const res = await api.getDemoDetails(d.namespace) setDetails(res) } catch (err) { const msg = err instanceof ApiError ? err.message : 'Erreur' toast({ status: 'error', title: 'État des pods indisponible', description: msg }) setExpandedId(null) } finally { setDetailsLoading(false) } } // Tant qu'une ligne est dépliée, on rafraîchit l'état des pods en direct // (silencieux : pas de spinner, juste la mise à jour des badges/jauges). useEffect(() => { const current = demos.find((d) => d.id === expandedId) if (!current) return const namespace = current.namespace const id = setInterval(() => { api .getDemoDetails(namespace) .then(setDetails) .catch(() => { /* échec silencieux : on garde le dernier état connu affiché */ }) }, DETAILS_POLL_MS) return () => clearInterval(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, [expandedId]) const isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed' const renderActions = (d: Demo) => ( <> ) return ( <> Démos setCreateModalOpen(false)} onCreated={() => void load()} /> {loading ? ( ) : demos.length === 0 ? ( Aucune démo active. Lancez-en une avec le bouton ci-dessus. ) : ( <> {/* Desktop/tablette : tableau. */} {demos.map((d) => { const isOpen = expandedId === d.id return ( toggleRow(d)} > ) })}
Namespace Client Statut URL Expire dans
{d.namespace} {d.username ? ( {d.username} ) : ( )} {statusLabel(d.status)} {d.status === 'ready' ? ( e.stopPropagation()} > {d.url} ) : ( )} } size="xs" variant="ghost" onClick={(e) => { e.stopPropagation() setEditingDomain(d) }} /> {isAlive(d.status) ? timeRemaining(d.expires_at) : '—'} {renderActions(d)}
{detailsLoading && !details ? ( ) : details ? ( ) : ( Aucune donnée. )}
{/* Mobile : cartes (pas de scroll horizontal). */} {demos.map((d) => ( toggleRow(d)} detailsLoading={detailsLoading} details={expandedId === d.id ? details : null} onEditDomain={() => setEditingDomain(d)} expiresLabel={isAlive(d.status) ? timeRemaining(d.expires_at) : '—'} actions={renderActions(d)} /> ))} )} setToDelete(null)} > La démo{' '} {toDelete?.namespace} {' '} et toutes ses données seront supprimées définitivement. Les ressources du pool seront libérées. Cette action est irréversible. setEditingDomain(null)} onSaved={() => void load()} /> ) }