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

This commit is contained in:
2026-05-19 18:05:17 +02:00
parent f71bf3fcad
commit 759e47fc1f
17 changed files with 875 additions and 19 deletions

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

@@ -390,19 +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 (registry beta injoignable ou baseline absente).</span>
</div>
<div *ngIf="!betaStatus?.updateAvailable && !betaStatus?.anyUnknown" class="hint">
Aucune version beta plus recente disponible.
</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,6 +322,42 @@
accent-color: #6c63ff;
}
.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.75rem;
}
.channel-current {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
.channel-label { color: #9ca3af; }
}
.channel-badge {
padding: 0.2rem 0.65rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 700;
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,106 @@ 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