Compare commits
2 Commits
0cd99dfb32
...
759e47fc1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 759e47fc1f | |||
| f71bf3fcad |
@@ -85,3 +85,57 @@ jobs:
|
|||||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
# Job separe pour le sidecar `switcher`.
|
||||||
|
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||||
|
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||||
|
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||||
|
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||||
|
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||||
|
build-switcher:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Detect channel
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
if [[ "${VERSION}" == *-beta* ]]; then
|
||||||
|
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push switcher (stable only)
|
||||||
|
if: steps.meta.outputs.channel == 'stable'
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./switcher
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||||
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
@@ -41,7 +41,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.8.4-beta",
|
version="0.8.5",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.8.4-beta</version>
|
<version>0.8.5</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.loremind.application.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||||
|
*
|
||||||
|
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||||
|
* dans un volume partage. Quand on depose une commande, il :
|
||||||
|
* <ol>
|
||||||
|
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||||
|
* <li>Lance docker compose pull + up -d</li>
|
||||||
|
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||||
|
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||||
|
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||||
|
* (channel ∈ {stable, beta} uniquement).
|
||||||
|
*
|
||||||
|
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||||
|
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||||
|
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChannelSwitcherService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||||
|
|
||||||
|
public enum Channel { STABLE, BETA }
|
||||||
|
|
||||||
|
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||||
|
|
||||||
|
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record SwitchResult(
|
||||||
|
String id,
|
||||||
|
SwitchStatus status,
|
||||||
|
Channel channel,
|
||||||
|
String message,
|
||||||
|
Instant completedAt) {}
|
||||||
|
|
||||||
|
private final Path switcherDataPath;
|
||||||
|
private final String imageNamespace;
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
public ChannelSwitcherService(
|
||||||
|
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||||
|
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||||
|
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||||
|
// qui peut etre absente dans les .env legacy).
|
||||||
|
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||||
|
this.switcherDataPath = Path.of(switcherDataPath);
|
||||||
|
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||||
|
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||||
|
switcherDataPath, this.imageNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||||
|
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||||
|
*/
|
||||||
|
public Channel getCurrentChannel() {
|
||||||
|
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le sidecar est disponible (volume partage accessible).
|
||||||
|
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||||
|
* avec instructions manuelles).
|
||||||
|
*/
|
||||||
|
public boolean isSwitcherAvailable() {
|
||||||
|
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||||
|
* de la commande, que le client peut utiliser pour poller le status.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||||
|
* @throws IOException si l'ecriture du fichier echoue
|
||||||
|
*/
|
||||||
|
public String requestSwitch(Channel target) throws IOException {
|
||||||
|
if (!isSwitcherAvailable()) {
|
||||||
|
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||||
|
}
|
||||||
|
String id = UUID.randomUUID().toString();
|
||||||
|
Map<String, Object> command = new LinkedHashMap<>();
|
||||||
|
command.put("id", id);
|
||||||
|
command.put("channel", target.name().toLowerCase());
|
||||||
|
command.put("requestedAt", Instant.now().toString());
|
||||||
|
|
||||||
|
Path commandFile = switcherDataPath.resolve("command.json");
|
||||||
|
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||||
|
try {
|
||||||
|
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||||
|
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||||
|
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} finally {
|
||||||
|
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||||
|
Files.deleteIfExists(tmp);
|
||||||
|
}
|
||||||
|
log.info("Switch command written: id={} channel={}", id, target);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||||
|
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||||
|
*/
|
||||||
|
public SwitchResult getLastResult() {
|
||||||
|
Path resultFile = switcherDataPath.resolve("result.json");
|
||||||
|
if (!Files.exists(resultFile)) return null;
|
||||||
|
try {
|
||||||
|
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.loremind.infrastructure.web;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepteur global d'exceptions pour TOUS les @RestController.
|
||||||
|
*
|
||||||
|
* <p>Role :
|
||||||
|
* <ul>
|
||||||
|
* <li>Logger systematiquement les exceptions non gerees (avec stack trace + path)
|
||||||
|
* — evite d'avoir a creuser dans les logs Docker apres coup.</li>
|
||||||
|
* <li>Renvoyer un JSON propre au client (`{error, type, ...}`) au lieu du 500 nu
|
||||||
|
* par defaut de Spring — utile pour debug cote frontend (visible directement
|
||||||
|
* dans la DevTools reseau).</li>
|
||||||
|
* <li>Mapper les exceptions courantes vers des status HTTP appropries
|
||||||
|
* (IllegalArgumentException -> 400, EntityNotFoundException -> 404).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Important : ne court-circuite PAS les try/catch locaux des controllers
|
||||||
|
* (ex: LicenseController.install catche InstallException -> 400 lui-meme).
|
||||||
|
* Ce handler n'attrape QUE ce qui a echappe au catch local.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Violation d'invariant domaine (doublons, valeurs invalides, etc.) -> 400.
|
||||||
|
* Concentre ici la logique qui etait dupliquee dans GameSystemController.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entite JPA introuvable -> 404. */
|
||||||
|
@ExceptionHandler(EntityNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleNotFound(EntityNotFoundException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", safeMessage(ex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON malforme dans le body de la requete -> 400. */
|
||||||
|
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnreadable(HttpMessageNotReadableException ex) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Malformed request body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation @Valid echouee -> 400 avec liste des erreurs par champ. */
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
ex.getBindingResult().getFieldErrors().forEach(e ->
|
||||||
|
fields.put(e.getField(), e.getDefaultMessage() != null ? e.getDefaultMessage() : "invalid"));
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "Validation failed",
|
||||||
|
"fields", fields
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
|
||||||
|
* un log ERROR explicite (path + stack trace) et un body JSON debuggable
|
||||||
|
* cote client. C'est LE filet de securite.
|
||||||
|
*
|
||||||
|
* Note : on attrape Throwable (pas Exception) pour aussi capturer les
|
||||||
|
* Error (NoClassDefFoundError, OutOfMemoryError... — cf. incident Tink).
|
||||||
|
* On NE swallow PAS — on log AVANT de renvoyer une reponse.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Throwable.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleUnexpected(HttpServletRequest request, Throwable ex) {
|
||||||
|
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
|
||||||
|
Map<String, String> body = new LinkedHashMap<>();
|
||||||
|
body.put("error", "Internal server error");
|
||||||
|
body.put("type", ex.getClass().getSimpleName());
|
||||||
|
String msg = safeMessage(ex);
|
||||||
|
if (!msg.isEmpty()) body.put("message", msg);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evite les NPE quand getMessage() est null sur certaines exceptions. */
|
||||||
|
private static String safeMessage(Throwable ex) {
|
||||||
|
return ex.getMessage() != null ? ex.getMessage() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
|||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@@ -72,12 +71,6 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
|
||||||
@ExceptionHandler(IllegalArgumentException.class)
|
|
||||||
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
import com.loremind.application.licensing.LicenseService;
|
import com.loremind.application.licensing.LicenseService;
|
||||||
import com.loremind.application.licensing.LicenseService.InstallException;
|
import com.loremind.application.licensing.LicenseService.InstallException;
|
||||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
|
||||||
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,9 +30,11 @@ import java.util.Map;
|
|||||||
public class LicenseController {
|
public class LicenseController {
|
||||||
|
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
private final ChannelSwitcherService channelSwitcher;
|
||||||
|
|
||||||
public LicenseController(LicenseService licenseService) {
|
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
|
this.channelSwitcher = channelSwitcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -82,6 +88,68 @@ public class LicenseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bascule de canal (stable <-> beta) via sidecar switcher ────────────
|
||||||
|
//
|
||||||
|
// Le flux :
|
||||||
|
// 1. UI POST /api/license/channel/switch { channel: "beta" }
|
||||||
|
// 2. Core valide la licence (refus si target=beta sans Patreon actif)
|
||||||
|
// 3. Core depose une commande dans le volume partage
|
||||||
|
// 4. Sidecar `switcher` la traite (sed .env, docker compose up -d)
|
||||||
|
// 5. UI poll GET /api/license/channel pour suivre le status
|
||||||
|
|
||||||
|
/** Etat courant : canal actuel + dispo du sidecar + dernier resultat. */
|
||||||
|
@GetMapping("/channel")
|
||||||
|
public ChannelStatusDTO getChannel() {
|
||||||
|
return ChannelStatusDTO.from(channelSwitcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche un switch de canal. Renvoie l'ID de la commande pour le polling. */
|
||||||
|
@PostMapping("/channel/switch")
|
||||||
|
public ResponseEntity<?> switchChannel(@RequestBody ChannelSwitchRequest request) {
|
||||||
|
if (request == null || request.channel() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "missing channel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ChannelSwitcherService.Channel target;
|
||||||
|
try {
|
||||||
|
target = ChannelSwitcherService.Channel.valueOf(request.channel().toUpperCase(Locale.ROOT));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "invalid channel (allowed: stable, beta)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde : pas de switch vers beta sans licence Patreon valide.
|
||||||
|
// Le switcher ferait le boulot quoi qu'il arrive (il valide juste le
|
||||||
|
// format), donc c'est ici qu'on doit refuser cote metier.
|
||||||
|
// VALID + GRACE autorisent l'acces beta (cf. javadoc de LicenseStatus).
|
||||||
|
if (target == ChannelSwitcherService.Channel.BETA) {
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
com.loremind.domain.licensing.LicenseStatus s = (snap != null) ? snap.status() : null;
|
||||||
|
boolean allowed = s == com.loremind.domain.licensing.LicenseStatus.VALID
|
||||||
|
|| s == com.loremind.domain.licensing.LicenseStatus.GRACE;
|
||||||
|
if (!allowed) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of(
|
||||||
|
"error", "Aucune licence Patreon active — impossible de basculer sur le canal beta."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelSwitcher.isSwitcherAvailable()) {
|
||||||
|
return ResponseEntity.status(503).body(Map.of(
|
||||||
|
"error", "Sidecar switcher non disponible (mise a jour requise du docker-compose.yml)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String id = channelSwitcher.requestSwitch(target);
|
||||||
|
return ResponseEntity.accepted().body(Map.of(
|
||||||
|
"id", id,
|
||||||
|
"channel", target.name().toLowerCase(Locale.ROOT)));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"error", "Impossible d'ecrire la commande de switch: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record InstallRequest(String jwt) {}
|
public record InstallRequest(String jwt) {}
|
||||||
public record BetaChannelRequest(boolean enabled) {}
|
public record BetaChannelRequest(boolean enabled) {}
|
||||||
|
public record ChannelSwitchRequest(String channel) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.loremind.application.licensing.ChannelSwitcherService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat du canal courant + dernier resultat de switch.
|
||||||
|
*
|
||||||
|
* <p>{@code currentChannel} : detecte au demarrage de Core a partir du prefixe
|
||||||
|
* d'image. {@code switcherAvailable} : indique si le sidecar de switch est
|
||||||
|
* monte (V0.9+) ou si on est sur une vieille install qui doit encore passer
|
||||||
|
* par les instructions manuelles.
|
||||||
|
*
|
||||||
|
* <p>{@code lastSwitch} : null tant qu'aucun switch n'a ete tente sur cette
|
||||||
|
* instance. Sinon, contient le resultat du dernier appel (en cours / succes /
|
||||||
|
* erreur), utilise par l'UI pour suivre la progression apres clic.
|
||||||
|
*/
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ChannelStatusDTO(
|
||||||
|
String currentChannel,
|
||||||
|
boolean switcherAvailable,
|
||||||
|
ChannelSwitcherService.SwitchResult lastSwitch) {
|
||||||
|
|
||||||
|
public static ChannelStatusDTO from(ChannelSwitcherService service) {
|
||||||
|
return new ChannelStatusDTO(
|
||||||
|
service.getCurrentChannel().name().toLowerCase(),
|
||||||
|
service.isSwitcherAvailable(),
|
||||||
|
service.getLastResult());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,11 +102,18 @@ services:
|
|||||||
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||||
# Chemin du docker config.json partage avec Watchtower
|
# Chemin du docker config.json partage avec Watchtower
|
||||||
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||||
|
# Chemin du repertoire partage avec le switcher (commande + resultat).
|
||||||
|
# Doit matcher le volume `switcher-data` monte ci-dessous.
|
||||||
|
SWITCHER_DATA_PATH: /shared/switcher
|
||||||
volumes:
|
volumes:
|
||||||
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||||
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||||
# privees du canal beta. Pas de creds = no-op.
|
# privees du canal beta. Pas de creds = no-op.
|
||||||
- docker-config:/shared/docker
|
- docker-config:/shared/docker
|
||||||
|
# Volume partage avec le switcher : Core ecrit une commande de switch
|
||||||
|
# de canal ici (command.json), le switcher la traite et y depose son
|
||||||
|
# resultat (result.json). Cf. service `switcher` ci-dessous.
|
||||||
|
- switcher-data:/shared/switcher
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||||
@@ -167,6 +174,44 @@ services:
|
|||||||
- "${WEB_PORT:-8081}:80"
|
- "${WEB_PORT:-8081}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Sidecar de bascule de canal (stable <-> beta).
|
||||||
|
#
|
||||||
|
# Pourquoi : la bascule entre canaux change le PREFIXE d'image (loremind- vs
|
||||||
|
# loremind-beta-), donc Watchtower seul ne peut pas la faire — il met a jour
|
||||||
|
# des images, pas leur reference. Ce sidecar fait le `sed .env` + le
|
||||||
|
# `docker compose pull/up -d` quand le Core depose une commande JSON.
|
||||||
|
#
|
||||||
|
# Securite : pas de port expose. La commande arrive via volume partage
|
||||||
|
# (`switcher-data`) que SEUL le Core ecrit. Le switcher valide strictement
|
||||||
|
# le contenu (channel ∈ {stable, beta}, rien d'autre) — pas de RCE via
|
||||||
|
# compromission du Core.
|
||||||
|
#
|
||||||
|
# L'image switcher est volontairement HORS de IMAGE_NAMESPACE : elle reste
|
||||||
|
# `igmlcreation/loremind-switcher` sur les deux canaux. Sinon le switcher
|
||||||
|
# se tuerait lui-meme pendant le `docker compose up -d` (race condition).
|
||||||
|
switcher:
|
||||||
|
image: ghcr.io/igmlcreation/loremind-switcher:${SWITCHER_TAG:-latest}
|
||||||
|
container_name: loremind-switcher
|
||||||
|
# PAS de label watchtower : la maj du switcher se fait via le canal
|
||||||
|
# stable uniquement, et hors du flow d'auto-update.
|
||||||
|
volumes:
|
||||||
|
# Socket Docker du host : permet de lancer docker compose pull/up.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Repertoire compose du host (docker-compose.yml + .env) — RW pour
|
||||||
|
# pouvoir sed la ligne IMAGE_NAMESPACE.
|
||||||
|
- ${COMPOSE_PROJECT_DIR:-./}:/compose
|
||||||
|
# Volume partage avec le Core pour la commande + le resultat.
|
||||||
|
- switcher-data:/data
|
||||||
|
environment:
|
||||||
|
# Repertoire interne ou trouver docker-compose.yml et .env. Bind au
|
||||||
|
# volume ci-dessus (COMPOSE_PROJECT_DIR = repertoire d'install du host).
|
||||||
|
COMPOSE_DIR: /compose
|
||||||
|
# Nom de projet docker compose : fixe ici pour que le switcher cible
|
||||||
|
# le MEME stack que celui qui tourne (sinon il creerait un duplicate).
|
||||||
|
# Doit matcher le `name:` (en V2.x) ou le nom du dossier du host.
|
||||||
|
COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-loremind}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Mises a jour automatiques des images core/brain/web.
|
# Mises a jour automatiques des images core/brain/web.
|
||||||
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
|
||||||
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
|
||||||
@@ -214,3 +259,5 @@ volumes:
|
|||||||
# Volume partage Core <-> Watchtower : config.json Docker pour
|
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||||
# l'authentification au registry prive GHCR (canal beta Patreon).
|
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||||
docker-config:
|
docker-config:
|
||||||
|
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
|
||||||
|
switcher-data:
|
||||||
|
|||||||
26
switcher/Dockerfile
Normal file
26
switcher/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# LoreMind channel switcher — sidecar minimal qui orchestre les bascules
|
||||||
|
# stable <-> beta. Tourne en permanence en attente d'une commande deposee
|
||||||
|
# dans le volume partage par le Core.
|
||||||
|
#
|
||||||
|
# Image volontairement legere (Alpine + docker-cli + bash). Pas de port
|
||||||
|
# expose, pas de processus reseau : tout passe par fichiers + socket Docker.
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
# docker-cli : pour parler au socket Docker du host
|
||||||
|
# docker-cli-compose : pour `docker compose pull/up`
|
||||||
|
# bash : pour les scripts (sh ne suffit pas, on utilise des features bash)
|
||||||
|
# jq : parsing JSON de la commande
|
||||||
|
# coreutils : pour `date -u --iso-8601=seconds`
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
docker-cli \
|
||||||
|
docker-cli-compose \
|
||||||
|
bash \
|
||||||
|
jq \
|
||||||
|
coreutils
|
||||||
|
|
||||||
|
WORKDIR /switcher
|
||||||
|
COPY watch.sh switch.sh ./
|
||||||
|
RUN chmod +x watch.sh switch.sh
|
||||||
|
|
||||||
|
# Tourne en permanence en mode polling.
|
||||||
|
ENTRYPOINT ["/switcher/watch.sh"]
|
||||||
66
switcher/README.md
Normal file
66
switcher/README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# LoreMind channel switcher
|
||||||
|
|
||||||
|
Sidecar qui bascule LoreMind entre les canaux **stable** et **beta** depuis l'UI,
|
||||||
|
sans manipulation manuelle du `.env` ni de docker-compose.
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Le switcher est un container minimal (Alpine + docker-cli + bash) qui :
|
||||||
|
|
||||||
|
1. Watch un fichier `command.json` dans un volume partagé avec le Core
|
||||||
|
2. Quand une commande arrive :
|
||||||
|
- Valide le canal cible (`stable` | `beta`)
|
||||||
|
- Sed la ligne `IMAGE_NAMESPACE` du `.env` du host
|
||||||
|
- Lance `docker compose pull` puis `docker compose up -d` sur core/brain/web
|
||||||
|
3. Écrit son résultat dans `result.json` (le Core remonte ça à l'UI via polling)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
Le switcher a accès au socket Docker et au répertoire compose du host (RW),
|
||||||
|
donc beaucoup de pouvoir. Pour éviter qu'une compromission du Core devienne
|
||||||
|
un RCE sur l'hôte :
|
||||||
|
|
||||||
|
- Le Core n'a **pas** accès au socket Docker — il dépose une commande dans un
|
||||||
|
fichier, point.
|
||||||
|
- Le switcher **valide strictement** le contenu : `channel` doit valoir exactement
|
||||||
|
`stable` ou `beta` (case statement, pas de regex laxiste).
|
||||||
|
- Aucun port n'est exposé. La communication se fait uniquement via volume
|
||||||
|
partagé.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
|
│ User clique │ │ Core │ │ switcher │ │ Docker │
|
||||||
|
│ "Passer beta"│─▶│ écrit command.json│─▶│ sed .env │─▶│ daemon │
|
||||||
|
│ dans UI │ │ dans volume │ │ docker compose│ │ (recreate) │
|
||||||
|
└──────────────┘ └──────────────────┘ └──────────────┘ └────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ result.json │ ◄── Core poll
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade pour les installs existantes
|
||||||
|
|
||||||
|
Le sidecar est arrivé dans LoreMind 0.9.0. Pour les installs antérieures qui
|
||||||
|
ne l'ont pas dans leur `docker-compose.yml`, l'utilisateur doit faire une
|
||||||
|
**dernière** manipulation :
|
||||||
|
|
||||||
|
1. Récupérer le nouveau `docker-compose.yml` du repo
|
||||||
|
2. Lancer `docker compose pull && docker compose up -d`
|
||||||
|
|
||||||
|
Après ça, tous les switchs futurs se font depuis l'UI sans intervention CLI.
|
||||||
|
|
||||||
|
## Pourquoi le switcher n'est PAS dans `IMAGE_NAMESPACE`
|
||||||
|
|
||||||
|
L'image du switcher est codée en dur (`ghcr.io/igmlcreation/loremind-switcher`)
|
||||||
|
plutôt que d'utiliser `${IMAGE_NAMESPACE}`. Raison : pendant un switch, le
|
||||||
|
switcher exécute `docker compose up -d`. Si son propre image faisait partie
|
||||||
|
de `IMAGE_NAMESPACE`, le compose voudrait le recréer en même temps que
|
||||||
|
core/brain/web — et il se tuerait au milieu de sa propre commande. Race
|
||||||
|
condition fatale.
|
||||||
|
|
||||||
|
Pour la même raison, le `docker compose up -d` dans `switch.sh` cible
|
||||||
|
explicitement `core brain web --no-deps` — jamais le switcher lui-même.
|
||||||
112
switcher/switch.sh
Normal file
112
switcher/switch.sh
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# switch.sh — execute le switch de canal pour LoreMind.
|
||||||
|
#
|
||||||
|
# Usage interne (appele par watch.sh) :
|
||||||
|
# ./switch.sh stable
|
||||||
|
# ./switch.sh beta
|
||||||
|
#
|
||||||
|
# Ce que ca fait, dans l'ordre :
|
||||||
|
# 1. Valide l'argument (stable|beta uniquement, rien d'autre — defense
|
||||||
|
# contre command injection si le Core etait compromis)
|
||||||
|
# 2. Sed la ligne IMAGE_NAMESPACE= du .env du host pour basculer le prefixe
|
||||||
|
# 3. docker compose pull (recupere les nouvelles images du canal cible)
|
||||||
|
# 4. docker compose up -d (recree core/brain/web avec les nouvelles images)
|
||||||
|
#
|
||||||
|
# Le switcher LUI-MEME n'est PAS dans IMAGE_NAMESPACE — il survit au switch
|
||||||
|
# sans interruption (cf. docker-compose.yml).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CHANNEL="${1:-}"
|
||||||
|
|
||||||
|
# --- Validation stricte -----------------------------------------------------
|
||||||
|
# Aucune autre valeur acceptee. Pas d'echappement, pas de slash, rien.
|
||||||
|
# C'est le filet de securite si le JSON depose dans /data/command.json
|
||||||
|
# contenait un payload exotique (Core compromis = on ne laisse PAS
|
||||||
|
# executer du code arbitraire sur l'hote).
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable|beta) ;;
|
||||||
|
*)
|
||||||
|
echo "Channel invalide: '${CHANNEL}' (attendu: stable|beta)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Configuration ---------------------------------------------------------
|
||||||
|
# Repertoire monte depuis l'hote contenant docker-compose.yml + .env
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/compose}"
|
||||||
|
ENV_FILE="${COMPOSE_DIR}/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
|
echo "Fichier .env introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${COMPOSE_DIR}/docker-compose.yml" ]]; then
|
||||||
|
echo "docker-compose.yml introuvable dans ${COMPOSE_DIR}" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Detection du nom de projet compose ------------------------------------
|
||||||
|
# Pour eviter que le switcher cree un projet PARALLELE (cas ou COMPOSE_PROJECT_NAME
|
||||||
|
# ne correspond pas au nom du projet sous lequel les containers tournent),
|
||||||
|
# on lit le label compose du container core en cours d'execution.
|
||||||
|
# Ce label est ecrit par docker compose au moment du `up -d` initial — c'est
|
||||||
|
# la source de verite.
|
||||||
|
PROJECT_NAME=$(docker inspect loremind-core \
|
||||||
|
--format '{{ index .Config.Labels "com.docker.compose.project" }}' \
|
||||||
|
2>/dev/null || echo "")
|
||||||
|
if [[ -z "${PROJECT_NAME}" ]]; then
|
||||||
|
# Fallback : env var ou defaut. Ne devrait pas arriver en prod
|
||||||
|
# (loremind-core tourne forcement quand l'UI declenche un switch).
|
||||||
|
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-loremind}"
|
||||||
|
echo "Warning: nom de projet auto-detecte impossible, fallback sur '${PROJECT_NAME}'" >&2
|
||||||
|
fi
|
||||||
|
export COMPOSE_PROJECT_NAME="${PROJECT_NAME}"
|
||||||
|
echo "→ Projet compose cible: ${PROJECT_NAME}"
|
||||||
|
|
||||||
|
# --- Mapping canal -> namespace --------------------------------------------
|
||||||
|
# Le slash final est important : il est concatene avec le suffixe image
|
||||||
|
# (core/brain/web) dans le docker-compose.yml.
|
||||||
|
case "${CHANNEL}" in
|
||||||
|
stable) NAMESPACE="igmlcreation/loremind-" ;;
|
||||||
|
beta) NAMESPACE="igmlcreation/loremind-beta-" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Etape 1 : sed le .env -------------------------------------------------
|
||||||
|
# On veut REMPLACER une ligne existante IMAGE_NAMESPACE=... ou AJOUTER
|
||||||
|
# si absente. Cas typique : .env utilisateur peut avoir cette ligne ou non.
|
||||||
|
#
|
||||||
|
# Sed -i avec un pattern qui matche la ligne entiere. Si pas de match,
|
||||||
|
# on append.
|
||||||
|
echo "→ Mise a jour de IMAGE_NAMESPACE dans .env (canal: ${CHANNEL})"
|
||||||
|
if grep -q '^IMAGE_NAMESPACE=' "${ENV_FILE}"; then
|
||||||
|
# Sur Alpine, sed -i sans backup. Le pattern d'echappement '/' dans
|
||||||
|
# le namespace impose un delimiter alternatif (|).
|
||||||
|
sed -i "s|^IMAGE_NAMESPACE=.*|IMAGE_NAMESPACE=${NAMESPACE}|" "${ENV_FILE}"
|
||||||
|
else
|
||||||
|
# Ligne absente → on l'ajoute en fin de fichier avec un commentaire.
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
|
||||||
|
echo "IMAGE_NAMESPACE=${NAMESPACE}"
|
||||||
|
} >> "${ENV_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Etape 2 : docker compose pull -----------------------------------------
|
||||||
|
echo "→ Pull des nouvelles images (${NAMESPACE}*)"
|
||||||
|
# --no-deps inutile ici : pull n'a pas de notion de deps.
|
||||||
|
# --policy missing eviterait de re-puller si deja la, mais on VEUT puller
|
||||||
|
# pour avoir la derniere version disponible — c'est le but du switch.
|
||||||
|
cd "${COMPOSE_DIR}"
|
||||||
|
docker compose pull core brain web
|
||||||
|
|
||||||
|
# --- Etape 3 : recreate les containers avec les nouvelles images -----------
|
||||||
|
# On cible explicitement core/brain/web — pas le switcher (qui s'auto-tuerait
|
||||||
|
# au milieu de la commande), pas postgres/minio (pas de changement d'image).
|
||||||
|
# --no-deps : ne pas re-recreer postgres/minio comme effet de bord.
|
||||||
|
echo "→ Recreation des containers avec les nouvelles images"
|
||||||
|
docker compose up -d --no-deps core brain web
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Switch vers le canal ${CHANNEL} termine avec succes."
|
||||||
|
echo "Containers core/brain/web recrees avec ${NAMESPACE}*."
|
||||||
84
switcher/watch.sh
Normal file
84
switcher/watch.sh
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# watch.sh — boucle principale du switcher.
|
||||||
|
#
|
||||||
|
# Surveille /data/command.json (depose par le Core via l'API HTTP) et lance
|
||||||
|
# switch.sh quand une nouvelle commande arrive. L'ID de la commande sert
|
||||||
|
# d'idempotence : on ne traite pas deux fois la meme requete.
|
||||||
|
#
|
||||||
|
# Le resultat est ecrit dans /data/result.json pour que le Core puisse le
|
||||||
|
# remonter a l'UI via son endpoint de status.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DATA_DIR="/data"
|
||||||
|
COMMAND_FILE="${DATA_DIR}/command.json"
|
||||||
|
RESULT_FILE="${DATA_DIR}/result.json"
|
||||||
|
LAST_PROCESSED_FILE="${DATA_DIR}/.last-processed-id"
|
||||||
|
|
||||||
|
mkdir -p "${DATA_DIR}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u --iso-8601=seconds)] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ecrit un resultat JSON dans result.json — atomique via tmp + mv.
|
||||||
|
write_result() {
|
||||||
|
local status="$1" # "in-progress" | "success" | "error"
|
||||||
|
local channel="$2" # "stable" | "beta" | ""
|
||||||
|
local message="$3"
|
||||||
|
local id="$4"
|
||||||
|
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -p "${DATA_DIR}" result.XXXXXX)"
|
||||||
|
cat > "${tmp}" <<EOF
|
||||||
|
{
|
||||||
|
"id": "${id}",
|
||||||
|
"status": "${status}",
|
||||||
|
"channel": "${channel}",
|
||||||
|
"message": $(printf '%s' "${message}" | jq -Rs .),
|
||||||
|
"completedAt": "$(date -u --iso-8601=seconds)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
mv "${tmp}" "${RESULT_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "LoreMind channel switcher started — watching ${COMMAND_FILE}"
|
||||||
|
|
||||||
|
# Boucle de polling. Intervalle court (1s) — la charge est negligeable
|
||||||
|
# (un test de fichier) et l'utilisateur attend une reaction rapide.
|
||||||
|
while true; do
|
||||||
|
if [[ -f "${COMMAND_FILE}" ]]; then
|
||||||
|
# Parse la commande. Tolere les JSON malformes : on ignore et on attend.
|
||||||
|
if ! id=$(jq -er '.id' "${COMMAND_FILE}" 2>/dev/null); then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Idempotence : skip si on a deja traite cet ID.
|
||||||
|
last_id=""
|
||||||
|
[[ -f "${LAST_PROCESSED_FILE}" ]] && last_id=$(cat "${LAST_PROCESSED_FILE}")
|
||||||
|
if [[ "${id}" == "${last_id}" ]]; then
|
||||||
|
sleep 1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
channel=$(jq -er '.channel' "${COMMAND_FILE}" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
log "New command received: id=${id} channel=${channel}"
|
||||||
|
write_result "in-progress" "${channel}" "Switch en cours..." "${id}"
|
||||||
|
|
||||||
|
# Lance le switch. On capture stdout+stderr et le code de sortie.
|
||||||
|
if output=$(/switcher/switch.sh "${channel}" 2>&1); then
|
||||||
|
log "Switch SUCCESS for id=${id} channel=${channel}"
|
||||||
|
write_result "success" "${channel}" "${output}" "${id}"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
|
||||||
|
write_result "error" "${channel}" "${output}" "${id}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Marque l'ID comme traite — empeche les replays.
|
||||||
|
echo "${id}" > "${LAST_PROCESSED_FILE}"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.4-beta",
|
"version": "0.8.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.4-beta",
|
"version": "0.8.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.4-beta",
|
"version": "0.8.5",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ export interface LicenseStatusDTO {
|
|||||||
betaChannelEnabled: boolean;
|
betaChannelEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat du canal courant + dernier resultat de bascule (cf. ChannelStatusDTO cote backend).
|
||||||
|
*/
|
||||||
|
export type ChannelName = 'stable' | 'beta';
|
||||||
|
export type SwitchStatus = 'IN_PROGRESS' | 'SUCCESS' | 'ERROR';
|
||||||
|
|
||||||
|
export interface ChannelStatusDTO {
|
||||||
|
currentChannel: ChannelName;
|
||||||
|
switcherAvailable: boolean;
|
||||||
|
lastSwitch: {
|
||||||
|
id: string;
|
||||||
|
status: SwitchStatus;
|
||||||
|
channel: ChannelName;
|
||||||
|
message: string;
|
||||||
|
completedAt: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflet de UpdateCheckService.BetaStatus.
|
* Reflet de UpdateCheckService.BetaStatus.
|
||||||
*/
|
*/
|
||||||
@@ -91,4 +109,23 @@ export class LicenseService {
|
|||||||
catchError(() => of(null))
|
catchError(() => of(null))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Etat du canal courant et dernier resultat de switch (pour polling UI). */
|
||||||
|
getChannelStatus(): Observable<ChannelStatusDTO | null> {
|
||||||
|
return this.http.get<ChannelStatusDTO>(`${this.apiUrl}/channel`, this.authOptions).pipe(
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declenche un switch de canal. 202 + { id, channel } si accepte,
|
||||||
|
* sinon erreur (403 = pas de licence, 503 = sidecar indispo, etc.).
|
||||||
|
*/
|
||||||
|
switchChannel(channel: ChannelName): Observable<{ id: string; channel: ChannelName } | { error: string }> {
|
||||||
|
return this.http.post<{ id: string; channel: ChannelName }>(
|
||||||
|
`${this.apiUrl}/channel/switch`, { channel }, this.authOptions
|
||||||
|
).pipe(
|
||||||
|
catchError((err) => of({ error: err?.error?.error ?? 'Echec du switch de canal' }))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,22 +252,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
<span>Verification impossible pour certaines images — voir details ci-dessous.</span>
|
<span>Verification impossible (baseline absente ou registry injoignable).</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
<div *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
|
||||||
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="update-images" *ngIf="updateStatus?.images?.length">
|
|
||||||
<li *ngFor="let img of updateStatus?.images">
|
|
||||||
<strong>{{ img.image }}</strong>
|
|
||||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">MAJ dispo</span>
|
|
||||||
<span *ngIf="img.status === 'UP_TO_DATE'" class="badge-ok">a jour</span>
|
|
||||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn"
|
|
||||||
title="Impossible de comparer (baseline absente ou registry injoignable)">verification impossible</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
<div class="form-row" *ngIf="updateStatus?.updateAvailable">
|
||||||
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
||||||
@@ -333,7 +323,7 @@
|
|||||||
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
|
||||||
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
|
||||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||||
<span>Compte Patreon connecte. Tier {{ licenseStatus.tierId }} actif.</span>
|
<span>Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif.</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
<div *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
|
||||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
@@ -354,7 +344,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="license-info">
|
<ul class="license-info">
|
||||||
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ licenseStatus.tierId }}</li>
|
<li *ngIf="licenseStatus.tierId"><strong>Tier :</strong> {{ tierLabel(licenseStatus.tierId) }}</li>
|
||||||
<li *ngIf="licenseStatus.expiresAt">
|
<li *ngIf="licenseStatus.expiresAt">
|
||||||
<strong>Validite :</strong>
|
<strong>Validite :</strong>
|
||||||
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
|
||||||
@@ -400,23 +390,68 @@
|
|||||||
Indisponible : {{ betaStatus.disabledReason }}
|
Indisponible : {{ betaStatus.disabledReason }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
<div *ngIf="!betaChecking && betaStatus?.enabled">
|
||||||
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
|
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
|
||||||
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
|
|
||||||
<span>Une version beta est disponible. Pour l'installer, modifie ton fichier <code>.env</code> :
|
|
||||||
<code>IMAGE_NAMESPACE=igmlcreation/loremind-beta-</code> puis
|
|
||||||
<code>docker compose pull && docker compose up -d</code>.</span>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
|
|
||||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
<span>Verification beta impossible pour certaines images.</span>
|
<span>Verification beta impossible (registry beta injoignable ou baseline absente).</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="update-images" *ngIf="betaStatus?.images?.length">
|
</div>
|
||||||
<li *ngFor="let img of betaStatus?.images">
|
</div>
|
||||||
<strong>{{ img.image }}</strong>
|
|
||||||
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
|
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
|
||||||
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
|
<div class="channel-switch" *ngIf="channelStatus">
|
||||||
</li>
|
<div class="channel-current">
|
||||||
</ul>
|
<span class="channel-label">Canal actuel :</span>
|
||||||
|
<span class="channel-badge"
|
||||||
|
[class.channel-stable]="channelStatus.currentChannel === 'stable'"
|
||||||
|
[class.channel-beta]="channelStatus.currentChannel === 'beta'">
|
||||||
|
{{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidecar dispo : boutons d'action -->
|
||||||
|
<ng-container *ngIf="channelStatus.switcherAvailable">
|
||||||
|
<!-- On stable -> proposer passage beta (uniquement si licence active) -->
|
||||||
|
<button *ngIf="channelStatus.currentChannel === 'stable'"
|
||||||
|
type="button" class="btn-primary"
|
||||||
|
[disabled]="switchInFlight"
|
||||||
|
(click)="requestChannelSwitch('beta')">
|
||||||
|
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
|
||||||
|
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Passer sur le canal beta' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- On beta -> proposer retour stable -->
|
||||||
|
<button *ngIf="channelStatus.currentChannel === 'beta'"
|
||||||
|
type="button" class="btn-secondary"
|
||||||
|
[disabled]="switchInFlight"
|
||||||
|
(click)="requestChannelSwitch('stable')">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
<span>{{ switchInFlight ? 'Bascule en cours...' : 'Repasser sur le canal stable' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Switch en cours : on prévient que la page va se rendre injoignable -->
|
||||||
|
<div *ngIf="switchInFlight" class="alert alert-warn">
|
||||||
|
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||||
|
<span>Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur eventuelle remontee par le sidecar -->
|
||||||
|
<div *ngIf="switchError && !switchInFlight" class="alert alert-error">
|
||||||
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
|
<span>{{ switchError }}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Sidecar PAS dispo : fallback instructions manuelles (vieilles installs) -->
|
||||||
|
<div *ngIf="!channelStatus.switcherAvailable" class="alert alert-warn">
|
||||||
|
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||||
|
<span>
|
||||||
|
Le sidecar de bascule n'est pas installe. Pour beneficier du switch
|
||||||
|
automatique, recupere le dernier <code>docker-compose.yml</code> du repo
|
||||||
|
et fais <code>docker compose pull && docker compose up -d</code> une
|
||||||
|
fois. Sinon, bascule manuellement en editant <code>IMAGE_NAMESPACE</code>
|
||||||
|
dans ton <code>.env</code> (<code>igmlcreation/loremind-</code> pour stable,
|
||||||
|
<code>igmlcreation/loremind-beta-</code> pour beta).
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -322,32 +322,42 @@
|
|||||||
accent-color: #6c63ff;
|
accent-color: #6c63ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-images {
|
.channel-switch {
|
||||||
list-style: none;
|
margin-top: 1rem;
|
||||||
padding: 0;
|
padding: 1rem;
|
||||||
margin: 0.75rem 0;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.update-images li {
|
.channel-current {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.5rem;
|
||||||
padding: 0.4rem 0.6rem;
|
font-size: 0.9rem;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
|
.channel-label { color: #9ca3af; }
|
||||||
|
}
|
||||||
|
.channel-badge {
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
}
|
|
||||||
.badge-update {
|
|
||||||
margin-left: auto;
|
|
||||||
background: #6c63ff;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.15rem 0.5rem;
|
text-transform: uppercase;
|
||||||
border-radius: 3px;
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
&.channel-stable {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #81c784;
|
||||||
|
}
|
||||||
|
&.channel-beta {
|
||||||
|
background: rgba(108, 99, 255, 0.2);
|
||||||
|
color: #a39bff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-ok {
|
.badge-ok {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: rgba(76, 175, 80, 0.2);
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { interval, switchMap, Subscription } from 'rxjs';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
||||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
||||||
import { Subscription } from 'rxjs';
|
|
||||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||||
import { ConfigService } from '../services/config.service';
|
import { ConfigService } from '../services/config.service';
|
||||||
import { LicenseService, LicenseStatusDTO, BetaStatusDTO } from '../services/license.service';
|
import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../services/license.service';
|
||||||
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +28,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
|
|||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
styleUrls: ['./settings.component.scss']
|
styleUrls: ['./settings.component.scss']
|
||||||
})
|
})
|
||||||
export class SettingsComponent implements OnInit {
|
export class SettingsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
readonly ArrowLeft = ArrowLeft;
|
readonly ArrowLeft = ArrowLeft;
|
||||||
readonly RefreshCw = RefreshCw;
|
readonly RefreshCw = RefreshCw;
|
||||||
@@ -53,6 +53,17 @@ export class SettingsComponent implements OnInit {
|
|||||||
betaStatus: BetaStatusDTO | null = null;
|
betaStatus: BetaStatusDTO | null = null;
|
||||||
betaChecking = false;
|
betaChecking = false;
|
||||||
|
|
||||||
|
// --- Bascule de canal stable <-> beta via sidecar switcher ---
|
||||||
|
channelStatus: ChannelStatusDTO | null = null;
|
||||||
|
/** True pendant le polling apres clic. Bloque les boutons. */
|
||||||
|
switchInFlight = false;
|
||||||
|
/** ID de la commande de switch en cours, pour ignorer les vieux resultats. */
|
||||||
|
private switchCommandId: string | null = null;
|
||||||
|
/** Subscription du polling pour pouvoir l'arreter. */
|
||||||
|
private switchPollSub: Subscription | null = null;
|
||||||
|
/** Erreur affichee si le switch a echoue. */
|
||||||
|
switchError = '';
|
||||||
|
|
||||||
// --- Pull / delete de modeles Ollama ---
|
// --- Pull / delete de modeles Ollama ---
|
||||||
/** Dialog d'ajout de modele ouvert/ferme. */
|
/** Dialog d'ajout de modele ouvert/ferme. */
|
||||||
pullDialogOpen = false;
|
pullDialogOpen = false;
|
||||||
@@ -131,6 +142,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.checkUpdates();
|
this.checkUpdates();
|
||||||
}
|
}
|
||||||
this.loadLicense();
|
this.loadLicense();
|
||||||
|
this.loadChannelStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Licence Patreon ---------------------------------------------------
|
// --- Licence Patreon ---------------------------------------------------
|
||||||
@@ -237,6 +249,126 @@ export class SettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bascule de canal stable <-> beta --------------------------------------
|
||||||
|
|
||||||
|
loadChannelStatus(): void {
|
||||||
|
this.licenseService.getChannelStatus().subscribe({
|
||||||
|
next: (s) => {
|
||||||
|
this.channelStatus = s;
|
||||||
|
// Si on revient sur l'ecran apres un reload (post-switch reussi),
|
||||||
|
// on affiche le dernier resultat eventuel jusqu'a interaction utilisateur.
|
||||||
|
},
|
||||||
|
error: () => { this.channelStatus = null; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declenche un switch de canal. La sequence cote UI :
|
||||||
|
* 1. Confirm modal (action destructrice : recreate des containers)
|
||||||
|
* 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande
|
||||||
|
* 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS
|
||||||
|
* 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche
|
||||||
|
* "Recharge la page dans quelques secondes" et on essaie de poll quand
|
||||||
|
* meme — au retour de Core, on detectera SUCCESS et on rechargera auto.
|
||||||
|
* 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons.
|
||||||
|
*/
|
||||||
|
requestChannelSwitch(target: ChannelName): void {
|
||||||
|
const confirmMessage = target === 'beta'
|
||||||
|
? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.'
|
||||||
|
: 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.';
|
||||||
|
|
||||||
|
this.confirmDialog.confirm({
|
||||||
|
title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?',
|
||||||
|
message: confirmMessage,
|
||||||
|
details: [
|
||||||
|
'Les donnees (DB, images) sont preservees.',
|
||||||
|
'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.'
|
||||||
|
],
|
||||||
|
confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable',
|
||||||
|
variant: 'warning'
|
||||||
|
}).then(ok => {
|
||||||
|
if (!ok) return;
|
||||||
|
this.doChannelSwitch(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private doChannelSwitch(target: ChannelName): void {
|
||||||
|
this.switchInFlight = true;
|
||||||
|
this.switchError = '';
|
||||||
|
this.licenseService.switchChannel(target).subscribe((res) => {
|
||||||
|
if ('error' in res) {
|
||||||
|
this.switchError = res.error;
|
||||||
|
this.switchInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.switchCommandId = res.id;
|
||||||
|
this.startSwitchPolling();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un
|
||||||
|
* resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour
|
||||||
|
* a la fin de son traitement).
|
||||||
|
*/
|
||||||
|
private startSwitchPolling(): void {
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchPollSub = interval(2000).pipe(
|
||||||
|
switchMap(() => this.licenseService.getChannelStatus())
|
||||||
|
).subscribe((status) => {
|
||||||
|
if (!status) return;
|
||||||
|
this.channelStatus = status;
|
||||||
|
const last = status.lastSwitch;
|
||||||
|
if (!last || last.id !== this.switchCommandId) return;
|
||||||
|
if (last.status === 'SUCCESS') {
|
||||||
|
// La page va se rafraichir auto via l'update-banner qui detecte le
|
||||||
|
// restart de Core. On laisse switchInFlight a true pour bloquer
|
||||||
|
// toute autre action en attendant.
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchInFlight = false;
|
||||||
|
} else if (last.status === 'ERROR') {
|
||||||
|
this.switchError = last.message || 'Echec du switch';
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
this.switchInFlight = false;
|
||||||
|
}
|
||||||
|
// IN_PROGRESS : on continue a poll.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSwitchPolling(): void {
|
||||||
|
if (this.switchPollSub) {
|
||||||
|
this.switchPollSub.unsubscribe();
|
||||||
|
this.switchPollSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopSwitchPolling();
|
||||||
|
if (this.pullSubscription) {
|
||||||
|
this.pullSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard
|
||||||
|
* Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on
|
||||||
|
* affiche l'ID brut pour rester debuggable.
|
||||||
|
*
|
||||||
|
* Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie.
|
||||||
|
* (Pas besoin de toucher au backend — c'est juste un libelle d'UI.)
|
||||||
|
*/
|
||||||
|
private static readonly TIER_LABELS: Record<string, string> = {
|
||||||
|
'28448887': 'Compagnon',
|
||||||
|
// '0000000': 'Aventurier',
|
||||||
|
// '0000000': 'Heros',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */
|
||||||
|
tierLabel(tierId: string | null | undefined): string {
|
||||||
|
if (!tierId) return '';
|
||||||
|
return SettingsComponent.TIER_LABELS[tierId] ?? tierId;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format human-readable des dates renvoyees par le backend. */
|
/** Format human-readable des dates renvoyees par le backend. */
|
||||||
formatDate(iso: string | null | undefined): string {
|
formatDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
|
|||||||
Reference in New Issue
Block a user