Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e | |||
| e3c8232e38 | |||
| a4df9fc759 |
@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.0",
|
version="0.6.1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.0</version>
|
<version>0.6.1</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose la configuration publique consommee par le frontend au demarrage.
|
||||||
|
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
|
||||||
|
* masque alors Settings / Export VTT, et les endpoints sensibles sont
|
||||||
|
* verrouilles cote serveur (cf. SettingsController).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/config")
|
||||||
|
public class ConfigController {
|
||||||
|
|
||||||
|
private final boolean demoMode;
|
||||||
|
|
||||||
|
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||||
|
this.demoMode = demoMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Map<String, Object> getPublicConfig() {
|
||||||
|
return Map.of("demoMode", demoMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -32,20 +34,25 @@ public class SettingsController {
|
|||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final String brainBaseUrl;
|
private final String brainBaseUrl;
|
||||||
|
private final boolean demoMode;
|
||||||
|
|
||||||
public SettingsController(RestTemplate restTemplate,
|
public SettingsController(RestTemplate restTemplate,
|
||||||
@Value("${brain.base-url}") String brainBaseUrl) {
|
@Value("${brain.base-url}") String brainBaseUrl,
|
||||||
|
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
this.brainBaseUrl = brainBaseUrl;
|
this.brainBaseUrl = brainBaseUrl;
|
||||||
|
this.demoMode = demoMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<Map<String, Object>> getSettings() {
|
public ResponseEntity<Map<String, Object>> getSettings() {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.GET, "/settings", null);
|
return forward(HttpMethod.GET, "/settings", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
|
||||||
|
guardDemoMode();
|
||||||
return forward(HttpMethod.PUT, "/settings", patch);
|
return forward(HttpMethod.PUT, "/settings", patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +71,12 @@ public class SettingsController {
|
|||||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
return forward(HttpMethod.GET, "/models/onemin", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void guardDemoMode() {
|
||||||
|
if (demoMode) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ spring.jpa.show-sql=true
|
|||||||
spring.jpa.properties.hibernate.format_sql=true
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
|
||||||
# Configuration CORS pour autoriser le Frontend Angular
|
# Configuration CORS pour autoriser le Frontend Angular
|
||||||
spring.web.cors.allowed-origins=http://localhost:4200
|
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
|
||||||
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||||
spring.web.cors.allowed-headers=*
|
spring.web.cors.allowed-headers=*
|
||||||
spring.web.cors.allow-credentials=true
|
spring.web.cors.allow-credentials=true
|
||||||
|
|
||||||
# Configuration du Brain (service IA Python)
|
# Configuration du Brain (service IA Python)
|
||||||
brain.base-url=http://localhost:8000
|
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
|
||||||
brain.timeout-seconds=120
|
brain.timeout-seconds=120
|
||||||
|
|
||||||
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
|
||||||
@@ -50,3 +50,7 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
|
|||||||
# Limites d'upload d'images (MB)
|
# Limites d'upload d'images (MB)
|
||||||
spring.servlet.multipart.max-file-size=10MB
|
spring.servlet.multipart.max-file-size=10MB
|
||||||
spring.servlet.multipart.max-request-size=10MB
|
spring.servlet.multipart.max-request-size=10MB
|
||||||
|
|
||||||
|
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
|
||||||
|
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
|
||||||
|
app.demo-mode=${DEMO_MODE:false}
|
||||||
|
|||||||
27
demo/.env.example
Normal file
27
demo/.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Copie en .env sur le serveur (jamais commite).
|
||||||
|
|
||||||
|
# Registre et tag des images core / brain a spawner par session.
|
||||||
|
REGISTRY=git.igmlcreation.fr
|
||||||
|
TAG=latest
|
||||||
|
|
||||||
|
# Secret partage entre core et brain (genere aleatoirement au build de chaque
|
||||||
|
# session, mais un defaut est utile pour les checks de sante au boot).
|
||||||
|
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
|
||||||
|
|
||||||
|
# Capacite
|
||||||
|
MAX_SESSIONS=10
|
||||||
|
SESSION_TTL_MINUTES=20
|
||||||
|
|
||||||
|
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
|
|
||||||
|
# Limites par conteneur de session (Docker API)
|
||||||
|
CORE_MEMORY_MB=700
|
||||||
|
BRAIN_MEMORY_MB=300
|
||||||
|
POSTGRES_MEMORY_MB=200
|
||||||
|
|
||||||
|
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
|
||||||
|
TRAEFIK_NETWORK=traefik
|
||||||
|
|
||||||
|
# Domaine expose par Traefik
|
||||||
|
DEMO_HOST=loremind-demo.igmlcreation.fr
|
||||||
2
demo/.gitignore
vendored
Normal file
2
demo/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
46
demo/README.md
Normal file
46
demo/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Demo publique LoreMind
|
||||||
|
|
||||||
|
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
|
||||||
|
un court delai d'inactivite. Les donnees ne sont jamais persistees.
|
||||||
|
|
||||||
|
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
|
||||||
|
de configuration qui n'ont pas de sens en vitrine.
|
||||||
|
|
||||||
|
## Deploiement
|
||||||
|
|
||||||
|
Prerequis :
|
||||||
|
- Reseau Traefik existant cote host
|
||||||
|
- Images `core` et `brain` pushees au registre
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Ajuster .env
|
||||||
|
docker compose -f docker-compose.infra.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Premier build : 5-10 min. Suivants : incrementaux.
|
||||||
|
|
||||||
|
## Mise a jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.infra.yml pull
|
||||||
|
docker compose -f docker-compose.infra.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Les sessions en cours sont tuees au redemarrage.
|
||||||
|
|
||||||
|
## Observations
|
||||||
|
|
||||||
|
- `docker logs loremind-demo-orchestrator -f`
|
||||||
|
- `docker ps --filter "name=demo-"`
|
||||||
|
|
||||||
|
## Desactiver
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.infra.yml down
|
||||||
|
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
|
||||||
|
```
|
||||||
78
demo/docker-compose.infra.yml
Normal file
78
demo/docker-compose.infra.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ==========================================================================
|
||||||
|
# LoreMind Demo - Infra permanente
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur
|
||||||
|
# (lecture seule sauf containers/images/networks). Remplace le mount direct
|
||||||
|
# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant
|
||||||
|
# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon.
|
||||||
|
# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions.
|
||||||
|
#
|
||||||
|
# Les conteneurs de session sont crees dynamiquement par l'orchestrateur.
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
dockerproxy:
|
||||||
|
image: tecnativa/docker-socket-proxy:latest
|
||||||
|
container_name: loremind-demo-dockerproxy
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Minimum requis par l'orchestrateur.
|
||||||
|
CONTAINERS: 1
|
||||||
|
IMAGES: 1
|
||||||
|
NETWORKS: 1
|
||||||
|
POST: 1
|
||||||
|
# Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH,
|
||||||
|
# SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc.
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- socket-proxy
|
||||||
|
# Pas de ports exposes : accessible uniquement via le reseau socket-proxy.
|
||||||
|
|
||||||
|
orchestrator:
|
||||||
|
container_name: loremind-demo-orchestrator
|
||||||
|
depends_on:
|
||||||
|
- dockerproxy
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: demo/orchestrator/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# L'orchestrateur parle a dockerproxy au lieu du socket direct.
|
||||||
|
DOCKER_HOST: tcp://dockerproxy:2375
|
||||||
|
REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
|
||||||
|
TAG: ${TAG:-latest}
|
||||||
|
MAX_SESSIONS: ${MAX_SESSIONS:-10}
|
||||||
|
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20}
|
||||||
|
CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700}
|
||||||
|
BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300}
|
||||||
|
POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200}
|
||||||
|
SESSIONS_NETWORK: loremind-demo-sessions
|
||||||
|
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
|
||||||
|
# Rate limit : 1 creation par IP par fenetre (en secondes).
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- sessions
|
||||||
|
- socket-proxy
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)"
|
||||||
|
- "traefik.http.routers.loremind-demo.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.loremind-demo.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
name: ${TRAEFIK_NETWORK:-traefik}
|
||||||
|
sessions:
|
||||||
|
# Reseau interne pour les trios de session. Pas d'acces Internet direct
|
||||||
|
# (sauf via le DNS Docker), pas expose au host.
|
||||||
|
name: loremind-demo-sessions
|
||||||
|
driver: bridge
|
||||||
|
socket-proxy:
|
||||||
|
# Reseau prive entre dockerproxy et orchestrateur. Isole du reste.
|
||||||
|
name: loremind-demo-socket-proxy
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
32
demo/orchestrator/Dockerfile
Normal file
32
demo/orchestrator/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.6
|
||||||
|
# Build context attendu : racine du repo LoreMind.
|
||||||
|
# Appele depuis demo/docker-compose.infra.yml avec context: ../
|
||||||
|
|
||||||
|
# --- Etage 1 : build Angular statique ---
|
||||||
|
FROM node:20-alpine AS web-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ .
|
||||||
|
RUN npm run build -- --configuration production
|
||||||
|
|
||||||
|
# --- Etage 2 : build orchestrateur Go ---
|
||||||
|
# go 1.25+ requis par une dependance transitive de github.com/docker/docker
|
||||||
|
# (otelhttp v0.68+ impose cette version minimale).
|
||||||
|
FROM golang:1.25-alpine AS go-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY demo/orchestrator/ ./
|
||||||
|
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
|
||||||
|
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
|
||||||
|
|
||||||
|
# --- Etage final : runtime minimal ---
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=go-build /orchestrator /app/orchestrator
|
||||||
|
COPY --from=web-build /build/dist/web /app/static
|
||||||
|
COPY demo/orchestrator/preparing.html /app/preparing.html
|
||||||
|
EXPOSE 80
|
||||||
|
ENV STATIC_DIR=/app/static \
|
||||||
|
PREPARING_PAGE=/app/preparing.html
|
||||||
|
ENTRYPOINT ["/app/orchestrator"]
|
||||||
64
demo/orchestrator/config.go
Normal file
64
demo/orchestrator/config.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config centralise les parametres lus depuis les variables d'env au boot.
|
||||||
|
type Config struct {
|
||||||
|
Registry string
|
||||||
|
Tag string
|
||||||
|
MaxSessions int
|
||||||
|
SessionTTL time.Duration
|
||||||
|
CoreMemoryBytes int64
|
||||||
|
BrainMemoryBytes int64
|
||||||
|
PostgresMemoryBytes int64
|
||||||
|
SessionsNetwork string
|
||||||
|
BrainSecretDefault string
|
||||||
|
StaticDir string
|
||||||
|
PreparingPage string
|
||||||
|
RateLimitWindow time.Duration
|
||||||
|
MaxBodyBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
|
||||||
|
Tag: envStr("TAG", "latest"),
|
||||||
|
MaxSessions: envInt("MAX_SESSIONS", 10),
|
||||||
|
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
|
||||||
|
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
|
||||||
|
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
|
||||||
|
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
|
||||||
|
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
|
||||||
|
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
|
||||||
|
StaticDir: envStr("STATIC_DIR", "/app/static"),
|
||||||
|
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
|
||||||
|
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
|
||||||
|
// 10 Mo : aligne avec la limite d'upload d'image cote core.
|
||||||
|
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envStr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
i, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
335
demo/orchestrator/docker.go
Normal file
335
demo/orchestrator/docker.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy.
|
||||||
|
// Pas de SDK externe : evite les conflits de versions transitives qui
|
||||||
|
// rendaient github.com/docker/docker v27/v28 ininstallable proprement.
|
||||||
|
//
|
||||||
|
// L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy
|
||||||
|
// la supporte sans config supplementaire).
|
||||||
|
type DockerClient struct {
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDockerClient() (*DockerClient, error) {
|
||||||
|
base := os.Getenv("DOCKER_HOST")
|
||||||
|
if base == "" {
|
||||||
|
return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)")
|
||||||
|
}
|
||||||
|
// tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair).
|
||||||
|
base = strings.Replace(base, "tcp://", "http://", 1)
|
||||||
|
return &DockerClient{
|
||||||
|
baseURL: strings.TrimRight(base, "/") + "/v1.43",
|
||||||
|
http: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Types serialises vers l'API Engine ---
|
||||||
|
|
||||||
|
type containerSpec struct {
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Env []string `json:"Env,omitempty"`
|
||||||
|
Labels map[string]string `json:"Labels,omitempty"`
|
||||||
|
HostConfig hostConfig `json:"HostConfig"`
|
||||||
|
NetworkingConfig networkingConfig `json:"NetworkingConfig"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostConfig struct {
|
||||||
|
Memory int64 `json:"Memory,omitempty"`
|
||||||
|
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
|
||||||
|
PidsLimit int64 `json:"PidsLimit,omitempty"`
|
||||||
|
Tmpfs map[string]string `json:"Tmpfs,omitempty"`
|
||||||
|
SecurityOpt []string `json:"SecurityOpt,omitempty"`
|
||||||
|
RestartPolicy restartPolicy `json:"RestartPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type restartPolicy struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type networkingConfig struct {
|
||||||
|
EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointSettings struct {
|
||||||
|
Aliases []string `json:"Aliases,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec
|
||||||
|
// au moment d'envoyer la requete.
|
||||||
|
type runSpec struct {
|
||||||
|
Name string
|
||||||
|
Image string
|
||||||
|
Env []string
|
||||||
|
Labels map[string]string
|
||||||
|
Memory int64
|
||||||
|
Tmpfs map[string]string
|
||||||
|
Net string
|
||||||
|
Alias string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Operations de haut niveau ---
|
||||||
|
|
||||||
|
// SpawnTrio cree postgres + brain + core pour une session.
|
||||||
|
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
|
||||||
|
pgName := "demo-" + sessionID + "-postgres"
|
||||||
|
brainName := "demo-" + sessionID + "-brain"
|
||||||
|
coreName := "demo-" + sessionID + "-core"
|
||||||
|
pgPassword := randomHex(16)
|
||||||
|
brainSecret := randomHex(32)
|
||||||
|
adminPassword := randomHex(16)
|
||||||
|
|
||||||
|
labels := map[string]string{"demo-session": sessionID}
|
||||||
|
|
||||||
|
if err := d.runContainer(ctx, runSpec{
|
||||||
|
Name: pgName,
|
||||||
|
Image: "postgres:16-alpine",
|
||||||
|
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
|
||||||
|
Labels: copyLabels(labels, "postgres"),
|
||||||
|
Memory: cfg.PostgresMemoryBytes,
|
||||||
|
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
|
||||||
|
Net: cfg.SessionsNetwork,
|
||||||
|
Alias: pgName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("spawn postgres: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.runContainer(ctx, runSpec{
|
||||||
|
Name: brainName,
|
||||||
|
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
|
||||||
|
Env: []string{
|
||||||
|
"INTERNAL_SHARED_SECRET=" + brainSecret,
|
||||||
|
// Pas de provider LLM configure en demo : les features IA echoueront
|
||||||
|
// proprement, la demo sert principalement a explorer l'edition.
|
||||||
|
"LLM_PROVIDER=ollama",
|
||||||
|
"OLLAMA_BASE_URL=http://localhost:1",
|
||||||
|
},
|
||||||
|
Labels: copyLabels(labels, "brain"),
|
||||||
|
Memory: cfg.BrainMemoryBytes,
|
||||||
|
Net: cfg.SessionsNetwork,
|
||||||
|
Alias: brainName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("spawn brain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.runContainer(ctx, runSpec{
|
||||||
|
Name: coreName,
|
||||||
|
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
|
||||||
|
Env: []string{
|
||||||
|
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
|
||||||
|
"SPRING_DATASOURCE_USERNAME=loremind",
|
||||||
|
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
|
||||||
|
"BRAIN_BASE_URL=http://" + brainName + ":8000",
|
||||||
|
"BRAIN_INTERNAL_SECRET=" + brainSecret,
|
||||||
|
"ADMIN_USERNAME=admin",
|
||||||
|
"ADMIN_PASSWORD=" + adminPassword,
|
||||||
|
"DEMO_MODE=true",
|
||||||
|
"CORS_ALLOWED_ORIGINS=*",
|
||||||
|
},
|
||||||
|
Labels: copyLabels(labels, "core"),
|
||||||
|
Memory: cfg.CoreMemoryBytes,
|
||||||
|
Net: cfg.SessionsNetwork,
|
||||||
|
Alias: coreName,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("spawn core: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
|
||||||
|
// Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra.
|
||||||
|
_ = d.pullImage(ctx, s.Image)
|
||||||
|
|
||||||
|
spec := containerSpec{
|
||||||
|
Image: s.Image,
|
||||||
|
Env: s.Env,
|
||||||
|
Labels: s.Labels,
|
||||||
|
HostConfig: hostConfig{
|
||||||
|
Memory: s.Memory,
|
||||||
|
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
|
||||||
|
PidsLimit: 200, // anti fork-bomb
|
||||||
|
Tmpfs: s.Tmpfs,
|
||||||
|
SecurityOpt: []string{"no-new-privileges:true"},
|
||||||
|
RestartPolicy: restartPolicy{Name: "no"},
|
||||||
|
},
|
||||||
|
NetworkingConfig: networkingConfig{
|
||||||
|
EndpointsConfig: map[string]endpointSettings{
|
||||||
|
s.Net: {Aliases: []string{s.Alias}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(spec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResp, &created); err != nil {
|
||||||
|
return fmt.Errorf("parse create %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil {
|
||||||
|
return fmt.Errorf("start %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pullImage drain le flux de progression. Erreur silencieuse : si le pull
|
||||||
|
// echoue (registre prive sans auth, image deja locale), runContainer aura un
|
||||||
|
// retour clair via ContainerCreate.
|
||||||
|
func (d *DockerClient) pullImage(ctx context.Context, img string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||||
|
d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := d.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("pull %s: status %d", img, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout.
|
||||||
|
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
target := "http://demo-" + sessionID + "-core:8080/api/config"
|
||||||
|
c := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
resp, err := c.Get(target)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillTrio supprime tous les conteneurs labellises demo-session=<id>.
|
||||||
|
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
|
||||||
|
containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range containers {
|
||||||
|
_, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins.
|
||||||
|
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
|
||||||
|
containers, err := d.listContainersWithLabel(ctx, "demo-session")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range containers {
|
||||||
|
if v, ok := c.Labels["demo-session"]; ok && v != "" {
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for id := range seen {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerInfo struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) {
|
||||||
|
filters := map[string][]string{"label": {label}}
|
||||||
|
filtersJSON, _ := json.Marshal(filters)
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("all", "true")
|
||||||
|
q.Set("filters", string(filtersJSON))
|
||||||
|
body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []containerInfo
|
||||||
|
if err := json.Unmarshal(body, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie
|
||||||
|
// en erreur avec le contenu pour faciliter le debug.
|
||||||
|
func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
|
||||||
|
var rdr io.Reader
|
||||||
|
if body != nil {
|
||||||
|
rdr = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := d.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
out, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func copyLabels(base map[string]string, role string) map[string]string {
|
||||||
|
out := make(map[string]string, len(base)+1)
|
||||||
|
for k, v := range base {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
out["demo-role"] = role
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
8
demo/orchestrator/go.mod
Normal file
8
demo/orchestrator/go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/loremind/demo-orchestrator
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
// Aucune dependance externe : on parle a Docker Engine en HTTP brut
|
||||||
|
// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le
|
||||||
|
// graphe transitif est instable d'une version a l'autre (sockets.DialPipe,
|
||||||
|
// errors.As/Is, otelhttp...).
|
||||||
231
demo/orchestrator/main.go
Normal file
231
demo/orchestrator/main.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cookieName = "loremind-demo-session"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
docker, err := newDockerClient()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("docker init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := newManager(docker, cfg)
|
||||||
|
limiter := newRateLimiter(cfg.RateLimitWindow)
|
||||||
|
|
||||||
|
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
|
||||||
|
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
mgr.CleanupOrphans(cleanCtx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
go mgr.RunGC(context.Background())
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
|
||||||
|
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
|
||||||
|
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":80",
|
||||||
|
Handler: mux,
|
||||||
|
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
|
||||||
|
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 60 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Minute,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
// Headers max : 1 Mo (defaut Go), suffisant.
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
|
||||||
|
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
|
||||||
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
|
log.Fatalf("http server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
|
||||||
|
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
|
||||||
|
// et renvoie la page de preparation.
|
||||||
|
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := currentSession(r, mgr)
|
||||||
|
|
||||||
|
// Visiteur connu et session prete -> sert l'app normalement.
|
||||||
|
if sess != nil && sess.Status == StatusReady {
|
||||||
|
serveStatic(w, r, cfg.StaticDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
|
||||||
|
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
|
||||||
|
// de nouvelle session.
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "No session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !acceptsHTML(r) {
|
||||||
|
http.Error(w, "No session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
|
||||||
|
if sess == nil {
|
||||||
|
ip := clientIP(r)
|
||||||
|
if !limiter.Allow(ip) {
|
||||||
|
http.Error(w, "Trop de tentatives. Merci d'attendre "+
|
||||||
|
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
|
||||||
|
http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newSess, err := mgr.Create(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrCapacity) {
|
||||||
|
http.Error(w, "La demo est pleine (max "+
|
||||||
|
strconv.Itoa(cfg.MaxSessions)+
|
||||||
|
" sessions simultanees). Merci de reessayer plus tard.",
|
||||||
|
http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Impossible de creer la session : "+err.Error(),
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess = newSess
|
||||||
|
setCookie(w, sess.ID, cfg.SessionTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
servePreparingPage(w, cfg.PreparingPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiHandler proxifie /api/* vers le core de la session.
|
||||||
|
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
|
||||||
|
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := currentSession(r, mgr)
|
||||||
|
if sess == nil {
|
||||||
|
http.Error(w, "No session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sess.Status != StatusReady {
|
||||||
|
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Body != nil {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
|
||||||
|
}
|
||||||
|
proxy := sessionProxy(sess)
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
|
||||||
|
// session via sync.Once : garantit une seule creation meme sous requetes
|
||||||
|
// concurrentes, sans mutex explicite.
|
||||||
|
func sessionProxy(sess *Session) *httputil.ReverseProxy {
|
||||||
|
sess.proxyOnce.Do(func() {
|
||||||
|
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
|
||||||
|
p := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
log.Printf("proxy error session=%s: %v", sess.ID, err)
|
||||||
|
http.Error(w, "Upstream error", http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
sess.proxy = p
|
||||||
|
})
|
||||||
|
return sess.proxy.(*httputil.ReverseProxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
|
||||||
|
// N'expose aucun ID de session ni d'information sur les autres sessions.
|
||||||
|
func readyHandler(mgr *Manager) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := currentSession(r, mgr)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if sess == nil {
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": string(sess.Status),
|
||||||
|
"error": sess.Err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentSession lit le cookie et retrouve la session en memoire.
|
||||||
|
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
|
||||||
|
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
|
||||||
|
func currentSession(r *http.Request, mgr *Manager) *Session {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mgr.Get(c.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: id,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(ttl.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
|
||||||
|
// pour que le routeur cote client fonctionne (SPA).
|
||||||
|
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
|
||||||
|
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
|
||||||
|
reqPath := r.URL.Path
|
||||||
|
if reqPath == "/" || reqPath == "" {
|
||||||
|
reqPath = "/index.html"
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
|
||||||
|
if !strings.HasPrefix(fullPath, dir) {
|
||||||
|
http.Error(w, "bad path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||||
|
http.ServeFile(w, r, fullPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// servePreparingPage sert la page de chargement statique. Le cookie vient
|
||||||
|
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
|
||||||
|
// cookie pour poller /_demo/ready.
|
||||||
|
func servePreparingPage(w http.ResponseWriter, path string) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptsHTML(r *http.Request) bool {
|
||||||
|
accept := r.Header.Get("Accept")
|
||||||
|
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
|
||||||
|
}
|
||||||
127
demo/orchestrator/preparing.html
Normal file
127
demo/orchestrator/preparing.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LoreMind — Demo en preparation</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #1a1625;
|
||||||
|
color: #e4def5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #b794f4;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9880c4;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #aaa0c5;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
border: 3px solid rgba(183, 148, 244, 0.2);
|
||||||
|
border-top-color: #b794f4;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
display: none;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.error.visible { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="logo">✦ LoreMind</div>
|
||||||
|
<div class="subtitle">THE DIGITAL CODEX</div>
|
||||||
|
<h1>Preparation de votre demo…</h1>
|
||||||
|
<p>
|
||||||
|
Nous initialisons une instance isolee rien que pour vous.
|
||||||
|
Cela prend generalement 20 a 40 secondes.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
|
||||||
|
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
|
||||||
|
</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div id="err" class="error"></div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var errBox = document.getElementById('err');
|
||||||
|
var attempts = 0;
|
||||||
|
var maxAttempts = 90; // 90 * 2s = 3 min max
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
attempts++;
|
||||||
|
fetch('/_demo/ready', { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
|
||||||
|
errBox.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
|
||||||
|
errBox.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
|
||||||
|
errBox.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
demo/orchestrator/ratelimit.go
Normal file
72
demo/orchestrator/ratelimit.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
|
||||||
|
// Pas de token bucket : pour un endpoint de creation de session, "1 par
|
||||||
|
// fenetre" est largement suffisant et plus simple a raisonner.
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lastSeen map[string]time.Time
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(window time.Duration) *rateLimiter {
|
||||||
|
rl := &rateLimiter{
|
||||||
|
lastSeen: make(map[string]time.Time),
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
go rl.cleanupLoop()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
|
||||||
|
// Sur true, l'horloge de l'IP est reinitialisee.
|
||||||
|
func (rl *rateLimiter) Allow(ip string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rl.lastSeen[ip] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
|
||||||
|
// la croissance non bornee de la map sous trafic varie.
|
||||||
|
func (rl *rateLimiter) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
cutoff := time.Now().Add(-2 * rl.window)
|
||||||
|
rl.mu.Lock()
|
||||||
|
for ip, t := range rl.lastSeen {
|
||||||
|
if t.Before(cutoff) {
|
||||||
|
delete(rl.lastSeen, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For.
|
||||||
|
// Justification : Traefik APPEND l'IP du peer au header existant, donc la
|
||||||
|
// derniere valeur est celle que Traefik a observe directement (le vrai client).
|
||||||
|
// Prendre la premiere serait une faille : un attaquant peut preremplir le header.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
}
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
177
demo/orchestrator/sessions.go
Normal file
177
demo/orchestrator/sessions.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
|
||||||
|
type SessionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusStarting SessionStatus = "starting"
|
||||||
|
StatusReady SessionStatus = "ready"
|
||||||
|
StatusFailed SessionStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session represente une demo isolee pour un visiteur.
|
||||||
|
// CoreHost est le hostname Docker interne du conteneur core de cette session
|
||||||
|
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Status SessionStatus
|
||||||
|
CoreHost string
|
||||||
|
Err string
|
||||||
|
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
|
||||||
|
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
|
||||||
|
// session). proxy est typee any pour ne pas contraindre sessions.go a
|
||||||
|
// importer net/http/httputil.
|
||||||
|
proxy any
|
||||||
|
proxyOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
|
||||||
|
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
|
||||||
|
// handlers + goroutine de GC).
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*Session
|
||||||
|
docker *DockerClient
|
||||||
|
cfg *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManager(docker *DockerClient, cfg *Config) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
docker: docker,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
|
||||||
|
var ErrCapacity = errors.New("demo at capacity")
|
||||||
|
|
||||||
|
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
|
||||||
|
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
|
||||||
|
// les conteneurs sont up et que core repond a /api/config.
|
||||||
|
func (m *Manager) Create(ctx context.Context) (*Session, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if len(m.sessions) >= m.cfg.MaxSessions {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, ErrCapacity
|
||||||
|
}
|
||||||
|
id := newShortID()
|
||||||
|
sess := &Session{
|
||||||
|
ID: id,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
Status: StatusStarting,
|
||||||
|
CoreHost: "demo-" + id + "-core",
|
||||||
|
}
|
||||||
|
m.sessions[id] = sess
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
|
||||||
|
go func() {
|
||||||
|
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
|
||||||
|
log.Printf("session %s spawn failed: %v", id, err)
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Status = StatusFailed
|
||||||
|
sess.Err = err.Error()
|
||||||
|
m.mu.Unlock()
|
||||||
|
// Nettoyage best-effort des conteneurs partiellement crees.
|
||||||
|
_ = m.docker.KillTrio(context.Background(), id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
|
||||||
|
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Status = StatusReady
|
||||||
|
m.mu.Unlock()
|
||||||
|
log.Printf("session %s ready", id)
|
||||||
|
} else {
|
||||||
|
log.Printf("session %s never became ready", id)
|
||||||
|
m.mu.Lock()
|
||||||
|
sess.Status = StatusFailed
|
||||||
|
sess.Err = "timeout waiting for core"
|
||||||
|
m.mu.Unlock()
|
||||||
|
_ = m.docker.KillTrio(context.Background(), id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
|
||||||
|
func (m *Manager) Get(id string) *Session {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.sessions[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
|
||||||
|
// A lancer en goroutine au demarrage.
|
||||||
|
func (m *Manager) RunGC(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.gcOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) gcOnce() {
|
||||||
|
cutoff := time.Now().Add(-m.cfg.SessionTTL)
|
||||||
|
m.mu.Lock()
|
||||||
|
var expired []string
|
||||||
|
for id, s := range m.sessions {
|
||||||
|
if s.CreatedAt.Before(cutoff) {
|
||||||
|
expired = append(expired, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range expired {
|
||||||
|
delete(m.sessions, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range expired {
|
||||||
|
log.Printf("session %s expired, killing containers", id)
|
||||||
|
if err := m.docker.KillTrio(context.Background(), id); err != nil {
|
||||||
|
log.Printf("kill %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
|
||||||
|
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
|
||||||
|
func (m *Manager) CleanupOrphans(ctx context.Context) {
|
||||||
|
ids, err := m.docker.ListSessionIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("list orphans: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
log.Printf("cleaning orphan session %s", id)
|
||||||
|
_ = m.docker.KillTrio(ctx, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
|
||||||
|
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
|
||||||
|
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
|
||||||
|
func newShortID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "web:build"
|
"buildTarget": "web:build",
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.0",
|
"version": "0.6.1",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
8
web/proxy.conf.json
Normal file
8
web/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8080",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"logLevel": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { hiddenInDemoGuard } from './guards/demo-mode.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
|
||||||
@@ -30,6 +31,8 @@ export const routes: Routes = [
|
|||||||
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
|
||||||
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||||
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
|
||||||
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
|
// Routes masquees en mode demo : ajouter canActivate: [hiddenInDemoGuard]
|
||||||
|
// (a prevoir aussi sur la future route d'export VTT).
|
||||||
|
{ path: 'settings', canActivate: [hiddenInDemoGuard], loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
|
||||||
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
@@ -33,6 +34,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -50,7 +52,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.existingArcCount = treeData.arcs.length;
|
this.existingArcCount = treeData.arcs.length;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -68,6 +69,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -105,7 +107,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
arc: this.campaignService.getArcById(this.arcId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -42,6 +43,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -64,7 +66,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
arc: this.campaignService.getArcById(this.arcId),
|
arc: this.campaignService.getArcById(this.arcId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="detail-section characters-section">
|
<section class="detail-section characters-section" *ngIf="!editing">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Personnages joueurs</h2>
|
<h2>Personnages joueurs</h2>
|
||||||
<button class="btn-add" (click)="createCharacter()">
|
<button class="btn-add" (click)="createCharacter()">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="detail-section arcs-section">
|
<section class="detail-section arcs-section" *ngIf="!editing">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Arcs narratifs</h2>
|
<h2>Arcs narratifs</h2>
|
||||||
<button class="btn-add" (click)="createArc()">
|
<button class="btn-add" (click)="createArc()">
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(id => forkJoin({
|
switchMap(id => forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||||
)
|
)
|
||||||
}))
|
}))
|
||||||
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
@@ -111,8 +111,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
|
||||||
)
|
)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
this.campaign = campaign;
|
this.campaign = campaign;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Observable, forkJoin, of } from 'rxjs';
|
import { Observable, forkJoin, of } from 'rxjs';
|
||||||
import { switchMap, map } from 'rxjs/operators';
|
import { switchMap, map } from 'rxjs/operators';
|
||||||
import { CampaignService } from '../services/campaign.service';
|
import { CampaignService } from '../services/campaign.service';
|
||||||
|
import { CharacterService } from '../services/character.service';
|
||||||
import { TreeItem } from '../services/layout.service';
|
import { TreeItem } from '../services/layout.service';
|
||||||
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
import { Arc, Chapter, Scene } from '../services/campaign.model';
|
||||||
|
import { Character } from '../services/character.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
|
||||||
@@ -16,16 +18,21 @@ export interface CampaignTreeData {
|
|||||||
arcs: Arc[];
|
arcs: Arc[];
|
||||||
chaptersByArc: Record<string, Chapter[]>;
|
chaptersByArc: Record<string, Chapter[]>;
|
||||||
scenesByChapter: Record<string, Scene[]>;
|
scenesByChapter: Record<string, Scene[]>;
|
||||||
|
characters: Character[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCampaignTreeData(
|
export function loadCampaignTreeData(
|
||||||
service: CampaignService,
|
service: CampaignService,
|
||||||
campaignId: string
|
campaignId: string,
|
||||||
|
characterService: CharacterService
|
||||||
): Observable<CampaignTreeData> {
|
): Observable<CampaignTreeData> {
|
||||||
return service.getArcs(campaignId).pipe(
|
return forkJoin({
|
||||||
switchMap(arcs => {
|
arcs: service.getArcs(campaignId),
|
||||||
|
characters: characterService.getByCampaign(campaignId)
|
||||||
|
}).pipe(
|
||||||
|
switchMap(({ arcs, characters }) => {
|
||||||
if (arcs.length === 0) {
|
if (arcs.length === 0) {
|
||||||
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
|
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
|
||||||
}
|
}
|
||||||
const chapterCalls = arcs.map(a =>
|
const chapterCalls = arcs.map(a =>
|
||||||
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
|
||||||
@@ -40,7 +47,7 @@ export function loadCampaignTreeData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allChapters.length === 0) {
|
if (allChapters.length === 0) {
|
||||||
return of({ arcs, chaptersByArc, scenesByChapter: {} });
|
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
|
||||||
}
|
}
|
||||||
const sceneCalls = allChapters.map(c =>
|
const sceneCalls = allChapters.map(c =>
|
||||||
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
|
||||||
@@ -49,7 +56,7 @@ export function loadCampaignTreeData(
|
|||||||
map(sceneResults => {
|
map(sceneResults => {
|
||||||
const scenesByChapter: Record<string, Scene[]> = {};
|
const scenesByChapter: Record<string, Scene[]> = {};
|
||||||
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
|
||||||
return { arcs, chaptersByArc, scenesByChapter };
|
return { arcs, chaptersByArc, scenesByChapter, characters };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -67,9 +74,33 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
|
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
|
||||||
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
|
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
|
||||||
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
|
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
|
||||||
|
const sortedCharacters = [...data.characters].sort(byName);
|
||||||
|
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
|
||||||
|
id: `character-${ch.id}`,
|
||||||
|
label: ch.name,
|
||||||
|
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const charactersNode: TreeItem = {
|
||||||
|
id: 'characters-root',
|
||||||
|
label: 'Personnages',
|
||||||
|
iconKey: 'users',
|
||||||
|
children: characterItems,
|
||||||
|
meta: characterItems.length ? String(characterItems.length) : undefined,
|
||||||
|
sectionHeaderBefore: 'Personnages',
|
||||||
|
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
|
||||||
|
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
|
||||||
|
createActions: [{
|
||||||
|
id: 'new-character',
|
||||||
|
label: 'Nouveau PJ',
|
||||||
|
route: `/campaigns/${campaignId}/characters/create`,
|
||||||
|
actionIcon: 'plus'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
const sortedArcs = [...data.arcs].sort(byName);
|
const sortedArcs = [...data.arcs].sort(byName);
|
||||||
|
|
||||||
return sortedArcs.map(arc => {
|
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
|
||||||
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
|
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
|
||||||
|
|
||||||
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
|
||||||
@@ -98,6 +129,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
label: arc.name,
|
label: arc.name,
|
||||||
children: chapterItems,
|
children: chapterItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||||
|
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
|
||||||
|
|
||||||
createActions: [{
|
createActions: [{
|
||||||
id: `new-chapter-${arc.id}`,
|
id: `new-chapter-${arc.id}`,
|
||||||
label: 'Nouveau chapitre',
|
label: 'Nouveau chapitre',
|
||||||
@@ -106,4 +139,6 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return [...arcNodes, charactersNode];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule } from 'lucide-angular';
|
import { LucideAngularModule } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
@@ -32,6 +33,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -50,7 +52,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
|
||||||
this.arcName = currentArc?.name ?? '';
|
this.arcName = currentArc?.name ?? '';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -61,6 +62,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -98,7 +100,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
||||||
@@ -48,6 +49,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
) {}
|
) {}
|
||||||
@@ -67,7 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
scenes: this.campaignService.getScenes(this.chapterId),
|
scenes: this.campaignService.getScenes(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
|
||||||
this.chapter = chapter;
|
this.chapter = chapter;
|
||||||
this.scenes = scenes;
|
this.scenes = scenes;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -41,6 +42,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -67,7 +69,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
chapter: this.campaignService.getChapterById(this.chapterId),
|
chapter: this.campaignService.getChapterById(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule } from 'lucide-angular';
|
import { LucideAngularModule } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
@@ -33,6 +34,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private layoutService: LayoutService
|
private layoutService: LayoutService
|
||||||
) {
|
) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -52,7 +54,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||||
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
|
||||||
this.chapterName = currentChapter?.name ?? '';
|
this.chapterName = currentChapter?.name ?? '';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -65,6 +66,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -116,7 +118,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
scene: this.campaignService.getSceneById(this.sceneId),
|
scene: this.campaignService.getSceneById(this.sceneId),
|
||||||
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
chapterScenes: this.campaignService.getScenes(this.chapterId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { forkJoin, of } from 'rxjs';
|
|||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
@@ -41,6 +42,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private campaignService: CampaignService,
|
private campaignService: CampaignService,
|
||||||
|
private characterService: CharacterService,
|
||||||
private pageService: PageService,
|
private pageService: PageService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private pageTitleService: PageTitleService
|
private pageTitleService: PageTitleService
|
||||||
@@ -70,7 +72,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
campaign: this.campaignService.getCampaignById(this.campaignId),
|
campaign: this.campaignService.getCampaignById(this.campaignId),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
scene: this.campaignService.getSceneById(this.sceneId),
|
scene: this.campaignService.getSceneById(this.sceneId),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
|
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
switchMap(data => {
|
switchMap(data => {
|
||||||
const lid = data.campaign.loreId ?? null;
|
const lid = data.campaign.loreId ?? null;
|
||||||
|
|||||||
17
web/src/app/guards/demo-mode.guard.ts
Normal file
17
web/src/app/guards/demo-mode.guard.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { ConfigService } from '../services/config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloque l'acces aux routes sensibles quand demoMode est actif et redirige
|
||||||
|
* vers la home. Defense UX ; le verrou serveur reste la source de verite.
|
||||||
|
*/
|
||||||
|
export const hiddenInDemoGuard: CanActivateFn = () => {
|
||||||
|
const config = inject(ConfigService);
|
||||||
|
const router = inject(Router);
|
||||||
|
if (config.demoMode) {
|
||||||
|
router.navigate(['/']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ Grille des dossiers racine ============ -->
|
<!-- ============ Grille des dossiers racine ============ -->
|
||||||
<section class="detail-section nodes-section">
|
<section class="detail-section nodes-section" *ngIf="!editing">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Dossiers</h2>
|
<h2>Dossiers</h2>
|
||||||
<button class="btn-add" (click)="navigateToCreateNode()">
|
<button class="btn-add" (click)="navigateToCreateNode()">
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AiChatService {
|
export class AiChatService {
|
||||||
private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream';
|
private readonly loreEndpoint = '/api/ai/chat/stream';
|
||||||
private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign';
|
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
|
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface ChapterDeletionImpact {
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class CampaignService {
|
export class CampaignService {
|
||||||
private apiUrl = 'http://localhost:8080/api/campaigns';
|
private apiUrl = '/api/campaigns';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
@@ -60,73 +60,73 @@ export class CampaignService {
|
|||||||
|
|
||||||
// ========== ARC ==========
|
// ========== ARC ==========
|
||||||
getArcs(campaignId: string): Observable<Arc[]> {
|
getArcs(campaignId: string): Observable<Arc[]> {
|
||||||
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
return this.http.get<Arc[]>(`/api/arcs/campaign/${campaignId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getArcById(id: string): Observable<Arc> {
|
getArcById(id: string): Observable<Arc> {
|
||||||
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
|
return this.http.get<Arc>(`/api/arcs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createArc(payload: ArcCreate): Observable<Arc> {
|
createArc(payload: ArcCreate): Observable<Arc> {
|
||||||
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
|
return this.http.post<Arc>('/api/arcs', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
|
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
|
||||||
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
|
return this.http.put<Arc>(`/api/arcs/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteArc(id: string): Observable<void> {
|
deleteArc(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
|
return this.http.delete<void>(`/api/arcs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
|
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
|
||||||
return this.http.get<ArcDeletionImpact>(`http://localhost:8080/api/arcs/${id}/deletion-impact`);
|
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== CHAPTER ==========
|
// ========== CHAPTER ==========
|
||||||
getChapters(arcId: string): Observable<Chapter[]> {
|
getChapters(arcId: string): Observable<Chapter[]> {
|
||||||
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
|
return this.http.get<Chapter[]>(`/api/chapters/arc/${arcId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getChapterById(id: string): Observable<Chapter> {
|
getChapterById(id: string): Observable<Chapter> {
|
||||||
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
|
return this.http.get<Chapter>(`/api/chapters/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createChapter(payload: ChapterCreate): Observable<Chapter> {
|
createChapter(payload: ChapterCreate): Observable<Chapter> {
|
||||||
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
|
return this.http.post<Chapter>('/api/chapters', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
|
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
|
||||||
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
|
return this.http.put<Chapter>(`/api/chapters/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteChapter(id: string): Observable<void> {
|
deleteChapter(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
|
return this.http.delete<void>(`/api/chapters/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
|
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
|
||||||
return this.http.get<ChapterDeletionImpact>(`http://localhost:8080/api/chapters/${id}/deletion-impact`);
|
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== SCENE ==========
|
// ========== SCENE ==========
|
||||||
getScenes(chapterId: string): Observable<Scene[]> {
|
getScenes(chapterId: string): Observable<Scene[]> {
|
||||||
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
|
return this.http.get<Scene[]>(`/api/scenes/chapter/${chapterId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSceneById(id: string): Observable<Scene> {
|
getSceneById(id: string): Observable<Scene> {
|
||||||
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
|
return this.http.get<Scene>(`/api/scenes/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createScene(payload: SceneCreate): Observable<Scene> {
|
createScene(payload: SceneCreate): Observable<Scene> {
|
||||||
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
|
return this.http.post<Scene>('/api/scenes', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
|
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
|
||||||
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
|
return this.http.put<Scene>(`/api/scenes/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteScene(id: string): Observable<void> {
|
deleteScene(id: string): Observable<void> {
|
||||||
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
|
return this.http.delete<void>(`/api/scenes/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
search(q: string): Observable<Campaign[]> {
|
search(q: string): Observable<Campaign[]> {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Character, CharacterCreate } from './character.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CharacterService {
|
export class CharacterService {
|
||||||
private apiUrl = 'http://localhost:8080/api/characters';
|
private apiUrl = '/api/characters';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
31
web/src/app/services/config.service.ts
Normal file
31
web/src/app/services/config.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration publique chargee une seule fois au demarrage via APP_INITIALIZER.
|
||||||
|
* Le flag demoMode bascule l'UI en mode vitrine (Settings/Export masques).
|
||||||
|
*/
|
||||||
|
export interface PublicConfig {
|
||||||
|
demoMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ConfigService {
|
||||||
|
private config: PublicConfig = { demoMode: false };
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.config = await firstValueFrom(this.http.get<PublicConfig>('/api/config'));
|
||||||
|
} catch {
|
||||||
|
// Si l'endpoint n'est pas joignable au boot, on reste sur le default
|
||||||
|
// (demoMode=false) pour ne pas bloquer l'app en dev.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get demoMode(): boolean {
|
||||||
|
return this.config.demoMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ConversationService {
|
export class ConversationService {
|
||||||
private readonly apiUrl = 'http://localhost:8080/api/conversations';
|
private readonly apiUrl = '/api/conversations';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { GameSystem, GameSystemCreate } from './game-system.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class GameSystemService {
|
export class GameSystemService {
|
||||||
private apiUrl = 'http://localhost:8080/api/game-systems';
|
private apiUrl = '/api/game-systems';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { Image } from './image.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ImageService {
|
export class ImageService {
|
||||||
/** Base absolue du backend — utile pour construire des URLs complètes (<img src>). */
|
/** Base du backend (vide = même origine que le front, résolue par le navigateur). */
|
||||||
readonly apiBase = 'http://localhost:8080';
|
readonly apiBase = '';
|
||||||
private apiUrl = `${this.apiBase}/api/images`;
|
private apiUrl = `${this.apiBase}/api/images`;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export interface TreeItem {
|
|||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
|
||||||
meta?: string;
|
meta?: string;
|
||||||
|
/**
|
||||||
|
* Libellé de section affiché AU-DESSUS du nœud, avec un filet de séparation.
|
||||||
|
* Utilisé pour grouper visuellement des nœuds racines (ex: "Personnages" vs "Narration").
|
||||||
|
*/
|
||||||
|
sectionHeaderBefore?: string;
|
||||||
/**
|
/**
|
||||||
* Actions de creation contextuelles (ex: "+ Nouveau chapitre" sur un arc).
|
* Actions de creation contextuelles (ex: "+ Nouveau chapitre" sur un arc).
|
||||||
* Affichees comme boutons icone au survol du noeud (repli visuel), et en
|
* Affichees comme boutons icone au survol du noeud (repli visuel), et en
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export interface LoreDeletionImpact {
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class LoreService {
|
export class LoreService {
|
||||||
private apiUrl = 'http://localhost:8080/api/lores';
|
private apiUrl = '/api/lores';
|
||||||
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
|
private nodesUrl = '/api/lore-nodes';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Page, PageCreate } from './page.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PageService {
|
export class PageService {
|
||||||
private apiUrl = 'http://localhost:8080/api/pages';
|
private apiUrl = '/api/pages';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export interface OllamaModelInfo {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
private readonly apiUrl = 'http://localhost:8080/api/settings';
|
private readonly apiUrl = '/api/settings';
|
||||||
|
|
||||||
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
|
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
|
||||||
// withCredentials=true pour que les creds soient renvoyees sur les appels
|
// withCredentials=true pour que les creds soient renvoyees sur les appels
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Template, TemplateCreate } from './template.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
private apiUrl = 'http://localhost:8080/api/templates';
|
private apiUrl = '/api/templates';
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
|
|
||||||
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
||||||
<ng-template #treeNode let-item let-level="level">
|
<ng-template #treeNode let-item let-level="level">
|
||||||
|
<div class="tree-section-header" *ngIf="level === 0 && item.sectionHeaderBefore">
|
||||||
|
{{ item.sectionHeaderBefore }}
|
||||||
|
</div>
|
||||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||||
<div class="tree-row">
|
<div class="tree-row">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -110,6 +110,23 @@
|
|||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
|
||||||
|
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
|
||||||
|
// (le titre suffit) — on cible ça via :not(:first-child).
|
||||||
|
.tree-section-header {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 0.6rem 0.5rem 0.3rem;
|
||||||
|
}
|
||||||
|
.tree > .tree-section-header:not(:first-child) {
|
||||||
|
border-top: 1px solid #374151;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-btn {
|
.tree-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
|
||||||
<span>Systèmes de JDR</span>
|
<span>Systèmes de JDR</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn">
|
<button class="tool-btn" *ngIf="!config.demoMode">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
<span>Export VTT</span>
|
<span>Export VTT</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
|
<button class="tool-btn" *ngIf="!config.demoMode" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
|
||||||
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
|
||||||
<span>Paramètres</span>
|
<span>Paramètres</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
|||||||
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
|
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
|
||||||
import { LayoutService } from '../services/layout.service';
|
import { LayoutService } from '../services/layout.service';
|
||||||
import { GlobalSearchService } from '../services/global-search.service';
|
import { GlobalSearchService } from '../services/global-search.service';
|
||||||
|
import { ConfigService } from '../services/config.service';
|
||||||
// Single source of truth pour la version affichée dans le footer :
|
// Single source of truth pour la version affichée dans le footer :
|
||||||
// on lit directement package.json à la compilation (resolveJsonModule).
|
// on lit directement package.json à la compilation (resolveJsonModule).
|
||||||
import packageJson from '../../../package.json';
|
import packageJson from '../../../package.json';
|
||||||
@@ -30,7 +31,8 @@ export class SidebarComponent {
|
|||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private globalSearch: GlobalSearchService
|
private globalSearch: GlobalSearchService,
|
||||||
|
public config: ConfigService
|
||||||
) {
|
) {
|
||||||
this.router.events.subscribe(() => {
|
this.router.events.subscribe(() => {
|
||||||
this.currentRoute = this.router.url;
|
this.currentRoute = this.router.url;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AppComponent } from './app/app.component';
|
|||||||
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
|
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
|
||||||
import { routes } from './app/app.routes';
|
import { routes } from './app/app.routes';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { APP_INITIALIZER } from '@angular/core';
|
||||||
|
import { ConfigService } from './app/services/config.service';
|
||||||
|
|
||||||
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
||||||
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
|
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
|
||||||
@@ -13,5 +15,11 @@ bootstrapApplication(AppComponent, {
|
|||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes, withPreloading(PreloadAllModules)),
|
provideRouter(routes, withPreloading(PreloadAllModules)),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (config: ConfigService) => () => config.load(),
|
||||||
|
deps: [ConfigService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).catch((err: Error) => console.error(err));
|
}).catch((err: Error) => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user