fix: multiple error
This commit is contained in:
@@ -118,12 +118,18 @@ func main() {
|
||||
}
|
||||
|
||||
// Traefik partagé + WAF Coraza : installé une fois pour toutes les démos
|
||||
// (idempotent). Non-fatal : si ça échoue, l'API démarre quand même, mais
|
||||
// les démos provisionnées ensuite ne seront ni routées ni protégées tant
|
||||
// que ce n'est pas corrigé.
|
||||
// (idempotent). En arrière-plan et non-fatal : "helm upgrade --install
|
||||
// --wait --timeout 5m" peut bloquer plusieurs minutes si le cluster est
|
||||
// lent/injoignable, ce qui retardait le démarrage du serveur HTTP au
|
||||
// point de faire échouer le healthcheck Docker avant même que l'API
|
||||
// n'écoute sur le port. Ici ça ne retarde plus rien : les démos
|
||||
// provisionnées avant la fin de cet appel ne seront juste ni routées ni
|
||||
// protégées tant qu'il n'a pas fini.
|
||||
go func() {
|
||||
if err := helmProv.EnsureSharedInfra(); err != nil {
|
||||
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Service de démos avec le provisioner Helm
|
||||
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
|
||||
|
||||
@@ -24,11 +24,14 @@ type createRequest struct {
|
||||
}
|
||||
|
||||
type DetailDemoUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=64,alphanum"`
|
||||
// Namespace identifie la démo de façon unique. Le username ne suffit
|
||||
// pas : un même client peut avoir plusieurs démos (historique, relances),
|
||||
// et interroger par username renvoyait toujours la même (la plus
|
||||
// ancienne), jamais celle réellement sélectionnée dans l'UI.
|
||||
Namespace string `json:"namespace" binding:"required,min=3,max=63"`
|
||||
}
|
||||
|
||||
type DemoDetailsResponse struct {
|
||||
Username string `json:"username"`
|
||||
Namespace string `json:"namespace"`
|
||||
State ResourceState `json:"state"`
|
||||
}
|
||||
@@ -42,11 +45,14 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
}
|
||||
d, err := h.svc.Create(req.LeadID, req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrCapacityReached) {
|
||||
switch {
|
||||
case errors.Is(err, ErrCapacityReached):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
|
||||
return
|
||||
}
|
||||
case errors.Is(err, ErrUserHasActiveDemo):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, d)
|
||||
@@ -62,6 +68,21 @@ func (h *Handler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// ListMine : GET /demos/mine — démo(s) rattachée(s) au client authentifié.
|
||||
func (h *Handler) ListMine(c *gin.Context) {
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||
return
|
||||
}
|
||||
items, err := h.svc.ListForUser(p.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) ListDetails(c *gin.Context) {
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
@@ -74,26 +95,19 @@ func (h *Handler) ListDetails(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req DetailDemoUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username requis"})
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Namespace == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "namespace requis"})
|
||||
return
|
||||
}
|
||||
|
||||
demo, err := h.svc.store.GetDemoByUsername(req.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := h.helm.GetResourceState(c.Request.Context(), demo.Namespace)
|
||||
state, err := h.helm.GetResourceState(c.Request.Context(), req.Namespace)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, DemoDetailsResponse{
|
||||
Username: req.Username,
|
||||
Namespace: demo.Namespace,
|
||||
Namespace: req.Namespace,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ var (
|
||||
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
|
||||
ErrNotFound = errors.New("démo introuvable")
|
||||
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
|
||||
ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active")
|
||||
)
|
||||
|
||||
// Config du service (injectable pour les tests).
|
||||
@@ -50,8 +51,22 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
|
||||
}
|
||||
|
||||
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
|
||||
// username (optionnel) rattache la démo à un client existant.
|
||||
// username (optionnel) rattache la démo à un client existant. Un client n'a
|
||||
// droit qu'à une seule démo active à la fois.
|
||||
func (s *Service) Create(leadID, username string) (Demo, error) {
|
||||
normalizedUsername := auth.NormalizeUsername(username)
|
||||
if normalizedUsername != "" {
|
||||
existing, err := s.store.ListByUsername(normalizedUsername)
|
||||
if err != nil {
|
||||
return Demo{}, err
|
||||
}
|
||||
for _, d := range existing {
|
||||
if d.Status.Active() {
|
||||
return Demo{}, ErrUserHasActiveDemo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active, err := s.store.CountActive()
|
||||
if err != nil {
|
||||
return Demo{}, err
|
||||
@@ -63,7 +78,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
|
||||
id := uuid.NewString()
|
||||
demo := Demo{
|
||||
ID: id,
|
||||
Username: auth.NormalizeUsername(username),
|
||||
Username: normalizedUsername,
|
||||
LeadID: leadID,
|
||||
Status: StatusPending,
|
||||
Namespace: "demo-" + shortID(id),
|
||||
|
||||
@@ -35,13 +35,13 @@ func (h *Handler) UpdateUsernameById(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := c.MustGet("id").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UpdateUsername(id, u.Username)
|
||||
user, err := h.store.UpdateUsername(p.UserID, u.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
||||
return
|
||||
@@ -57,9 +57,9 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
id, ok := c.MustGet("id").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.store.UpdatePassword(id, hash)
|
||||
user, err := h.store.UpdatePassword(p.UserID, hash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||
return
|
||||
@@ -79,12 +79,12 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) GetTelegramById(c *gin.Context) {
|
||||
id, ok := c.MustGet("id").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||
return
|
||||
}
|
||||
user, err := h.store.GetTelegram(id)
|
||||
user, err := h.store.GetTelegram(p.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||
return
|
||||
@@ -98,12 +98,12 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||
return
|
||||
}
|
||||
id, ok := c.MustGet("id").(string)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"})
|
||||
p := auth.PrincipalFrom(c)
|
||||
if p == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||
return
|
||||
}
|
||||
user, err := h.store.SetTelegram(id, t.Telegram)
|
||||
user, err := h.store.SetTelegram(p.UserID, t.Telegram)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
|
||||
return
|
||||
|
||||
@@ -63,6 +63,9 @@ func New(d Deps) *gin.Engine {
|
||||
client.GET("/leads", d.LeadsH.List)
|
||||
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
|
||||
client.POST("/subscription", d.SubH.AddCode)
|
||||
if d.DemosH != nil {
|
||||
client.GET("/demos/mine", d.DemosH.ListMine)
|
||||
}
|
||||
}
|
||||
// Espace admin : provisioning des démos (admin uniquement).
|
||||
admin := authed.Group("")
|
||||
|
||||
@@ -88,11 +88,17 @@ traefik:
|
||||
enabled: true
|
||||
allowCrossNamespace: true
|
||||
|
||||
# IP réelle via X-Forwarded-For (profondeur 1 = proxy direct)
|
||||
# Le rate-limiter et les logs utilisent cette IP
|
||||
# Cluster (pas Local) : sur un cluster bare-metal multi-nœuds sans
|
||||
# LoadBalancer, "Local" fait DROP le trafic NodePort sur tout nœud qui ne
|
||||
# fait pas tourner le pod Traefik (comportement kube-proxy documenté —
|
||||
# "has no local endpoints"), donc l'URL casse selon le nœud contacté et
|
||||
# où le pod est schedulé. "Cluster" relaie toujours vers le bon nœud, au
|
||||
# prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le
|
||||
# rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant
|
||||
# qu'il n'y a pas de LoadBalancer (MetalLB) devant.
|
||||
service:
|
||||
spec:
|
||||
externalTrafficPolicy: Local
|
||||
externalTrafficPolicy: Cluster
|
||||
|
||||
# Métriques Prometheus
|
||||
metrics:
|
||||
|
||||
+3
-3
@@ -104,7 +104,6 @@ export interface DemoState {
|
||||
}
|
||||
|
||||
export interface DemoDetails {
|
||||
username: string
|
||||
namespace: string
|
||||
state: DemoState
|
||||
}
|
||||
@@ -150,6 +149,7 @@ export const api = {
|
||||
request<Lead>('PATCH', `/leads/${id}/status`, { status }),
|
||||
|
||||
listDemos: () => request<{ items: Demo[] }>('GET', '/demos'),
|
||||
listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'),
|
||||
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
|
||||
createDemo: (username: string, leadId?: string) =>
|
||||
request<Demo>('POST', '/demos', {
|
||||
@@ -167,8 +167,8 @@ export const api = {
|
||||
sendMessage: (username: string, telegram: string, sujet: string, message: string) =>
|
||||
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
|
||||
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
|
||||
getDemoDetails: (username: string) =>
|
||||
request<DemoDetails>('POST', '/demos/details', { username }),
|
||||
getDemoDetails: (namespace: string) =>
|
||||
request<DemoDetails>('POST', '/demos/details', { namespace }),
|
||||
updateUsername: (username: string) =>
|
||||
request<UpdateUsername>('POST', '/profile/username', { username }),
|
||||
updatePassword: (password: string) =>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function Demos() {
|
||||
setDetails(null)
|
||||
setDetailsLoading(true)
|
||||
try {
|
||||
const res = await api.getDemoDetails(d.username)
|
||||
const res = await api.getDemoDetails(d.namespace)
|
||||
setDetails(res)
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||
@@ -156,10 +156,10 @@ export function Demos() {
|
||||
useEffect(() => {
|
||||
const current = demos.find((d) => d.id === expandedId)
|
||||
if (!current) return
|
||||
const username = current.username
|
||||
const namespace = current.namespace
|
||||
const id = setInterval(() => {
|
||||
api
|
||||
.getDemoDetails(username)
|
||||
.getDemoDetails(namespace)
|
||||
.then(setDetails)
|
||||
.catch(() => {
|
||||
/* échec silencieux : on garde le dernier état connu affiché */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Heading,
|
||||
HStack,
|
||||
Input,
|
||||
Link,
|
||||
Spacer,
|
||||
Spinner,
|
||||
Stack,
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api, ApiError } from '../../lib/api'
|
||||
import { api, ApiError, type Demo } from '../../lib/api'
|
||||
import { statusColor, statusLabel } from '../../lib/format'
|
||||
|
||||
export function Subscription() {
|
||||
const toast = useToast()
|
||||
@@ -28,6 +30,8 @@ export function Subscription() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [code, setCode] = useState('')
|
||||
const [showRenewForm, setShowRenewForm] = useState(false)
|
||||
const [demos, setDemos] = useState<Demo[]>([])
|
||||
const [demosLoading, setDemosLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -42,9 +46,21 @@ export function Subscription() {
|
||||
}
|
||||
}, [navigate, toast])
|
||||
|
||||
const loadDemos = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.listMyDemos()
|
||||
setDemos(res.items ?? [])
|
||||
} catch {
|
||||
// Silencieux : l'absence de démo n'est pas une erreur à afficher ici.
|
||||
} finally {
|
||||
setDemosLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
void loadDemos()
|
||||
}, [load, loadDemos])
|
||||
|
||||
const submitCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -76,6 +92,36 @@ export function Subscription() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex mb={6} align="center">
|
||||
<Heading size="md">Ma démo</Heading>
|
||||
<Spacer />
|
||||
</Flex>
|
||||
|
||||
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
|
||||
{demosLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : demos.length === 0 ? (
|
||||
<Text color="gray.500">Aucune démo pour le moment.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{demos.map((d) => (
|
||||
<HStack key={d.id} justify="space-between" flexWrap="wrap" rowGap={2}>
|
||||
{d.status === 'ready' ? (
|
||||
<Link href={d.url} color="primary.500" isExternal fontFamily="mono">
|
||||
{d.url}
|
||||
</Link>
|
||||
) : (
|
||||
<Text color="gray.400" fontFamily="mono">
|
||||
{d.url || '—'}
|
||||
</Text>
|
||||
)}
|
||||
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Flex mb={6} align="center">
|
||||
<Heading size="md">Mon abonnement</Heading>
|
||||
<Spacer />
|
||||
|
||||
Reference in New Issue
Block a user