fix: fixup multi problem
ci-api / test (push) Successful in 25m0s
ci-web / test (push) Failing after 7m59s

This commit is contained in:
Nuxgrid
2026-07-30 19:29:57 +02:00
parent 0848d3c0a0
commit ac8bdbcab7
14 changed files with 492 additions and 163 deletions
+225 -118
View File
@@ -1,26 +1,22 @@
import { useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Collapse,
Flex,
FormControl,
FormLabel,
Heading,
HStack,
Icon,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Progress,
SimpleGrid,
Spacer,
Spinner,
Stat,
StatLabel,
StatNumber,
Stack,
Table,
TableContainer,
Tbody,
@@ -32,12 +28,21 @@ import {
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api'
import { podStatusColor, podStatusLabel, 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
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
const DETAILS_POLL_MS = 5000
const COMPONENTS: { key: keyof DemoDetails['state']; label: string }[] = [
{ key: 'api', label: 'Backend' },
{ key: 'web', label: 'Frontend' },
{ key: 'db', label: 'PostgreSQL' },
{ key: 'dbm', label: 'Redis' },
]
export function Demos() {
const toast = useToast()
@@ -51,8 +56,8 @@ export function Demos() {
const [newUsername, setNewUsername] = useState('')
const [newLeadId, setNewLeadId] = useState('')
// --- Détails d'une démo (modale) ---
const [detailsFor, setDetailsFor] = 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)
@@ -125,8 +130,13 @@ export function Demos() {
}
}
const openDetails = async (d: Demo) => {
setDetailsFor(d)
const toggleRow = async (d: Demo) => {
if (expandedId === d.id) {
setExpandedId(null)
setDetails(null)
return
}
setExpandedId(d.id)
setDetails(null)
setDetailsLoading(true)
try {
@@ -134,17 +144,30 @@ export function Demos() {
setDetails(res)
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Détails indisponibles', description: msg })
setDetailsFor(null)
toast({ status: 'error', title: 'État des pods indisponible', description: msg })
setExpandedId(null)
} finally {
setDetailsLoading(false)
}
}
const closeDetails = () => {
setDetailsFor(null)
setDetails(null)
}
// 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 username = current.username
const id = setInterval(() => {
api
.getDemoDetails(username)
.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'
@@ -199,61 +222,96 @@ export function Demos() {
</Tr>
</Thead>
<Tbody>
{demos.map((d) => (
<Tr
key={d.id}
cursor="pointer"
_hover={{ bg: 'gray.50' }}
onClick={() => openDetails(d)}
>
<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
onClick={(e) => e.stopPropagation()}
>
{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={(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>
</HStack>
</Td>
</Tr>
))}
{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>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{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={(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>
</HStack>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={5}>
<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>
@@ -274,47 +332,96 @@ export function Demos() {
et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible.
</ConfirmDialog>
<Modal isOpen={!!detailsFor} onClose={closeDetails} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>
Détails de{' '}
<Text as="span" fontFamily="mono">
{detailsFor?.namespace}
</Text>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{detailsLoading ? (
<Flex justify="center" py={8}>
<Spinner />
</Flex>
) : details ? (
<SimpleGrid columns={2} spacing={4}>
<Stat>
<StatLabel>Backend</StatLabel>
<StatNumber fontSize="md">{details.state.api_state || '—'}</StatNumber>
</Stat>
<Stat>
<StatLabel>Frontend</StatLabel>
<StatNumber fontSize="md">{details.state.web_state || '—'}</StatNumber>
</Stat>
<Stat>
<StatLabel>PostgreSQL</StatLabel>
<StatNumber fontSize="md">{details.state.db_state || '—'}</StatNumber>
</Stat>
<Stat>
<StatLabel>Redis</StatLabel>
<StatNumber fontSize="md">{details.state.dbm_state || '—'}</StatNumber>
</Stat>
</SimpleGrid>
) : (
<Text color="gray.500">Aucune donnée.</Text>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
)
}
// PodStatusPanel : vue d'ensemble des 4 composants d'une démo (statut + CPU/mémoire live).
function PodStatusPanel({ state }: { state: DemoDetails['state'] }) {
const allRunning = COMPONENTS.every((c) => state[c.key].phase === 'Running')
const downCount = COMPONENTS.filter((c) => state[c.key].phase !== 'Running').length
return (
<Stack spacing={3}>
<HStack spacing={2}>
<Box
w="8px"
h="8px"
borderRadius="full"
bg={allRunning ? 'green.400' : 'red.400'}
flexShrink={0}
/>
<Text fontSize="sm" fontWeight="medium">
{allRunning
? 'Tous les services sont opérationnels'
: `${downCount} service${downCount > 1 ? 's' : ''} indisponible${downCount > 1 ? 's' : ''}`}
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, sm: 2, lg: 4 }} spacing={3}>
{COMPONENTS.map((c) => (
<ComponentCard key={c.key} title={c.label} cs={state[c.key]} />
))}
</SimpleGrid>
</Stack>
)
}
function ComponentCard({ title, cs }: { title: string; cs: ComponentState }) {
const cpuPct = cs.cpu_limit_milli > 0 ? Math.min(100, Math.round((cs.cpu_milli / cs.cpu_limit_milli) * 100)) : 0
const memPct = cs.memory_limit_mi > 0 ? Math.min(100, Math.round((cs.memory_mi / cs.memory_limit_mi) * 100)) : 0
return (
<Box p={3} borderWidth="1px" borderRadius="lg" bg="bg-surface">
<HStack justify="space-between" mb={3}>
<Text fontSize="sm" fontWeight="semibold">
{title}
</Text>
<HStack spacing={1.5}>
<Box w="7px" h="7px" borderRadius="full" bg={`${podStatusColor(cs.phase)}.400`} flexShrink={0} />
<Badge colorScheme={podStatusColor(cs.phase)} fontSize="10px">
{podStatusLabel(cs.phase)}
</Badge>
</HStack>
</HStack>
<Stack spacing={2}>
<Box>
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
<Text>CPU</Text>
<Text fontFamily="mono">
{cs.cpu_milli}m / {cs.cpu_limit_milli}m
</Text>
</Flex>
<Progress
value={cpuPct}
size="xs"
borderRadius="full"
colorScheme={cpuPct > 85 ? 'red' : cpuPct > 60 ? 'orange' : 'primary'}
/>
</Box>
<Box>
<Flex justify="space-between" fontSize="xs" color="gray.500" mb={1}>
<Text>Mémoire</Text>
<Text fontFamily="mono">
{cs.memory_mi}Mi / {cs.memory_limit_mi}Mi
</Text>
</Flex>
<Progress
value={memPct}
size="xs"
borderRadius="full"
colorScheme={memPct > 85 ? 'red' : memPct > 60 ? 'orange' : 'primary'}
/>
</Box>
</Stack>
</Box>
)
}
function ChevronIcon(props: React.ComponentProps<typeof Icon>) {
return (
<Icon viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 18l6-6-6-6" />
</Icon>
)
}