diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index a0a4f22..85ab538 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -27,6 +27,11 @@ jobs:
id: repo
run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
+ - name: Bump version in package.json
+ run: |
+ VERSION=$(echo "${{ gitea.ref_name }}" | sed 's/^v//')
+ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json
+
- name: Build and push
uses: https://github.com/docker/build-push-action@v6
with:
diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css
index d9f8766..e651926 100644
--- a/src/app/menu/menu.css
+++ b/src/app/menu/menu.css
@@ -14,6 +14,7 @@
}
.sidebar-logo {
+ position: relative;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
@@ -34,11 +35,120 @@
letter-spacing: -0.02em;
}
+.sidebar-version-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
.sidebar-version {
font-size: 0.72rem;
color: #9ca3af;
}
+.sidebar-info-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.1rem;
+ height: 1.1rem;
+ font-size: 0.65rem;
+ font-weight: 700;
+ font-style: italic;
+ line-height: 1;
+ color: #9ca3af;
+ background: transparent;
+ border: 1px solid #d1d5db;
+ border-radius: 50%;
+ cursor: pointer;
+ padding: 0;
+ transition: background 0.1s, color 0.1s, border-color 0.1s;
+}
+
+.sidebar-info-btn:hover {
+ background: #f3f4f6;
+ color: #374151;
+ border-color: #9ca3af;
+}
+
+.sidebar-info-btn--warn {
+ color: #92400e;
+ background: #fef3c7;
+ border-color: #f59e0b;
+}
+
+.sidebar-info-btn--warn:hover {
+ background: #fde68a;
+ color: #78350f;
+ border-color: #d97706;
+}
+
+.version-popover {
+ position: absolute;
+ left: 0.75rem;
+ right: 0.75rem;
+ top: calc(100% + 0.5rem);
+ background: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 10;
+}
+
+.version-popover-close {
+ position: absolute;
+ top: 0.4rem;
+ right: 0.5rem;
+ background: transparent;
+ border: none;
+ font-size: 1rem;
+ color: #9ca3af;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+}
+
+.version-popover-close:hover {
+ color: #374151;
+}
+
+.version-popover-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: #374151;
+ margin-bottom: 0.5rem;
+}
+
+.version-popover-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.75rem;
+ padding: 0.2rem 0;
+}
+
+.version-popover-label {
+ color: #6b7280;
+}
+
+.version-popover-value {
+ font-weight: 600;
+ color: #111827;
+ font-family: monospace;
+}
+
+.version-popover-warn {
+ margin-top: 0.5rem;
+ font-size: 0.7rem;
+ color: #92400e;
+ background: #fef3c7;
+ border: 1px solid #f59e0b;
+ border-radius: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ text-align: center;
+}
+
.sidebar-nav {
display: flex;
flex-direction: column;
diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html
index 15f4d16..b8011e2 100644
--- a/src/app/menu/menu.html
+++ b/src/app/menu/menu.html
@@ -25,7 +25,33 @@
-
+
+ @if (showInfo()) {
+
+
+
Versions
+
+ Interface
+ v{{ version }}
+
+
+ API
+ {{ apiVersion() !== null ? 'v' + apiVersion() : '...' }}
+
+ @if (versionMismatch()) {
+
Versions différentes
+ }
+
+ }
@if (keycloak.isAuthenticated()) {
diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts
index bd536c8..d9d2ce5 100644
--- a/src/app/menu/menu.spec.ts
+++ b/src/app/menu/menu.spec.ts
@@ -1,10 +1,12 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
import { ProjectEntity } from '../projects/projects.store';
+import { VersionApiService } from '../version/version-api.service';
import { Menu } from './menu';
describe('Menu', () => {
@@ -18,6 +20,7 @@ describe('Menu', () => {
const keycloakMock = { isAuthenticated, username, logout: vi.fn(), login: vi.fn() };
const projectContextMock = { projectId, project };
+ const versionApiMock = { getVersion: vi.fn() };
beforeEach(async () => {
isAuthenticated.set(false);
@@ -25,6 +28,7 @@ describe('Menu', () => {
project.set(null);
keycloakMock.logout = vi.fn();
keycloakMock.login = vi.fn();
+ versionApiMock.getVersion = vi.fn();
await TestBed.configureTestingModule({
imports: [Menu],
@@ -32,6 +36,7 @@ describe('Menu', () => {
provideRouter([]),
{ provide: KeycloakService, useValue: keycloakMock },
{ provide: ProjectContextService, useValue: projectContextMock },
+ { provide: VersionApiService, useValue: versionApiMock },
],
}).compileComponents();
@@ -73,4 +78,66 @@ describe('Menu', () => {
const hrefs = Array.from(links).map((l: any) => l.getAttribute('href') ?? '');
expect(hrefs.some((h) => h === '/projects')).toBe(true);
});
+
+ describe('info button', () => {
+ it('toggleInfo opens the popover and fetches API version', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
+
+ (component as any).toggleInfo();
+
+ expect((component as any).showInfo()).toBe(true);
+ expect((component as any).apiVersion()).toBe('1.2.3');
+ });
+
+ it('toggleInfo closes the popover when already open', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
+ (component as any).toggleInfo();
+ (component as any).toggleInfo();
+
+ expect((component as any).showInfo()).toBe(false);
+ });
+
+ it('does not call getVersion again when popover is reopened', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
+ (component as any).toggleInfo();
+ (component as any).toggleInfo();
+ (component as any).toggleInfo();
+
+ expect(versionApiMock.getVersion).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets apiVersion to — on API error', () => {
+ versionApiMock.getVersion.mockReturnValue(throwError(() => new Error('network')));
+
+ (component as any).toggleInfo();
+
+ expect((component as any).apiVersion()).toBe('—');
+ });
+
+ it('closeInfo hides the popover', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
+ (component as any).toggleInfo();
+ (component as any).closeInfo();
+
+ expect((component as any).showInfo()).toBe(false);
+ });
+
+ it('versionMismatch is false when versions match', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: (component as any).version }));
+ (component as any).toggleInfo();
+
+ expect((component as any).versionMismatch()).toBe(false);
+ });
+
+ it('versionMismatch is true when versions differ', () => {
+ versionApiMock.getVersion.mockReturnValue(of({ version: '99.99.99' }));
+ (component as any).toggleInfo();
+
+ expect((component as any).versionMismatch()).toBe(true);
+ });
+
+ it('versionMismatch is false before API responds', () => {
+ expect((component as any).versionMismatch()).toBe(false);
+ });
+ });
});
diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts
index f251149..796be6c 100644
--- a/src/app/menu/menu.ts
+++ b/src/app/menu/menu.ts
@@ -1,8 +1,9 @@
-import { Component, computed, inject } from '@angular/core';
+import { Component, computed, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../package.json';
import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
+import { VersionApiService } from '../version/version-api.service';
@Component({
selector: 'app-menu',
@@ -14,6 +15,14 @@ export class Menu {
protected readonly version = version;
protected readonly keycloak = inject(KeycloakService);
protected readonly projectContext = inject(ProjectContextService);
+ private readonly versionApi = inject(VersionApiService);
+
+ protected readonly showInfo = signal(false);
+ protected readonly apiVersion = signal(null);
+ protected readonly versionMismatch = computed(() => {
+ const api = this.apiVersion();
+ return api !== null && api !== this.version;
+ });
protected readonly projectMenuItems = computed(() => {
const pid = this.projectContext.projectId();
@@ -26,6 +35,20 @@ export class Menu {
];
});
+ protected toggleInfo(): void {
+ if (!this.showInfo() && this.apiVersion() === null) {
+ this.versionApi.getVersion().subscribe({
+ next: (r) => this.apiVersion.set(r.version),
+ error: () => this.apiVersion.set('—'),
+ });
+ }
+ this.showInfo.update((v) => !v);
+ }
+
+ protected closeInfo(): void {
+ this.showInfo.set(false);
+ }
+
protected logout(): void {
this.keycloak.logout();
}
diff --git a/src/app/version/version-api.service.spec.ts b/src/app/version/version-api.service.spec.ts
new file mode 100644
index 0000000..5c86db8
--- /dev/null
+++ b/src/app/version/version-api.service.spec.ts
@@ -0,0 +1,37 @@
+import { HttpClient } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { vi } from 'vitest';
+import { VersionApiService } from './version-api.service';
+
+describe('VersionApiService', () => {
+ let service: VersionApiService;
+ let httpGetMock: ReturnType;
+
+ beforeEach(() => {
+ httpGetMock = vi.fn();
+
+ TestBed.configureTestingModule({
+ providers: [
+ VersionApiService,
+ { provide: HttpClient, useValue: { get: httpGetMock } },
+ ],
+ });
+
+ service = TestBed.inject(VersionApiService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('calls GET /api/version and returns the response', () => {
+ httpGetMock.mockReturnValue(of({ version: '1.2.3' }));
+
+ let result: any;
+ service.getVersion().subscribe((r) => (result = r));
+
+ expect(httpGetMock).toHaveBeenCalledWith('/api/version');
+ expect(result).toEqual({ version: '1.2.3' });
+ });
+});
diff --git a/src/app/version/version-api.service.ts b/src/app/version/version-api.service.ts
new file mode 100644
index 0000000..9fe986f
--- /dev/null
+++ b/src/app/version/version-api.service.ts
@@ -0,0 +1,18 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+
+const API_BASE_URL = '/api';
+
+export interface VersionResponse {
+ version: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class VersionApiService {
+ private readonly http = inject(HttpClient);
+
+ getVersion(): Observable {
+ return this.http.get(`${API_BASE_URL}/version`);
+ }
+}