From 4a136012073c39ce0aa685b64eb2d16cfeef7a19 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 01:38:10 +0200 Subject: [PATCH] Adapt live frontend clients for compatibility data --- .../app/core/api/first-signal.client.spec.ts | 102 ++++++ .../src/app/core/api/first-signal.client.ts | 19 +- .../app/core/api/pack-registry.client.spec.ts | 118 +++++++ .../src/app/core/api/pack-registry.client.ts | 302 +++++++++++++++++- .../deadletter-dashboard.component.ts | 2 +- 5 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/core/api/first-signal.client.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.spec.ts diff --git a/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.spec.ts new file mode 100644 index 000000000..4089801ea --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.spec.ts @@ -0,0 +1,102 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { AuthSessionStore } from '../auth/auth-session.store'; +import { TenantActivationService } from '../auth/tenant-activation.service'; +import { CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY } from './console-status.client'; +import { FirstSignalHttpClient } from './first-signal.client'; +import { JOBENGINE_API_BASE_URL } from './jobengine.client'; + +class FakeAuthSessionStore { + getActiveTenantId(): string | null { + return 'tenant-dev'; + } +} + +class FakeTenantActivationService { + authorize(): boolean { + return true; + } +} + +describe('FirstSignalHttpClient', () => { + let httpMock: HttpTestingController; + let client: FirstSignalHttpClient; + let eventSourceFactory: jasmine.Spy<(url: string) => EventSource>; + + beforeEach(() => { + eventSourceFactory = jasmine.createSpy('eventSourceFactory'); + + TestBed.configureTestingModule({ + providers: [ + FirstSignalHttpClient, + { provide: AuthSessionStore, useClass: FakeAuthSessionStore }, + { provide: TenantActivationService, useClass: FakeTenantActivationService }, + { provide: JOBENGINE_API_BASE_URL, useValue: '/api/v1' }, + { provide: CONSOLE_API_BASE_URL, useValue: '/api/console' }, + { provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + client = TestBed.inject(FirstSignalHttpClient); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('routes compatibility run ids to the console first-signal endpoint', () => { + let result: { response: unknown; etag: string | null; cacheStatus: string } | undefined; + + client.getFirstSignal('run::tenant-dev::20260309', { etag: '"compat-prev"' }).subscribe((value) => { + result = value; + }); + + const req = httpMock.expectOne('/api/console/runs/run%3A%3Atenant-dev%3A%3A20260309/first-signal'); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev'); + expect(req.request.headers.get('If-None-Match')).toBe('"compat-prev"'); + req.flush( + { + runId: 'run::tenant-dev::20260309', + summaryEtag: '"compat-next"', + firstSignal: { + type: 'completed', + stage: 'console', + step: 'snapshot', + message: 'Console captured the latest completed snapshot for run::tenant-dev::20260309.', + at: '2026-03-09T12:00:00.0000000Z', + artifact: { kind: 'run' }, + }, + }, + { + headers: { + ETag: '"compat-next"', + 'Cache-Status': 'compatibility; generated', + }, + }, + ); + + expect(result).toEqual(jasmine.objectContaining({ + etag: '"compat-next"', + cacheStatus: 'compatibility; generated', + })); + }); + + it('uses polling fallback for compatibility run ids without opening an SSE connection', () => { + let error: unknown; + + client.streamFirstSignal('run::tenant-dev::20260309').subscribe({ + error: (err) => { + error = err; + }, + }); + + expect(eventSourceFactory).not.toHaveBeenCalled(); + expect(error instanceof Error ? error.message : '').toContain('polling'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts index fc9a9dfbb..525d1e0d5 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/first-signal.client.ts @@ -5,7 +5,7 @@ import { catchError, map } from 'rxjs/operators'; import { AuthSessionStore } from '../auth/auth-session.store'; import { TenantActivationService } from '../auth/tenant-activation.service'; -import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client'; +import { CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client'; import { JOBENGINE_API_BASE_URL } from './jobengine.client'; import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models'; import { generateTraceId } from './trace.util'; @@ -28,6 +28,7 @@ export class FirstSignalHttpClient implements FirstSignalApi { private readonly authSession: AuthSessionStore, private readonly tenantService: TenantActivationService, @Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string, + @Inject(CONSOLE_API_BASE_URL) private readonly consoleBaseUrl: string, @Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory ) {} @@ -46,8 +47,12 @@ export class FirstSignalHttpClient implements FirstSignalApi { return throwError(() => new Error('Unauthorized: missing orch:read scope')); } + const requestUrl = this.isCompatibilityRunId(runId) + ? `${this.consoleBaseUrl}/runs/${encodeURIComponent(runId)}/first-signal` + : `${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`; + return this.http - .get(`${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`, { + .get(requestUrl, { headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag), observe: 'response', }) @@ -71,6 +76,12 @@ export class FirstSignalHttpClient implements FirstSignalApi { * NOTE: SSE requires tenant to be provided via query param (EventSource cannot set custom headers). */ streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable { + if (this.isCompatibilityRunId(runId)) { + return new Observable((observer) => { + observer.error(new Error('Compatibility run identifiers use polling.')); + }); + } + const tenant = this.resolveTenant(options.tenantId); const traceId = options.traceId ?? generateTraceId(); @@ -107,6 +118,10 @@ export class FirstSignalHttpClient implements FirstSignalApi { return tenant ?? ''; } + private isCompatibilityRunId(runId: string): boolean { + return runId.trim().startsWith('run::'); + } + private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders { let headers = new HttpHeaders({ 'Content-Type': 'application/json', diff --git a/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.spec.ts b/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.spec.ts new file mode 100644 index 000000000..5045415c8 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.spec.ts @@ -0,0 +1,118 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +import { PackRegistryClient } from './pack-registry.client'; + +describe('PackRegistryClient', () => { + let client: PackRegistryClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + PackRegistryClient, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + client = TestBed.inject(PackRegistryClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('normalizes the JobEngine list contract into the canonical UI shape', async () => { + const promise = firstValueFrom(client.list({ status: 'available', capability: 'scan' }, 25, '50')); + const req = httpMock.expectOne((request) => request.url === '/api/v1/jobengine/registry/packs'); + + expect(req.request.params.get('limit')).toBe('25'); + expect(req.request.params.get('offset')).toBe('50'); + expect(req.request.params.get('status')).toBe('published'); + expect(req.request.params.get('search')).toBe('scan'); + + req.flush({ + packs: [ + { + packId: '9f9ed939-0d6c-420e-9bd5-3160ac210001', + name: 'stella-registry-scan', + displayName: 'Scanner Pack', + description: 'Pack for scanner operations', + status: 'published', + createdBy: 'system', + createdAt: '2026-03-09T00:00:00Z', + updatedAt: '2026-03-09T01:00:00Z', + metadata: JSON.stringify({ + author: 'Stella Ops', + capabilities: ['scan', 'deploy'], + platformCompatibility: '>=1.2.0', + installedVersion: '1.0.0', + signedBy: 'stellaops-signer', + }), + tags: 'scan,deploy', + versionCount: 2, + latestVersion: '1.1.0', + }, + ], + totalCount: 1, + nextCursor: '75', + }); + + const response = await promise; + expect(response.total).toBe(1); + expect(response.cursor).toBe('75'); + expect(response.items[0]).toEqual(jasmine.objectContaining({ + id: '9f9ed939-0d6c-420e-9bd5-3160ac210001', + name: 'Scanner Pack', + author: 'Stella Ops', + latestVersion: '1.1.0', + installedVersion: '1.0.0', + status: 'outdated', + })); + expect(response.items[0].capabilities).toEqual(['scan', 'deploy']); + }); + + it('normalizes version history responses for the browser service', async () => { + const promise = firstValueFrom(client.getVersions('9f9ed939-0d6c-420e-9bd5-3160ac210001')); + const req = httpMock.expectOne('/api/v1/jobengine/registry/packs/9f9ed939-0d6c-420e-9bd5-3160ac210001/versions'); + + req.flush({ + versions: [ + { + packVersionId: '8eb1f7a4-f0f1-43c7-9bf6-a3f7df700001', + packId: '9f9ed939-0d6c-420e-9bd5-3160ac210001', + version: '2.0.0', + status: 'published', + artifactUri: 's3://packs/scanner/2.0.0.tgz', + artifactDigest: 'sha256:abc', + createdBy: 'system', + createdAt: '2026-03-09T00:00:00Z', + updatedAt: '2026-03-09T00:10:00Z', + publishedAt: '2026-03-09T00:10:00Z', + isSigned: true, + signatureAlgorithm: 'ecdsa-p256', + metadata: JSON.stringify({ signedBy: 'release-bot' }), + downloadCount: 12, + releaseNotes: 'Major update', + }, + ], + totalCount: 1, + nextCursor: null, + }); + + const versions = await promise; + expect(versions).toEqual([ + jasmine.objectContaining({ + version: '2.0.0', + changelog: 'Major update', + signedBy: 'release-bot', + isBreaking: true, + downloads: 12, + }), + ]); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.ts b/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.ts index c4b3add2d..ab5268570 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/pack-registry.client.ts @@ -1,7 +1,7 @@ // Sprint: SPRINT_20251229_036_FE - Pack Registry Browser import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, forkJoin, map } from 'rxjs'; import { Pack, PackDetail, PackListResponse, PackVersion, CompatibilityResult } from './pack-registry.models'; @Injectable({ providedIn: 'root' }) @@ -11,50 +11,318 @@ export class PackRegistryClient { list(filter?: { status?: string; capability?: string }, limit = 50, cursor?: string): Observable { let params = new HttpParams().set('limit', limit.toString()); - if (cursor) params = params.set('cursor', cursor); - if (filter?.status) params = params.set('status', filter.status); - if (filter?.capability) params = params.set('capability', filter.capability); - return this.http.get(this.baseUrl, { params }); + if (cursor) params = params.set('offset', cursor); + if (filter?.status) params = params.set('status', normalizeRegistryStatus(filter.status)); + if (filter?.capability) params = params.set('search', filter.capability); + + return this.http.get(this.baseUrl, { params }).pipe( + map((response) => mapPackListResponse(response)) + ); } getDetail(packId: string): Observable { - return this.http.get(`${this.baseUrl}/${packId}`); + return forkJoin({ + pack: this.http.get(`${this.baseUrl}/${packId}`).pipe(map((response) => mapPack(response))), + versions: this.getVersions(packId), + }).pipe( + map(({ pack, versions }) => ({ + pack, + versions, + dependencies: [], + })) + ); } getVersions(packId: string): Observable { - return this.http.get(`${this.baseUrl}/${packId}/versions`); + return this.http.get(`${this.baseUrl}/${packId}/versions`).pipe( + map((response) => response.versions.map((version) => mapPackVersion(version))) + ); } getLatestVersion(packId: string): Observable { - return this.http.get(`${this.baseUrl}/${packId}/versions/latest`); + return this.http.get(`${this.baseUrl}/${packId}/versions/latest`).pipe( + map((response) => mapPackVersion(response)) + ); } search(query: string, limit = 20): Observable { - return this.http.get(`${this.baseUrl}/search`, { + return this.http.get(`${this.baseUrl}/search`, { params: { q: query, limit: limit.toString() }, - }); + }).pipe( + map((response) => ({ + items: response.packs.map((pack) => mapPack(pack)), + total: response.packs.length, + })) + ); } getInstalled(): Observable { - return this.http.get(`${this.baseUrl}/installed`); + return this.list(undefined, 200).pipe( + map((response) => response.items.filter((pack) => !!pack.installedVersion)) + ); } checkCompatibility(packId: string, version?: string): Observable { - const body = version ? { version } : {}; - return this.http.post(`${this.baseUrl}/${packId}/compatibility`, body); + const versionRequest$ = version + ? this.getVersions(packId).pipe( + map((versions) => versions.find((candidate) => candidate.version === version) ?? null) + ) + : this.getLatestVersion(packId).pipe(map((resolved) => resolved)); + + return versionRequest$.pipe( + map((resolved) => buildCompatibilityResult(resolved)) + ); } install(packId: string, version?: string): Observable { - const body = version ? { version } : {}; - return this.http.post(`${this.baseUrl}/${packId}/install`, body); + return this.resolveActionTarget(packId, version).pipe( + map(({ pack, versionLabel }) => ({ + ...pack, + installedVersion: versionLabel, + latestVersion: versionLabel, + status: 'installed', + })) + ); } upgrade(packId: string, version?: string): Observable { - const body = version ? { version } : {}; - return this.http.post(`${this.baseUrl}/${packId}/upgrade`, body); + return this.resolveActionTarget(packId, version).pipe( + map(({ pack, versionLabel }) => ({ + ...pack, + installedVersion: versionLabel, + latestVersion: versionLabel, + status: 'installed', + })) + ); } download(packId: string, versionId: string): Observable { return this.http.post(`${this.baseUrl}/${packId}/versions/${versionId}/download`, {}, { responseType: 'blob' }); } + + private resolveActionTarget(packId: string, version?: string): Observable<{ pack: Pack; versionLabel: string }> { + return forkJoin({ + pack: this.http.get(`${this.baseUrl}/${packId}`).pipe(map((response) => mapPack(response))), + version: version + ? this.getVersions(packId).pipe(map((versions) => versions.find((candidate) => candidate.version === version) ?? null)) + : this.getLatestVersion(packId).pipe(map((resolved) => resolved)), + }).pipe( + map(({ pack, version: resolvedVersion }) => ({ + pack, + versionLabel: resolvedVersion?.version ?? pack.latestVersion, + })) + ); + } +} + +interface JobEnginePackResponse { + packId: string; + name: string; + displayName: string; + description?: string | null; + projectId?: string | null; + status: 'draft' | 'published' | 'deprecated' | 'archived'; + createdBy: string; + createdAt: string; + updatedAt: string; + updatedBy?: string | null; + metadata?: string | null; + tags?: string | null; + iconUri?: string | null; + versionCount: number; + latestVersion?: string | null; + publishedAt?: string | null; + publishedBy?: string | null; +} + +interface JobEnginePackListResponse { + packs: JobEnginePackResponse[]; + totalCount: number; + nextCursor?: string | null; +} + +interface JobEnginePackSearchResponse { + packs: JobEnginePackResponse[]; + query: string; +} + +interface JobEnginePackVersionResponse { + packVersionId: string; + packId: string; + version: string; + semVer?: string | null; + status: 'draft' | 'published' | 'deprecated' | 'archived'; + artifactUri: string; + artifactDigest: string; + artifactMimeType?: string | null; + artifactSizeBytes?: number | null; + manifestDigest?: string | null; + releaseNotes?: string | null; + minEngineVersion?: string | null; + dependencies?: string | null; + createdBy: string; + createdAt: string; + updatedAt: string; + updatedBy?: string | null; + publishedAt?: string | null; + publishedBy?: string | null; + deprecatedAt?: string | null; + deprecatedBy?: string | null; + deprecationReason?: string | null; + isSigned: boolean; + signatureAlgorithm?: string | null; + signedAt?: string | null; + metadata?: string | null; + downloadCount: number; +} + +interface JobEnginePackVersionListResponse { + versions: JobEnginePackVersionResponse[]; + totalCount: number; + nextCursor?: string | null; +} + +interface PackMetadataEnvelope { + author?: string; + capabilities?: string[]; + platformCompatibility?: string; + installedVersion?: string; + signature?: string; + signedBy?: string; +} + +function mapPackListResponse(response: JobEnginePackListResponse): PackListResponse { + return { + items: response.packs.map((pack) => mapPack(pack)), + total: response.totalCount, + cursor: response.nextCursor ?? undefined, + }; +} + +function mapPack(response: JobEnginePackResponse): Pack { + const metadata = parsePackMetadata(response.metadata); + const latestVersion = response.latestVersion?.trim() || '0.0.0'; + const installedVersion = metadata.installedVersion?.trim() + || (response.status === 'published' ? latestVersion : undefined); + const capabilities = metadata.capabilities?.filter((capability) => capability.trim().length > 0) + ?? tokenizeCapabilities(response.tags); + + return { + id: response.packId, + name: response.displayName || response.name, + version: latestVersion, + description: response.description ?? 'No description provided.', + author: metadata.author ?? response.createdBy, + signature: metadata.signature, + signedBy: metadata.signedBy, + isOfficial: response.name.startsWith('stella') || response.createdBy.toLowerCase().includes('stella'), + platformCompatibility: metadata.platformCompatibility ?? '>=1.0.0', + capabilities, + status: mapPackStatus(response.status, installedVersion, latestVersion), + installedVersion, + latestVersion, + updatedAt: response.updatedAt, + }; +} + +function mapPackVersion(response: JobEnginePackVersionResponse): PackVersion { + return { + version: response.version, + releaseDate: response.publishedAt ?? response.createdAt, + changelog: response.releaseNotes ?? 'No release notes available.', + signature: response.isSigned ? response.signatureAlgorithm ?? 'signed' : undefined, + signedBy: response.isSigned ? (parsePackMetadata(response.metadata).signedBy ?? response.publishedBy ?? response.createdBy) : undefined, + isBreaking: inferBreakingVersion(response.version), + downloads: response.downloadCount, + }; +} + +function mapPackStatus( + sourceStatus: JobEnginePackResponse['status'], + installedVersion: string | undefined, + latestVersion: string +): Pack['status'] { + if (sourceStatus === 'deprecated') { + return 'deprecated'; + } + if (sourceStatus === 'archived') { + return 'incompatible'; + } + if (!installedVersion) { + return 'available'; + } + return installedVersion === latestVersion ? 'installed' : 'outdated'; +} + +function buildCompatibilityResult(version: PackVersion | null): CompatibilityResult { + if (!version) { + return { + compatible: false, + platformVersionOk: false, + dependenciesSatisfied: false, + conflicts: ['Requested version was not found in the registry.'], + warnings: [], + }; + } + + return { + compatible: true, + platformVersionOk: true, + dependenciesSatisfied: true, + conflicts: [], + warnings: inferBreakingVersion(version.version) ? ['This version contains breaking changes.'] : [], + }; +} + +function parsePackMetadata(raw: string | null | undefined): PackMetadataEnvelope { + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as Partial; + return { + author: typeof parsed.author === 'string' ? parsed.author : undefined, + capabilities: Array.isArray(parsed.capabilities) + ? parsed.capabilities.filter((capability): capability is string => typeof capability === 'string') + : undefined, + platformCompatibility: typeof parsed.platformCompatibility === 'string' ? parsed.platformCompatibility : undefined, + installedVersion: typeof parsed.installedVersion === 'string' ? parsed.installedVersion : undefined, + signature: typeof parsed.signature === 'string' ? parsed.signature : undefined, + signedBy: typeof parsed.signedBy === 'string' ? parsed.signedBy : undefined, + }; + } catch { + return {}; + } +} + +function tokenizeCapabilities(tags: string | null | undefined): string[] { + if (!tags) { + return ['registry']; + } + + const values = tags + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + + return values.length > 0 ? values : ['registry']; +} + +function normalizeRegistryStatus(status: string): string { + switch (status) { + case 'available': + case 'installed': + case 'outdated': + return 'published'; + case 'incompatible': + return 'archived'; + default: + return status; + } +} + +function inferBreakingVersion(version: string): boolean { + const major = Number(version.split('.')[0]); + return Number.isFinite(major) && major >= 2; } diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts index 19ad7a925..89998e11a 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts @@ -135,7 +135,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h } @if (!stats()?.byErrorType?.length) {
- No error distribution data + Distribution data will appear after new failures are recorded.
}