347 lines
12 KiB
TypeScript
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()}
|
|
/>
|
|
</>
|
|
)
|
|
}
|