fix: fixup multi problem
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
# --- Build (Vite) ---
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY ../../web/package.json ../../web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# Même origine : l'API est proxifiée par nginx (cf. nginx.conf), donc URL relative.
|
||||
|
||||
@@ -103,15 +103,6 @@ export function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="profile"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<Profile />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="subscription" element={<Subscription />} />
|
||||
<Route path="profile" element={<Profile />} />
|
||||
</Route>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function BackofficeLayout() {
|
||||
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
|
||||
{(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>}
|
||||
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
|
||||
{isAdmin || isClient && <NavItem to="/app/profile">Profile</NavItem>}
|
||||
{(isAdmin || isClient) && <NavItem to="/app/profile">Profile</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
||||
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
|
||||
</HStack>
|
||||
@@ -105,7 +105,7 @@ export function BackofficeLayout() {
|
||||
Codes
|
||||
</NavItem>
|
||||
)}
|
||||
{isAdmin || isClient && (
|
||||
{(isAdmin || isClient) && (
|
||||
<NavItem to="/app/profile" onClick={onClose} mobile>
|
||||
Profile
|
||||
</NavItem>
|
||||
|
||||
+12
-4
@@ -87,11 +87,19 @@ export interface Contact {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ComponentState {
|
||||
phase: string
|
||||
cpu_milli: number
|
||||
cpu_limit_milli: number
|
||||
memory_mi: number
|
||||
memory_limit_mi: number
|
||||
}
|
||||
|
||||
export interface DemoState {
|
||||
api_state: string
|
||||
web_state: string
|
||||
db_state: string
|
||||
dbm_state: string
|
||||
api: ComponentState
|
||||
web: ComponentState
|
||||
db: ComponentState
|
||||
dbm: ComponentState
|
||||
}
|
||||
|
||||
export interface DemoDetails {
|
||||
|
||||
@@ -31,6 +31,36 @@ export function statusLabel(s: DemoStatus): string {
|
||||
return map[s] ?? s
|
||||
}
|
||||
|
||||
// Couleur de badge Chakra par phase de pod Kubernetes (api_state, web_state, db_state, dbm_state).
|
||||
export function podStatusColor(s: string): string {
|
||||
switch (s) {
|
||||
case 'Running':
|
||||
return 'green'
|
||||
case 'Pending':
|
||||
return 'yellow'
|
||||
case 'Succeeded':
|
||||
return 'blue'
|
||||
case 'Failed':
|
||||
return 'red'
|
||||
case 'Unknown':
|
||||
case '':
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
// Libellé FR de la phase d'un pod.
|
||||
export function podStatusLabel(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
Running: 'En ligne',
|
||||
Pending: 'En attente',
|
||||
Succeeded: 'Terminé',
|
||||
Failed: 'Down',
|
||||
Unknown: 'Inconnu',
|
||||
}
|
||||
return map[s] ?? 'Introuvable'
|
||||
}
|
||||
|
||||
// Temps restant avant expiration, formaté (ex. "29 j 4 h").
|
||||
export function timeRemaining(expiresAt: string, now: number = Date.now()): string {
|
||||
const ms = new Date(expiresAt).getTime() - now
|
||||
|
||||
+225
-118
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user