fix: fixup multi problem
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user