From ac8bdbcab73a4a85d6d536ceab1d5a680fdbf59e Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Thu, 30 Jul 2026 19:29:57 +0200 Subject: [PATCH] fix: fixup multi problem --- control-plane/api/cmd/api/main.go | 10 +- control-plane/api/go.mod | 1 + control-plane/api/go.sum | 2 + .../api/internal/demos/helm_provisioner.go | 87 ++++- control-plane/api/internal/demos/models.go | 22 +- control-plane/api/internal/k8s/client.go | 33 +- docker/.env | 23 ++ docker/docker-compose.yml | 73 ++++ web/Dockerfile | 2 +- web/src/App.tsx | 9 - web/src/components/BackofficeLayout.tsx | 4 +- web/src/lib/api.ts | 16 +- web/src/lib/format.ts | 30 ++ web/src/pages/backoffice/Demos.tsx | 343 ++++++++++++------ 14 files changed, 492 insertions(+), 163 deletions(-) create mode 100644 docker/.env create mode 100644 docker/docker-compose.yml diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index c4bbfc1..4a2ecf9 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -104,7 +104,15 @@ func main() { 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 { log.Fatalf("helm provisioner: %v", err) } diff --git a/control-plane/api/go.mod b/control-plane/api/go.mod index fca873d..8aefcd0 100644 --- a/control-plane/api/go.mod +++ b/control-plane/api/go.mod @@ -72,6 +72,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // 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 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/control-plane/api/go.sum b/control-plane/api/go.sum index 8dfa045..e3e8aea 100644 --- a/control-plane/api/go.sum +++ b/control-plane/api/go.sum @@ -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/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= 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/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 9f88a82..f2a136d 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -17,6 +17,7 @@ import ( k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + metricsclient "k8s.io/metrics/pkg/client/clientset/versioned" ) // HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm. @@ -24,7 +25,8 @@ import ( type HelmProvisioner struct { cfg *config.Config k8sClient *kubernetes.Clientset - chartsDir string // chemin vers le dossier des charts (ex: /charts) + metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible + chartsDir string // chemin vers le dossier des charts (ex: /charts) frontendImage string backendImage string baseDomain string @@ -34,9 +36,11 @@ type HelmProvisioner struct { // NewHelmProvisioner crée un nouveau provisioner Helm. // chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/) // 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( cfg *config.Config, k8sClient *kubernetes.Clientset, + metricsClient *metricsclient.Clientset, chartsDir string, helmPath string, ) (*HelmProvisioner, error) { @@ -55,6 +59,7 @@ func NewHelmProvisioner( return &HelmProvisioner{ cfg: cfg, k8sClient: k8sClient, + metricsClient: metricsClient, chartsDir: chartsDir, frontendImage: cfg.FrontendImage, 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( ctx context.Context, namespace string, @@ -349,23 +386,47 @@ func (h *HelmProvisioner) GetResourceState( 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 for _, pod := range pods.Items { - status := string(pod.Status.Phase) + key := componentKey(pod.Name) + if key == "" { + continue + } - switch { - case strings.Contains(pod.Name, "backend"): - state.APIState = status + cs := ComponentState{Phase: string(pod.Status.Phase)} + if limits, ok := componentResourceLimits[key]; ok { + 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"): - state.WebState = status - - case strings.Contains(pod.Name, "postgresql"): - state.DBState = status - - case strings.Contains(pod.Name, "redis"): - state.DBMemoryState = status + switch key { + case "backend": + state.API = cs + case "frontend": + state.Web = cs + case "postgresql": + state.DB = cs + case "redis": + state.DBM = cs } } diff --git a/control-plane/api/internal/demos/models.go b/control-plane/api/internal/demos/models.go index 319e027..ea8068c 100644 --- a/control-plane/api/internal/demos/models.go +++ b/control-plane/api/internal/demos/models.go @@ -27,11 +27,21 @@ type ExternalResource struct { func (ExternalResource) TableName() string { return "external_pool" } -type ResourceState struct { - APIState string `gorm:"size:20;not null" json:"api_state"` - WebState string `gorm:"size:20;not null" json:"web_state"` - DBState string `gorm:"size:20;not null" json:"db_state"` - DBMemoryState string `gorm:"size:20;not null" json:"dbm_state"` +// ComponentState : état live d'un composant (pod) d'une démo — phase k8s + +// usage CPU/mémoire actuel rapporté par metrics-server, avec les limites +// configurées dans le chart Helm correspondant (pour calculer un pourcentage). +type ComponentState struct { + 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"` +} diff --git a/control-plane/api/internal/k8s/client.go b/control-plane/api/internal/k8s/client.go index 0147df2..7788b3f 100644 --- a/control-plane/api/internal/k8s/client.go +++ b/control-plane/api/internal/k8s/client.go @@ -8,19 +8,18 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + metricsclient "k8s.io/metrics/pkg/client/clientset/versioned" ) -func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { - var ( - k8sCfg *rest.Config - err error - ) - +func restConfig(cfg *config.Config) (*rest.Config, error) { if cfg.Kubeconfig != "" { - k8sCfg, err = clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig) - } else { - k8sCfg, err = rest.InClusterConfig() + return clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig) } + return rest.InClusterConfig() +} + +func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { + k8sCfg, err := restConfig(cfg) if err != nil { return nil, fmt.Errorf("k8s config: %w", err) } @@ -32,3 +31,19 @@ func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { 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 +} diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..9313f14 --- /dev/null +++ b/docker/.env @@ -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 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..7311db1 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: diff --git a/web/Dockerfile b/web/Dockerfile index d5cf744..6bc4617 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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. diff --git a/web/src/App.tsx b/web/src/App.tsx index 13dcc25..898d477 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -103,15 +103,6 @@ export function App() { } /> - - - - } - /> - } /> } /> diff --git a/web/src/components/BackofficeLayout.tsx b/web/src/components/BackofficeLayout.tsx index 6bde8c8..17feac9 100644 --- a/web/src/components/BackofficeLayout.tsx +++ b/web/src/components/BackofficeLayout.tsx @@ -50,7 +50,7 @@ export function BackofficeLayout() { {(isAdmin || (isClient && !isPremium)) && Leads} {isClient && Abonnement} - {isAdmin || isClient && Profile} + {(isAdmin || isClient) && Profile} {isAdmin && Démos} {isAdmin && Codes} @@ -105,7 +105,7 @@ export function BackofficeLayout() { Codes )} - {isAdmin || isClient && ( + {(isAdmin || isClient) && ( Profile diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1d2f70d..8a1d617 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 549f3ab..39b2d2b 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -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 = { + 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 diff --git a/web/src/pages/backoffice/Demos.tsx b/web/src/pages/backoffice/Demos.tsx index ab97d5d..9203658 100644 --- a/web/src/pages/backoffice/Demos.tsx +++ b/web/src/pages/backoffice/Demos.tsx @@ -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(null) + // --- Ligne dépliée (état live des pods) --- + const [expandedId, setExpandedId] = useState(null) const [details, setDetails] = useState(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() { - {demos.map((d) => ( - openDetails(d)} - > - {d.namespace} - - {statusLabel(d.status)} - - - {d.status === 'ready' ? ( - e.stopPropagation()} - > - {d.url} - - ) : ( - - )} - - {isAlive(d.status) ? timeRemaining(d.expires_at) : '—'} - - - - - - - - ))} + {demos.map((d) => { + const isOpen = expandedId === d.id + return ( + + toggleRow(d)} + > + + + + {d.namespace} + + + + {statusLabel(d.status)} + + + {d.status === 'ready' ? ( + e.stopPropagation()} + > + {d.url} + + ) : ( + + )} + + {isAlive(d.status) ? timeRemaining(d.expires_at) : '—'} + + + + + + + + + + + + {detailsLoading && !details ? ( + + + + ) : details ? ( + + ) : ( + + Aucune donnée. + + )} + + + + + + ) + })} @@ -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. - - - - - - Détails de{' '} - - {detailsFor?.namespace} - - - - - {detailsLoading ? ( - - - - ) : details ? ( - - - Backend - {details.state.api_state || '—'} - - - Frontend - {details.state.web_state || '—'} - - - PostgreSQL - {details.state.db_state || '—'} - - - Redis - {details.state.dbm_state || '—'} - - - ) : ( - Aucune donnée. - )} - - - ) } + +// 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 ( + + + + + {allRunning + ? 'Tous les services sont opérationnels' + : `${downCount} service${downCount > 1 ? 's' : ''} indisponible${downCount > 1 ? 's' : ''}`} + + + + {COMPONENTS.map((c) => ( + + ))} + + + ) +} + +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 ( + + + + {title} + + + + + {podStatusLabel(cs.phase)} + + + + + + + + CPU + + {cs.cpu_milli}m / {cs.cpu_limit_milli}m + + + 85 ? 'red' : cpuPct > 60 ? 'orange' : 'primary'} + /> + + + + Mémoire + + {cs.memory_mi}Mi / {cs.memory_limit_mi}Mi + + + 85 ? 'red' : memPct > 60 ? 'orange' : 'primary'} + /> + + + + ) +} + +function ChevronIcon(props: React.ComponentProps) { + return ( + + + + ) +}