chore: update
This commit is contained in:
@@ -4,6 +4,7 @@ package demos
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -33,8 +34,26 @@ const (
|
|||||||
var (
|
var (
|
||||||
ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'")
|
ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'")
|
||||||
ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3")
|
ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3")
|
||||||
|
ErrInvalidDomain = errors.New("nom de domaine invalide")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// domainPattern : nom d'hôte RFC 1123 (labels alphanumériques + tirets,
|
||||||
|
// séparés par des points, au moins un point — pas de protocole ni de chemin).
|
||||||
|
var domainPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$`)
|
||||||
|
|
||||||
|
// ValidateDomain vérifie le format d'un domaine personnalisé. Une chaîne
|
||||||
|
// vide est valide : elle signifie "revenir au domaine par défaut" (voir
|
||||||
|
// Service.SetDomain).
|
||||||
|
func ValidateDomain(domain string) error {
|
||||||
|
if domain == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(domain) > 253 || !domainPattern.MatchString(domain) {
|
||||||
|
return ErrInvalidDomain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProvisionConfig : réglages saisis par l'admin à la création (popup de
|
// ProvisionConfig : réglages saisis par l'admin à la création (popup de
|
||||||
// déploiement), propagés jusqu'au chart backend. Jamais persisté en base —
|
// déploiement), propagés jusqu'au chart backend. Jamais persisté en base —
|
||||||
// notamment les secrets (token Telegram, clés NowPayments), qui ne
|
// notamment les secrets (token Telegram, clés NowPayments), qui ne
|
||||||
|
|||||||
@@ -179,6 +179,35 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, d)
|
c.JSON(http.StatusOK, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setDomainRequest struct {
|
||||||
|
// Domain : nom d'hôte sans protocole ni chemin (ex. "boutique.client.com").
|
||||||
|
// Vide = réinitialise au domaine par défaut.
|
||||||
|
Domain string `json:"domain" binding:"omitempty,max=253"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomain : POST /demos/:id/domain — modifie (ou réinitialise si vide) le
|
||||||
|
// domaine public d'une démo.
|
||||||
|
func (h *Handler) SetDomain(c *gin.Context) {
|
||||||
|
var req setDomainRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := h.svc.SetDomain(c.Param("id"), req.Domain)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
||||||
|
case errors.Is(err, ErrInvalidDomain):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
// Extend : POST /demos/:id/extend — prolonge de 30 jours.
|
// Extend : POST /demos/:id/extend — prolonge de 30 jours.
|
||||||
func (h *Handler) Extend(c *gin.Context) {
|
func (h *Handler) Extend(c *gin.Context) {
|
||||||
d, err := h.svc.Extend(c.Param("id"))
|
d, err := h.svc.Extend(c.Param("id"))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
k8sCoreV1 "k8s.io/api/core/v1"
|
k8sCoreV1 "k8s.io/api/core/v1"
|
||||||
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/apimachinery/pkg/types"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/kubernetes/scheme"
|
"k8s.io/client-go/kubernetes/scheme"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -314,6 +315,80 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDomain change le domaine public d'une démo sans changer de
|
||||||
|
// namespace ni migrer de données : réinstalle l'IngressRoute avec le
|
||||||
|
// nouveau host (Traefik route sur le nom d'hôte), et si un bot Telegram
|
||||||
|
// et/ou le load-balancer lbtelegram sont configurés, met à jour leurs
|
||||||
|
// valeurs dépendantes du domaine (URL de webhook, GATEWAY_URL) puis force
|
||||||
|
// un redémarrage des pods concernés — un Secret modifié seul ne déclenche
|
||||||
|
// pas de rollout automatique côté Kubernetes.
|
||||||
|
//
|
||||||
|
// newURL : URL complète ("https://hôte[:port]") telle que calculée par
|
||||||
|
// Service.SetDomain. Best-effort sur backend/lbtelegram : une démo encore
|
||||||
|
// en cours de provisioning (releases pas encore installées) n'a que son
|
||||||
|
// IngressRoute mis à jour, sans erreur bloquante.
|
||||||
|
func (h *HelmProvisioner) UpdateDomain(d Demo, newURL string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
host := strings.TrimPrefix(strings.TrimPrefix(newURL, "https://"), "http://")
|
||||||
|
if i := strings.IndexByte(host, '/'); i >= 0 {
|
||||||
|
host = host[:i]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||||
|
host = host[:i] // Host() de Traefik ne matche pas le port
|
||||||
|
}
|
||||||
|
|
||||||
|
ingressValues := map[string]interface{}{
|
||||||
|
"host": host,
|
||||||
|
"traefikNamespace": sharedTraefikNamespace,
|
||||||
|
}
|
||||||
|
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-ingressroute", "ingressroute", ingressValues); err != nil {
|
||||||
|
return fmt.Errorf("mise à jour ingressroute: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if backendValues, err := h.getReleaseValues(d.Namespace, d.Namespace+"-backend"); err == nil {
|
||||||
|
secrets := asStringMap(backendValues["secrets"])
|
||||||
|
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
|
||||||
|
secrets["TELEGRAM_WEBHOOK_URL"] = newURL + "/webhook/telegram"
|
||||||
|
backendValues["secrets"] = secrets
|
||||||
|
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-backend", "backend", backendValues); err != nil {
|
||||||
|
log.Printf("Warning: mise à jour webhook Telegram (backend) échouée pour %s: %v", d.Namespace, err)
|
||||||
|
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-backend-gestion-backend"); err != nil {
|
||||||
|
log.Printf("Warning: redémarrage backend échoué pour %s: %v", d.Namespace, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lbValues, ok := h.getReleaseValuesOptional(d.Namespace, d.Namespace+"-lbtelegram"); ok {
|
||||||
|
env := asStringMap(lbValues["env"])
|
||||||
|
env["GATEWAY_URL"] = "https://" + host
|
||||||
|
lbValues["env"] = env
|
||||||
|
lbValues["host"] = host
|
||||||
|
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-lbtelegram", "lbtelegram", lbValues); err != nil {
|
||||||
|
log.Printf("Warning: mise à jour host lbtelegram échouée pour %s: %v", d.Namespace, err)
|
||||||
|
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-lbtelegram-lbtelegram"); err != nil {
|
||||||
|
log.Printf("Warning: redémarrage lbtelegram échoué pour %s: %v", d.Namespace, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Domaine de %s mis à jour: %s", d.Namespace, host)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartDeployment force un nouveau rollout (annotation datée sur le pod
|
||||||
|
// template) pour qu'un Deployment reprenne en compte un Secret modifié sans
|
||||||
|
// changer d'image ni de spec structurel — Kubernetes ne redémarre pas les
|
||||||
|
// pods automatiquement dans ce cas.
|
||||||
|
func (h *HelmProvisioner) restartDeployment(ctx context.Context, namespace, name string) error {
|
||||||
|
patch := []byte(fmt.Sprintf(
|
||||||
|
`{"spec":{"template":{"metadata":{"annotations":{"omnex.app/restartedAt":%q}}}}}`,
|
||||||
|
time.Now().UTC().Format(time.RFC3339),
|
||||||
|
))
|
||||||
|
_, err := h.k8sClient.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// migratePostgresData copie les données postgres d'un namespace à l'autre
|
// migratePostgresData copie les données postgres d'un namespace à l'autre
|
||||||
// (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace.
|
// (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace.
|
||||||
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error {
|
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error {
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package demos
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Demo struct {
|
type Demo struct {
|
||||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
|
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
|
||||||
Status Status `gorm:"size:20;not null;index" json:"status"`
|
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||||
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
||||||
URL string `gorm:"size:255" json:"url"`
|
URL string `gorm:"size:255" json:"url"`
|
||||||
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
|
// CustomDomain : domaine choisi par l'admin ("" = domaine par défaut
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// "<namespace>.<baseDomain>", voir Service.SetDomain).
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
CustomDomain string `gorm:"size:255" json:"custom_domain,omitempty"`
|
||||||
|
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Demo) TableName() string { return "demos" }
|
func (Demo) TableName() string { return "demos" }
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type Provisioner interface {
|
|||||||
// données postgres, puis supprime l'ancien namespace une fois le
|
// données postgres, puis supprime l'ancien namespace une fois le
|
||||||
// nouveau opérationnel. No-op si d.Namespace == newNamespace.
|
// nouveau opérationnel. No-op si d.Namespace == newNamespace.
|
||||||
MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error
|
MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error
|
||||||
|
// UpdateDomain change le domaine public d'une démo (IngressRoute, et
|
||||||
|
// re-branchement des webhooks Telegram si configurés) sans changer de
|
||||||
|
// namespace ni migrer de données.
|
||||||
|
UpdateDomain(d Demo, newURL string) error
|
||||||
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
|
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ type NoopProvisioner struct{}
|
|||||||
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
|
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
|
||||||
func (NoopProvisioner) Teardown(Demo) error { return nil }
|
func (NoopProvisioner) Teardown(Demo) error { return nil }
|
||||||
func (NoopProvisioner) MigrateToPremiumNamespace(Demo, string, string) error { return nil }
|
func (NoopProvisioner) MigrateToPremiumNamespace(Demo, string, string) error { return nil }
|
||||||
|
func (NoopProvisioner) UpdateDomain(Demo, string) error { return nil }
|
||||||
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
|
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
|
||||||
return ResourceState{}, nil
|
return ResourceState{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package demos
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -192,6 +193,39 @@ func (s *Service) urlFor(namespace string) string {
|
|||||||
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
|
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDomain modifie le domaine public d'une démo (IngressRoute, et
|
||||||
|
// re-branchement des webhooks Telegram si un bot est configuré — voir
|
||||||
|
// HelmProvisioner.UpdateDomain). domain vide réinitialise au domaine par
|
||||||
|
// défaut ("<namespace>.<baseDomain>"). Contrairement à
|
||||||
|
// MigrateToPremiumNamespace, c'est une opération rapide (aucune donnée à
|
||||||
|
// migrer, aucun namespace à recréer) : appliquée de façon synchrone.
|
||||||
|
func (s *Service) SetDomain(id, domain string) (Demo, error) {
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
if err := ValidateDomain(domain); err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d, ok := s.store.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return Demo{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
newURL := domain
|
||||||
|
if newURL == "" {
|
||||||
|
newURL = s.urlFor(d.Namespace)
|
||||||
|
} else {
|
||||||
|
newURL = "https://" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.prov.UpdateDomain(d, newURL); err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.CustomDomain = domain
|
||||||
|
d.URL = newURL
|
||||||
|
return s.store.Update(d)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete déclenche le teardown et libère le pool.
|
// Delete déclenche le teardown et libère le pool.
|
||||||
func (s *Service) Delete(id string) (Demo, error) {
|
func (s *Service) Delete(id string) (Demo, error) {
|
||||||
d, ok := s.store.Get(id)
|
d, ok := s.store.Get(id)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func New(d Deps) *gin.Engine {
|
|||||||
admin.GET("/demos/:id", d.DemosH.Get)
|
admin.GET("/demos/:id", d.DemosH.Get)
|
||||||
admin.DELETE("/demos/:id", d.DemosH.Delete)
|
admin.DELETE("/demos/:id", d.DemosH.Delete)
|
||||||
admin.POST("/demos/:id/extend", d.DemosH.Extend)
|
admin.POST("/demos/:id/extend", d.DemosH.Extend)
|
||||||
|
admin.POST("/demos/:id/domain", d.DemosH.SetDomain)
|
||||||
admin.POST("/demos/details", d.DemosH.ListDetails)
|
admin.POST("/demos/details", d.DemosH.ListDetails)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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:
|
|
||||||
Vendored
-1510
File diff suppressed because one or more lines are too long
Vendored
-13
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
|
|
||||||
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
|
|
||||||
<script type="module" crossorigin src="/assets/index-BZ4jKugg.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { api, ApiError, type Demo } from '../lib/api'
|
||||||
|
|
||||||
|
interface EditDomainModalProps {
|
||||||
|
demo: Demo | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal d'édition du domaine public d'une démo — partagé entre le dashboard
|
||||||
|
// Demos et le dashboard Premium.
|
||||||
|
export function EditDomainModal({ demo, onClose, onSaved }: EditDomainModalProps) {
|
||||||
|
const toast = useToast()
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDomain(demo?.custom_domain ?? '')
|
||||||
|
}, [demo])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (saving) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!demo) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.setDemoDomain(demo.id, domain.trim())
|
||||||
|
toast({ status: 'success', title: 'Domaine mis à jour' })
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
|
toast({ status: 'error', title: 'Mise à jour impossible', description: msg })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={!!demo} onClose={handleClose} closeOnOverlayClick={!saving}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Domaine de la plateforme</ModalHeader>
|
||||||
|
<ModalCloseButton isDisabled={saving} />
|
||||||
|
<ModalBody>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel fontSize="sm">Domaine personnalisé</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="boutique.mon-domaine.com"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
isDisabled={saving}
|
||||||
|
fontFamily="mono"
|
||||||
|
/>
|
||||||
|
<FormHelperText>
|
||||||
|
Laissez vide pour revenir au domaine par défaut ({demo?.namespace}.<domaine
|
||||||
|
omnex>). Le DNS du domaine choisi doit pointer vers le cluster, et un certificat
|
||||||
|
TLS valide doit le couvrir — le certificat wildcard partagé ne couvre que le domaine
|
||||||
|
par défaut.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="primary" onClick={handleSave} isLoading={saving}>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ export interface Demo {
|
|||||||
status: DemoStatus
|
status: DemoStatus
|
||||||
namespace: string
|
namespace: string
|
||||||
url: string
|
url: string
|
||||||
|
custom_domain?: string
|
||||||
type_abonnement?: string
|
type_abonnement?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
@@ -186,6 +187,8 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
|
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
|
||||||
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),
|
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),
|
||||||
|
setDemoDomain: (id: string, domain: string) =>
|
||||||
|
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
|
||||||
|
|
||||||
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
||||||
createCode: (username: string) =>
|
createCode: (username: string) =>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
Spacer,
|
Spacer,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -21,11 +22,13 @@ import {
|
|||||||
Tr,
|
Tr,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
|
import { EditIcon } from '@chakra-ui/icons'
|
||||||
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 Demo, type DemoDetails } from '../../lib/api'
|
||||||
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
|
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
|
||||||
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||||
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
||||||
|
import { EditDomainModal } from '../../components/EditDomainModal'
|
||||||
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
||||||
|
|
||||||
// Un provisioning en cours => on rafraîchit régulièrement.
|
// Un provisioning en cours => on rafraîchit régulièrement.
|
||||||
@@ -41,6 +44,7 @@ export function Demos() {
|
|||||||
const [busy, setBusy] = useState<string | null>(null)
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
const [toDelete, setToDelete] = useState<Demo | null>(null)
|
const [toDelete, setToDelete] = useState<Demo | null>(null)
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||||
|
const [editingDomain, setEditingDomain] = useState<Demo | null>(null)
|
||||||
|
|
||||||
// --- Ligne dépliée (état live des pods) ---
|
// --- Ligne dépliée (état live des pods) ---
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
@@ -209,18 +213,30 @@ export function Demos() {
|
|||||||
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
{d.status === 'ready' ? (
|
<HStack spacing={1}>
|
||||||
<Link
|
{d.status === 'ready' ? (
|
||||||
href={d.url}
|
<Link
|
||||||
color="primary.500"
|
href={d.url}
|
||||||
isExternal
|
color="primary.500"
|
||||||
onClick={(e) => e.stopPropagation()}
|
isExternal
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{d.url}
|
>
|
||||||
</Link>
|
{d.url}
|
||||||
) : (
|
</Link>
|
||||||
<Text color="gray.400">—</Text>
|
) : (
|
||||||
)}
|
<Text color="gray.400">—</Text>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Modifier le domaine"
|
||||||
|
icon={<EditIcon />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingDomain(d)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
|
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
|
||||||
<Td textAlign="right">
|
<Td textAlign="right">
|
||||||
@@ -293,6 +309,12 @@ 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>
|
||||||
|
|
||||||
|
<EditDomainModal
|
||||||
|
demo={editingDomain}
|
||||||
|
onClose={() => setEditingDomain(null)}
|
||||||
|
onSaved={() => void load()}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
Spacer,
|
Spacer,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -21,11 +22,13 @@ import {
|
|||||||
Tr,
|
Tr,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
|
import { EditIcon } from '@chakra-ui/icons'
|
||||||
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 Demo, type DemoDetails } from '../../lib/api'
|
||||||
import { statusColor, statusLabel } from '../../lib/format'
|
import { statusColor, statusLabel } from '../../lib/format'
|
||||||
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
||||||
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
||||||
|
import { EditDomainModal } from '../../components/EditDomainModal'
|
||||||
|
|
||||||
// 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
|
||||||
@@ -38,6 +41,7 @@ export function PremiumDemos() {
|
|||||||
const [demos, setDemos] = useState<Demo[]>([])
|
const [demos, setDemos] = useState<Demo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||||
|
const [editingDomain, setEditingDomain] = useState<Demo | null>(null)
|
||||||
|
|
||||||
// --- Ligne dépliée (état live des pods) ---
|
// --- Ligne dépliée (état live des pods) ---
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
@@ -165,18 +169,30 @@ export function PremiumDemos() {
|
|||||||
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
{d.status === 'ready' ? (
|
<HStack spacing={1}>
|
||||||
<Link
|
{d.status === 'ready' ? (
|
||||||
href={d.url}
|
<Link
|
||||||
color="primary.500"
|
href={d.url}
|
||||||
isExternal
|
color="primary.500"
|
||||||
onClick={(e) => e.stopPropagation()}
|
isExternal
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{d.url}
|
>
|
||||||
</Link>
|
{d.url}
|
||||||
) : (
|
</Link>
|
||||||
<Text color="gray.400">—</Text>
|
) : (
|
||||||
)}
|
<Text color="gray.400">—</Text>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Modifier le domaine"
|
||||||
|
icon={<EditIcon />}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingDomain(d)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
<Tr>
|
<Tr>
|
||||||
@@ -203,6 +219,12 @@ export function PremiumDemos() {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<EditDomainModal
|
||||||
|
demo={editingDomain}
|
||||||
|
onClose={() => setEditingDomain(null)}
|
||||||
|
onSaved={() => void load()}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/CreateDemoModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PasswordInput.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/AdminLogin.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/CreateDemoModal.tsx","./src/components/EditDomainModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PasswordInput.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/AdminLogin.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user