Compare commits

..

62 Commits

Author SHA1 Message Date
4b9b7f0995 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build (web) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 1m48s
2026-05-19 18:36:00 +02:00
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
0cd99dfb32 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
f24ef0891e Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
20 changed files with 1022 additions and 65 deletions

View File

@@ -85,3 +85,57 @@ jobs:
${{ 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 }}:${{ 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 }}

View File

@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.8.4-beta",
version="0.8.6",
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.8.4-beta</version>
<version>0.8.6</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -96,6 +96,15 @@
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify(). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies>
<build>

View File

@@ -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;
}
}
}

View File

@@ -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() : "";
}
}

View File

@@ -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.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -72,12 +71,6 @@ public class GameSystemController {
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) {
return new GameSystemService.GameSystemData(
dto.getName(),

View File

@@ -1,12 +1,16 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.licensing.ChannelSwitcherService;
import com.loremind.application.licensing.LicenseService;
import com.loremind.application.licensing.LicenseService.InstallException;
import com.loremind.domain.licensing.LicenseSnapshot;
import com.loremind.infrastructure.web.dto.licensing.ChannelStatusDTO;
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
/**
@@ -26,9 +30,11 @@ import java.util.Map;
public class LicenseController {
private final LicenseService licenseService;
private final ChannelSwitcherService channelSwitcher;
public LicenseController(LicenseService licenseService) {
public LicenseController(LicenseService licenseService, ChannelSwitcherService channelSwitcher) {
this.licenseService = licenseService;
this.channelSwitcher = channelSwitcher;
}
@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 BetaChannelRequest(boolean enabled) {}
public record ChannelSwitchRequest(String channel) {}
}

View File

@@ -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());
}
}

View File

@@ -7,8 +7,8 @@
# Surchargables via variables d'environnement (CI) :
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.

View File

@@ -102,11 +102,18 @@ services:
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
# Chemin du docker config.json partage avec Watchtower
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:
# Volume partage avec Watchtower : Core ecrit les credentials registry
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
# privees du canal beta. Pas de creds = no-op.
- 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
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
@@ -167,6 +174,51 @@ services:
- "${WEB_PORT:-8081}:80"
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
# Volume partage avec le Core + Watchtower : contient config.json avec
# les creds GHCR (ecrits par le Core a partir du token Patreon).
# Indispensable pour pull les images privees du canal beta.
- docker-config:/shared/docker
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}
# Indique au CLI Docker du switcher ou trouver config.json (auth GHCR
# pour les images privees beta). Meme mecanisme que sur Watchtower.
DOCKER_CONFIG: /shared/docker
restart: unless-stopped
# Mises a jour automatiques des images core/brain/web.
# Active uniquement si COMPOSE_PROFILES=autoupdate (gere par l'installeur).
# Postgres et MinIO sont volontairement exclus (donnees persistantes,
@@ -214,3 +266,5 @@ volumes:
# Volume partage Core <-> Watchtower : config.json Docker pour
# l'authentification au registry prive GHCR (canal beta Patreon).
docker-config:
# Volume partage Core <-> Switcher : commande de bascule de canal + resultat.
switcher-data:

26
switcher/Dockerfile Normal file
View 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
View 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.

123
switcher/switch.sh Normal file
View File

@@ -0,0 +1,123 @@
#!/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, tag) -------------------------------------
# Le slash final du namespace est important : concatene avec le suffixe image
# (core/brain/web) dans le docker-compose.yml.
# Cote tag : le workflow CI pousse :latest pour le canal stable, :beta pour
# le canal beta. Le switcher doit donc forcer ces deux variables ensemble.
case "${CHANNEL}" in
stable)
NAMESPACE="igmlcreation/loremind-"
TAG="latest"
;;
beta)
NAMESPACE="igmlcreation/loremind-beta-"
TAG="beta"
;;
esac
# Helper : met a jour (ou ajoute) une variable key=value dans le .env.
update_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "${ENV_FILE}"; then
# Sur Alpine, sed -i sans backup. Le pattern '/' dans la valeur impose
# un delimiter alternatif (|).
sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}"
else
# Ligne absente → on l'ajoute en fin de fichier la premiere fois.
{
echo ""
echo "# Ajoute automatiquement par le switcher de canal LoreMind."
echo "${key}=${value}"
} >> "${ENV_FILE}"
fi
}
# --- Etape 1 : sed le .env -------------------------------------------------
echo "→ Mise a jour de IMAGE_NAMESPACE + TAG dans .env (canal: ${CHANNEL})"
update_env_var "IMAGE_NAMESPACE" "${NAMESPACE}"
update_env_var "TAG" "${TAG}"
# --- 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}*:${TAG}."

88
switcher/watch.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/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}"
# Log la sortie sur plusieurs lignes pour faciliter le debug
# (ce qu'on voit en docker logs).
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
write_result "success" "${channel}" "${output}" "${id}"
else
rc=$?
log "Switch FAILED for id=${id} channel=${channel} rc=${rc}"
while IFS= read -r line; do log " | ${line}"; done <<< "${output}"
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
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.8.4-beta",
"version": "0.8.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.8.4-beta",
"version": "0.8.6",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.8.4-beta",
"version": "0.8.6",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -19,6 +19,24 @@ export interface LicenseStatusDTO {
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.
*/
@@ -91,4 +109,23 @@ export class LicenseService {
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' }))
);
}
}

View File

@@ -252,22 +252,12 @@
</div>
<div *ngIf="updateStatus?.anyUnknown && !updateStatus?.updateAvailable" class="alert alert-warn">
<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 *ngIf="!updateStatus?.updateAvailable && !updateStatus?.anyUnknown" class="hint">
Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}).
</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">
<button type="button" class="btn-primary" (click)="applyUpdate()" [disabled]="updateApplying">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
@@ -333,7 +323,7 @@
<ng-container *ngIf="licenseStatus && licenseStatus.status !== 'NONE'">
<div *ngIf="licenseStatus.status === 'VALID'" class="alert alert-success">
<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 *ngIf="licenseStatus.status === 'GRACE'" class="alert alert-warn">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
@@ -354,7 +344,7 @@
</div>
<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">
<strong>Validite :</strong>
jusqu'au {{ formatDate(licenseStatus.expiresAt) }}
@@ -400,23 +390,68 @@
Indisponible : {{ betaStatus.disabledReason }}
</div>
<div *ngIf="!betaChecking && betaStatus?.enabled">
<div *ngIf="betaStatus?.updateAvailable" class="alert alert-success">
<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 &amp;&amp; docker compose up -d</code>.</span>
</div>
<div *ngIf="betaStatus?.anyUnknown && !betaStatus?.updateAvailable" class="alert alert-warn">
<div *ngIf="betaStatus?.anyUnknown" class="alert alert-warn">
<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>
<ul class="update-images" *ngIf="betaStatus?.images?.length">
<li *ngFor="let img of betaStatus?.images">
<strong>{{ img.image }}</strong>
<span *ngIf="img.status === 'UPDATE_AVAILABLE'" class="badge-update">version dispo</span>
<span *ngIf="img.status === 'UNKNOWN'" class="badge-warn">verification impossible</span>
</li>
</ul>
</div>
</div>
<!-- Bascule de canal (stable <-> beta) via sidecar switcher -->
<div class="channel-switch" *ngIf="channelStatus">
<div class="channel-current">
<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 &amp;&amp; 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>
</ng-container>

View File

@@ -322,32 +322,42 @@
accent-color: #6c63ff;
}
.update-images {
list-style: none;
padding: 0;
margin: 0.75rem 0;
.channel-switch {
margin-top: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.4rem;
gap: 0.75rem;
}
.update-images li {
.channel-current {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.03);
gap: 0.5rem;
font-size: 0.9rem;
.channel-label { color: #9ca3af; }
}
.channel-badge {
padding: 0.2rem 0.65rem;
border-radius: 4px;
font-size: 0.875rem;
}
.badge-update {
margin-left: auto;
background: #6c63ff;
color: white;
font-size: 0.7rem;
font-size: 0.8rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 3px;
text-transform: uppercase;
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 {
margin-left: auto;
background: rgba(76, 175, 80, 0.2);

View File

@@ -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 { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
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 { Subscription } from 'rxjs';
import { UpdatesService, UpdateStatus } from '../services/updates.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';
/**
@@ -28,7 +28,7 @@ import { ConfirmDialogService } from '../shared/confirm-dialog/confirm-dialog.se
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
export class SettingsComponent implements OnInit, OnDestroy {
readonly ArrowLeft = ArrowLeft;
readonly RefreshCw = RefreshCw;
@@ -53,6 +53,17 @@ export class SettingsComponent implements OnInit {
betaStatus: BetaStatusDTO | null = null;
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 ---
/** Dialog d'ajout de modele ouvert/ferme. */
pullDialogOpen = false;
@@ -131,6 +142,7 @@ export class SettingsComponent implements OnInit {
this.checkUpdates();
}
this.loadLicense();
this.loadChannelStatus();
}
// --- 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. */
formatDate(iso: string | null | undefined): string {
if (!iso) return '';