Files
omnex/web/src/pages/backoffice/Demos.tsx
T
Xor290 e2802aeb59
ci-web / test (push) Successful in 14m40s
chore: update
2026-08-03 11:23:42 +02:00

347 lines
12 KiB
TypeScript

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<Demo[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toDelete, setToDelete] = useState<Demo | null>(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingDomain, setEditingDomain] = useState<Demo | null>(null)
// --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null)
const [details, setDetails] = useState<DemoDetails | null>(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) => (
<>
<Button
size="sm"
variant="outline"
isDisabled={!isAlive(d.status) || busy === d.id}
onClick={(e) => {
e.stopPropagation()
extend(d)
}}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={(e) => {
e.stopPropagation()
setToDelete(d)
}}
>
Détruire
</Button>
</>
)
return (
<>
<Flex mb={6} align="center" gap={4} wrap="wrap">
<Heading size="md" mr={4}>
Démos
</Heading>
<Spacer />
<Button colorScheme="primary" onClick={() => setCreateModalOpen(true)}>
Nouvelle démo
</Button>
</Flex>
<CreateDemoModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onCreated={() => void load()}
/>
{loading ? (
<Spinner />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo active. Lancez-en une avec le bouton ci-dessus.</Text>
) : (
<>
{/* Desktop/tablette : tableau. */}
<TableContainer borderWidth="1px" borderRadius="lg" display={{ base: 'none', md: 'block' }}>
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Client</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr
cursor="pointer"
bg={isOpen ? 'chakra-subtle-bg' : undefined}
_hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
>
<Td fontFamily="mono">
<HStack spacing={2}>
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
<Text>{d.namespace}</Text>
</HStack>
</Td>
<Td>
{d.username ? (
<Text>{d.username}</Text>
) : (
<Text color="gray.400"></Text>
)}
</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
<HStack justify="flex-end">{renderActions(d)}</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={6}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>
</Table>
</TableContainer>
{/* Mobile : cartes (pas de scroll horizontal). */}
<Stack spacing={3} display={{ base: 'flex', md: 'none' }}>
{demos.map((d) => (
<DemoCard
key={d.id}
demo={d}
isOpen={expandedId === d.id}
onToggle={() => toggleRow(d)}
detailsLoading={detailsLoading}
details={expandedId === d.id ? details : null}
onEditDomain={() => setEditingDomain(d)}
expiresLabel={isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}
actions={renderActions(d)}
/>
))}
</Stack>
</>
)}
<ConfirmDialog
isOpen={!!toDelete}
title="Détruire la démo ?"
confirmLabel="Détruire"
isLoading={!!toDelete && busy === toDelete.id}
onConfirm={confirmDestroy}
onClose={() => setToDelete(null)}
>
La démo{' '}
<Text as="span" fontFamily="mono" fontWeight="semibold">
{toDelete?.namespace}
</Text>{' '}
et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible.
</ConfirmDialog>
<EditDomainModal
demo={editingDomain}
onClose={() => setEditingDomain(null)}
onSaved={() => void load()}
/>
</>
)
}