chore: update
ci-api / test (push) Successful in 21m59s
ci-web / test (push) Successful in 13m44s

This commit is contained in:
Xor290
2026-08-02 21:31:29 +02:00
parent ca14327ea2
commit 2592ea1c6f
15 changed files with 338 additions and 1629 deletions
+19
View File
@@ -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 {
@@ -8,6 +8,9 @@ type Demo struct {
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"`
// CustomDomain : domaine choisi par l'admin ("" = domaine par défaut
// "<namespace>.<baseDomain>", voir Service.SetDomain).
CustomDomain string `gorm:"size:255" json:"custom_domain,omitempty"`
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"` TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
@@ -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)
} }
} }
-73
View File
@@ -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:
-1510
View File
File diff suppressed because one or more lines are too long
-13
View File
@@ -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>
+92
View File
@@ -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}.&lt;domaine
omnex&gt;). 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>
)
}
+3
View File
@@ -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) =>
+22
View File
@@ -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,6 +213,7 @@ 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>
<HStack spacing={1}>
{d.status === 'ready' ? ( {d.status === 'ready' ? (
<Link <Link
href={d.url} href={d.url}
@@ -221,6 +226,17 @@ export function Demos() {
) : ( ) : (
<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()}
/>
</> </>
) )
} }
+22
View File
@@ -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,6 +169,7 @@ 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>
<HStack spacing={1}>
{d.status === 'ready' ? ( {d.status === 'ready' ? (
<Link <Link
href={d.url} href={d.url}
@@ -177,6 +182,17 @@ export function PremiumDemos() {
) : ( ) : (
<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
View File
@@ -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"}