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
+9 -1
View File
@@ -104,7 +104,15 @@ func main() {
log.Fatalf("k8s client: %v", err) log.Fatalf("k8s client: %v", err)
} }
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, "/charts", "helm") // Client metrics.k8s.io (usage CPU/mémoire live) : optionnel, ne bloque
// pas le démarrage si metrics-server n'est pas déployé sur le cluster.
metricsClient, err := k8s.NewMetricsClient(&cfg)
if err != nil {
log.Printf("metrics client indisponible (usage CPU/mémoire désactivé): %v", err)
metricsClient = nil
}
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, metricsClient, "/charts", "helm")
if err != nil { if err != nil {
log.Fatalf("helm provisioner: %v", err) log.Fatalf("helm provisioner: %v", err)
} }
+1
View File
@@ -72,6 +72,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/metrics v0.36.3 // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
+2
View File
@@ -181,6 +181,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
@@ -17,6 +17,7 @@ import (
k8sErrors "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
) )
// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm. // HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
@@ -24,6 +25,7 @@ import (
type HelmProvisioner struct { type HelmProvisioner struct {
cfg *config.Config cfg *config.Config
k8sClient *kubernetes.Clientset k8sClient *kubernetes.Clientset
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
chartsDir string // chemin vers le dossier des charts (ex: /charts) chartsDir string // chemin vers le dossier des charts (ex: /charts)
frontendImage string frontendImage string
backendImage string backendImage string
@@ -34,9 +36,11 @@ type HelmProvisioner struct {
// NewHelmProvisioner crée un nouveau provisioner Helm. // NewHelmProvisioner crée un nouveau provisioner Helm.
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/) // chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm") // helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
// metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil).
func NewHelmProvisioner( func NewHelmProvisioner(
cfg *config.Config, cfg *config.Config,
k8sClient *kubernetes.Clientset, k8sClient *kubernetes.Clientset,
metricsClient *metricsclient.Clientset,
chartsDir string, chartsDir string,
helmPath string, helmPath string,
) (*HelmProvisioner, error) { ) (*HelmProvisioner, error) {
@@ -55,6 +59,7 @@ func NewHelmProvisioner(
return &HelmProvisioner{ return &HelmProvisioner{
cfg: cfg, cfg: cfg,
k8sClient: k8sClient, k8sClient: k8sClient,
metricsClient: metricsClient,
chartsDir: chartsDir, chartsDir: chartsDir,
frontendImage: cfg.FrontendImage, frontendImage: cfg.FrontendImage,
backendImage: cfg.BackendImage, backendImage: cfg.BackendImage,
@@ -336,6 +341,38 @@ func (h *HelmProvisioner) waitForRollout(namespace string) error {
} }
} }
// componentResourceLimits : limites CPU/mémoire configurées dans chaque chart
// (deploy/chart-gestion/*/values.yaml), utilisées pour calculer un % d'usage.
var componentResourceLimits = map[string]struct {
cpuMilli int64
memMi int64
}{
"backend": {cpuMilli: 500, memMi: 256},
"frontend": {cpuMilli: 200, memMi: 128},
"postgresql": {cpuMilli: 1000, memMi: 1024},
"redis": {cpuMilli: 500, memMi: 512},
}
// componentKey identifie le composant (backend/frontend/postgresql/redis) à
// partir du nom de pod généré par Helm (ex: "demo-xxx-backend-...-6d59f4-abcde").
func componentKey(podName string) string {
switch {
case strings.Contains(podName, "backend"):
return "backend"
case strings.Contains(podName, "frontend"):
return "frontend"
case strings.Contains(podName, "postgresql"):
return "postgresql"
case strings.Contains(podName, "redis"):
return "redis"
default:
return ""
}
}
// GetResourceState : phase + usage CPU/mémoire live de chaque pod d'une démo.
// L'usage (metrics-server) est best-effort : s'il est indisponible, seule la
// phase du pod est renseignée plutôt que de faire échouer tout l'appel.
func (h *HelmProvisioner) GetResourceState( func (h *HelmProvisioner) GetResourceState(
ctx context.Context, ctx context.Context,
namespace string, namespace string,
@@ -349,23 +386,47 @@ func (h *HelmProvisioner) GetResourceState(
return ResourceState{}, err return ResourceState{}, err
} }
usage := map[string][2]int64{} // pod name -> [cpuMilli, memMi]
if h.metricsClient != nil {
if list, err := h.metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, metav1.ListOptions{}); err == nil {
for _, m := range list.Items {
var cpu, mem int64
for _, c := range m.Containers {
cpu += c.Usage.Cpu().MilliValue()
mem += c.Usage.Memory().Value() / (1024 * 1024)
}
usage[m.Name] = [2]int64{cpu, mem}
}
}
}
var state ResourceState var state ResourceState
for _, pod := range pods.Items { for _, pod := range pods.Items {
status := string(pod.Status.Phase) key := componentKey(pod.Name)
if key == "" {
continue
}
switch { cs := ComponentState{Phase: string(pod.Status.Phase)}
case strings.Contains(pod.Name, "backend"): if limits, ok := componentResourceLimits[key]; ok {
state.APIState = status cs.CPULimitMilli = limits.cpuMilli
cs.MemoryLimitMi = limits.memMi
}
if u, ok := usage[pod.Name]; ok {
cs.CPUMilli = u[0]
cs.MemoryMi = u[1]
}
case strings.Contains(pod.Name, "frontend"): switch key {
state.WebState = status case "backend":
state.API = cs
case strings.Contains(pod.Name, "postgresql"): case "frontend":
state.DBState = status state.Web = cs
case "postgresql":
case strings.Contains(pod.Name, "redis"): state.DB = cs
state.DBMemoryState = status case "redis":
state.DBM = cs
} }
} }
+16 -6
View File
@@ -27,11 +27,21 @@ type ExternalResource struct {
func (ExternalResource) TableName() string { return "external_pool" } func (ExternalResource) TableName() string { return "external_pool" }
type ResourceState struct { // ComponentState : état live d'un composant (pod) d'une démo — phase k8s +
APIState string `gorm:"size:20;not null" json:"api_state"` // usage CPU/mémoire actuel rapporté par metrics-server, avec les limites
WebState string `gorm:"size:20;not null" json:"web_state"` // configurées dans le chart Helm correspondant (pour calculer un pourcentage).
DBState string `gorm:"size:20;not null" json:"db_state"` type ComponentState struct {
DBMemoryState string `gorm:"size:20;not null" json:"dbm_state"` Phase string `json:"phase"` // Running, Pending, Failed, Unknown, "" si pod introuvable
CPUMilli int64 `json:"cpu_milli"` // usage CPU actuel, en millicores
CPULimitMilli int64 `json:"cpu_limit_milli"` // limite CPU configurée, en millicores
MemoryMi int64 `json:"memory_mi"` // usage mémoire actuel, en Mi
MemoryLimitMi int64 `json:"memory_limit_mi"` // limite mémoire configurée, en Mi
} }
func (ResourceState) TableName() string { return "ressource_state" } // ResourceState : état live des 4 composants d'une démo.
type ResourceState struct {
API ComponentState `json:"api"`
Web ComponentState `json:"web"`
DB ComponentState `json:"db"`
DBM ComponentState `json:"dbm"`
}
+24 -9
View File
@@ -8,19 +8,18 @@ import (
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd"
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
) )
func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { func restConfig(cfg *config.Config) (*rest.Config, error) {
var (
k8sCfg *rest.Config
err error
)
if cfg.Kubeconfig != "" { if cfg.Kubeconfig != "" {
k8sCfg, err = clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig) return clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig)
} else {
k8sCfg, err = rest.InClusterConfig()
} }
return rest.InClusterConfig()
}
func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
k8sCfg, err := restConfig(cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("k8s config: %w", err) return nil, fmt.Errorf("k8s config: %w", err)
} }
@@ -32,3 +31,19 @@ func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
return client, nil return client, nil
} }
// NewMetricsClient crée un client pour l'API metrics.k8s.io (metrics-server),
// utilisée pour lire l'usage CPU/mémoire en direct des pods.
func NewMetricsClient(cfg *config.Config) (*metricsclient.Clientset, error) {
k8sCfg, err := restConfig(cfg)
if err != nil {
return nil, fmt.Errorf("k8s config: %w", err)
}
client, err := metricsclient.NewForConfig(k8sCfg)
if err != nil {
return nil, fmt.Errorf("metrics clientset: %w", err)
}
return client, nil
}
+23
View File
@@ -0,0 +1,23 @@
# === Omnex — déploiement local (docker compose) ===
# Secrets générés pour le dev local. Ne jamais versionner .env.
# --- Secrets obligatoires ---
OMNEX_JWT_SECRET=0879032aa29538641920360c1990e39f7e86123b1fa869e9e3e60d44ceca0a34
OMNEX_SEED_PASSWORD=62dc702dfe26d117216d2994
# --- Postgres ---
POSTGRES_USER=omnex
POSTGRES_PASSWORD=770f17edeb9d1f38ad13e53f55e29963
POSTGRES_DB=omnex
# --- Redis (sessions) ---
REDIS_PASSWORD=630fa654c76f1256fea3ba3c51a718a4
# --- API ---
OMNEX_ENV=dev
OMNEX_SEED_USERNAME=admin
OMNEX_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
OMNEX_DEMO_DOMAIN=demo.omnex.app
KUBECONFIG=/home/xor_fakers/.kube/config
FRONTEND_IMAGE_APP=xor1234/frontend-mln:helm
BACKEND_IMAGE_APP=xor1234/backend-mln:helm
+73
View File
@@ -0,0 +1,73 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-omnex}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-omnex}
POSTGRES_DB: ${POSTGRES_DB:-omnex}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-omnex} -d ${POSTGRES_DB:-omnex}"]
interval: 5s
timeout: 3s
retries: 10
# Pas de port exposé : accès interne uniquement (défense en profondeur).
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-omnexredis}", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-omnexredis}", "ping"]
interval: 5s
timeout: 3s
retries: 10
web:
image: xor1234/omnex-web:latest
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "3000:80"
api:
image: xor1234/omnex-api:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
OMNEX_ENV: ${OMNEX_ENV:-dev}
OMNEX_ADDR: ":8080"
OMNEX_JWT_SECRET: ${OMNEX_JWT_SECRET}
OMNEX_ALLOWED_ORIGINS: ${OMNEX_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
OMNEX_DATABASE_URL: "host=postgres user=${POSTGRES_USER:-omnex} password=${POSTGRES_PASSWORD:-omnex} dbname=${POSTGRES_DB:-omnex} port=5432 sslmode=disable"
OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0"
OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin}
OMNEX_SEED_PASSWORD: ${OMNEX_SEED_PASSWORD}
OMNEX_DEMO_DOMAIN: ${OMNEX_DEMO_DOMAIN:-demo.omnex.app}
FRONTEND_IMAGE_APP: ${FRONTEND_IMAGE_APP:-xor1234/frontend-mln:latest}
BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest}
KUBECONFIG: /kubeconfig/config
volumes:
- ../deploy/chart-gestion:/charts:ro
- /home/xor_fakers/.kube/config:/kubeconfig/config:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 5s
timeout: 3s
retries: 10
ports:
- "8080:8080"
volumes:
pgdata:
redisdata:
+1 -1
View File
@@ -3,7 +3,7 @@
# --- Build (Vite) --- # --- Build (Vite) ---
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY ../../web/package.json ../../web/package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
# Même origine : l'API est proxifiée par nginx (cf. nginx.conf), donc URL relative. # Même origine : l'API est proxifiée par nginx (cf. nginx.conf), donc URL relative.
-9
View File
@@ -103,15 +103,6 @@ export function App() {
</RequireAdmin> </RequireAdmin>
} }
/> />
<Route
path="profile"
element={
<RequireAdmin>
<Profile />
</RequireAdmin>
}
/>
<Route path="subscription" element={<Subscription />} /> <Route path="subscription" element={<Subscription />} />
<Route path="profile" element={<Profile />} /> <Route path="profile" element={<Profile />} />
</Route> </Route>
+2 -2
View File
@@ -50,7 +50,7 @@ export function BackofficeLayout() {
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}> <HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
{(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>} {(isAdmin || (isClient && !isPremium)) && <NavItem to="/app/leads">Leads</NavItem>}
{isClient && <NavItem to="/app/subscription">Abonnement</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/demos">Démos</NavItem>}
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>} {isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
</HStack> </HStack>
@@ -105,7 +105,7 @@ export function BackofficeLayout() {
Codes Codes
</NavItem> </NavItem>
)} )}
{isAdmin || isClient && ( {(isAdmin || isClient) && (
<NavItem to="/app/profile" onClick={onClose} mobile> <NavItem to="/app/profile" onClick={onClose} mobile>
Profile Profile
</NavItem> </NavItem>
+12 -4
View File
@@ -87,11 +87,19 @@ export interface Contact {
created_at: string 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 { export interface DemoState {
api_state: string api: ComponentState
web_state: string web: ComponentState
db_state: string db: ComponentState
dbm_state: string dbm: ComponentState
} }
export interface DemoDetails { export interface DemoDetails {
+30
View File
@@ -31,6 +31,36 @@ export function statusLabel(s: DemoStatus): string {
return map[s] ?? s 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"). // Temps restant avant expiration, formaté (ex. "29 j 4 h").
export function timeRemaining(expiresAt: string, now: number = Date.now()): string { export function timeRemaining(expiresAt: string, now: number = Date.now()): string {
const ms = new Date(expiresAt).getTime() - now const ms = new Date(expiresAt).getTime() - now
+176 -69
View File
@@ -1,26 +1,22 @@
import { useCallback, useEffect, useState } from 'react' import { Fragment, useCallback, useEffect, useState } from 'react'
import { import {
Badge, Badge,
Box,
Button, Button,
Collapse,
Flex, Flex,
FormControl, FormControl,
FormLabel, FormLabel,
Heading, Heading,
HStack, HStack,
Icon,
Input, Input,
Link, Link,
Modal, Progress,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
SimpleGrid, SimpleGrid,
Spacer, Spacer,
Spinner, Spinner,
Stat, Stack,
StatLabel,
StatNumber,
Table, Table,
TableContainer, TableContainer,
Tbody, Tbody,
@@ -32,12 +28,21 @@ import {
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api' import { api, ApiError, type ComponentState, type Demo, type DemoDetails } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format' import { podStatusColor, podStatusLabel, statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog' import { ConfirmDialog } from '../../components/ConfirmDialog'
// Un provisioning en cours => on rafraîchit régulièrement. // Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000 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() { export function Demos() {
const toast = useToast() const toast = useToast()
@@ -51,8 +56,8 @@ export function Demos() {
const [newUsername, setNewUsername] = useState('') const [newUsername, setNewUsername] = useState('')
const [newLeadId, setNewLeadId] = useState('') const [newLeadId, setNewLeadId] = useState('')
// --- Détails d'une démo (modale) --- // --- Ligne dépliée (état live des pods) ---
const [detailsFor, setDetailsFor] = useState<Demo | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [details, setDetails] = useState<DemoDetails | null>(null) const [details, setDetails] = useState<DemoDetails | null>(null)
const [detailsLoading, setDetailsLoading] = useState(false) const [detailsLoading, setDetailsLoading] = useState(false)
@@ -125,8 +130,13 @@ export function Demos() {
} }
} }
const openDetails = async (d: Demo) => { const toggleRow = async (d: Demo) => {
setDetailsFor(d) if (expandedId === d.id) {
setExpandedId(null)
setDetails(null)
return
}
setExpandedId(d.id)
setDetails(null) setDetails(null)
setDetailsLoading(true) setDetailsLoading(true)
try { try {
@@ -134,17 +144,30 @@ export function Demos() {
setDetails(res) setDetails(res)
} catch (err) { } catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur' const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Détails indisponibles', description: msg }) toast({ status: 'error', title: 'État des pods indisponible', description: msg })
setDetailsFor(null) setExpandedId(null)
} finally { } finally {
setDetailsLoading(false) setDetailsLoading(false)
} }
} }
const closeDetails = () => { // Tant qu'une ligne est dépliée, on rafraîchit l'état des pods en direct
setDetailsFor(null) // (silencieux : pas de spinner, juste la mise à jour des badges/jauges).
setDetails(null) 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' const isAlive = (s: Demo['status']) => s !== 'expired' && s !== 'failed'
@@ -199,14 +222,28 @@ export function Demos() {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{demos.map((d) => ( {demos.map((d) => {
const isOpen = expandedId === d.id
return (
<Fragment key={d.id}>
<Tr <Tr
key={d.id}
cursor="pointer" cursor="pointer"
_hover={{ bg: 'gray.50' }} bg={isOpen ? 'chakra-subtle-bg' : undefined}
onClick={() => openDetails(d)} _hover={{ bg: 'chakra-subtle-bg' }}
onClick={() => toggleRow(d)}
> >
<Td fontFamily="mono">{d.namespace}</Td> <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> <Td>
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge> <Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td> </Td>
@@ -253,7 +290,28 @@ export function Demos() {
</HStack> </HStack>
</Td> </Td>
</Tr> </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> </Tbody>
</Table> </Table>
</TableContainer> </TableContainer>
@@ -274,47 +332,96 @@ export function Demos() {
et toutes ses données seront supprimées définitivement. Les ressources du pool seront et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible. libérées. Cette action est irréversible.
</ConfirmDialog> </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>
)
}