feat: add web
ci-web / test (push) Failing after 26m34s

This commit is contained in:
Nuxgrid
2026-07-27 19:13:56 +02:00
parent bb6c425840
commit 07db8a2ae2
39 changed files with 7534 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Button,
Flex,
Heading,
HStack,
Link,
Spacer,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
// Un provisioning en cours => on rafraîchit régulièrement.
const 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 load = useCallback(async () => {
try {
const res = await api.listDemos()
setDemos(res.items ?? [])
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/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 create = async () => {
setBusy('new')
try {
await api.createDemo()
toast({ status: 'success', title: 'Démo lancée' })
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Lancement impossible', description: msg })
} finally {
setBusy(null)
}
}
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 isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed'
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Démos</Heading>
<Spacer />
<Button colorScheme="primary" isLoading={busy === 'new'} onClick={create}>
Nouvelle démo
</Button>
</Flex>
{loading ? (
<Spinner />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo active. Lancez-en une depuis un lead ou ci-dessus.</Text>
) : (
<TableContainer borderWidth="1px" borderRadius="lg">
<Table>
<Thead>
<Tr>
<Th>Namespace</Th>
<Th>Statut</Th>
<Th>URL</Th>
<Th>Expire dans</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{demos.map((d) => (
<Tr key={d.id}>
<Td fontFamily="mono">{d.namespace}</Td>
<Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
{d.status === 'ready' ? (
<Link href={d.url} color="primary.500" isExternal>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
</Td>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
<HStack justify="flex-end">
<Button
size="sm"
variant="outline"
isDisabled={!isAlive(d.status) || busy === d.id}
onClick={() => extend(d)}
>
+30 j
</Button>
<Button
size="sm"
colorScheme="red"
variant="outline"
isDisabled={!isAlive(d.status)}
onClick={() => setToDelete(d)}
>
Détruire
</Button>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
<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>
</>
)
}