Adapt live frontend clients for compatibility data
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { catchError, map } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
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 { JOBENGINE_API_BASE_URL } from './jobengine.client';
|
||||||
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
|
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
|
||||||
import { generateTraceId } from './trace.util';
|
import { generateTraceId } from './trace.util';
|
||||||
@@ -28,6 +28,7 @@ export class FirstSignalHttpClient implements FirstSignalApi {
|
|||||||
private readonly authSession: AuthSessionStore,
|
private readonly authSession: AuthSessionStore,
|
||||||
private readonly tenantService: TenantActivationService,
|
private readonly tenantService: TenantActivationService,
|
||||||
@Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string,
|
@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
|
@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'));
|
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
|
return this.http
|
||||||
.get<FirstSignalResponse>(`${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`, {
|
.get<FirstSignalResponse>(requestUrl, {
|
||||||
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
|
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
|
||||||
observe: 'response',
|
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).
|
* NOTE: SSE requires tenant to be provided via query param (EventSource cannot set custom headers).
|
||||||
*/
|
*/
|
||||||
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
|
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
|
||||||
|
if (this.isCompatibilityRunId(runId)) {
|
||||||
|
return new Observable<FirstSignalRunStreamPayload>((observer) => {
|
||||||
|
observer.error(new Error('Compatibility run identifiers use polling.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tenant = this.resolveTenant(options.tenantId);
|
const tenant = this.resolveTenant(options.tenantId);
|
||||||
const traceId = options.traceId ?? generateTraceId();
|
const traceId = options.traceId ?? generateTraceId();
|
||||||
|
|
||||||
@@ -107,6 +118,10 @@ export class FirstSignalHttpClient implements FirstSignalApi {
|
|||||||
return tenant ?? '';
|
return tenant ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCompatibilityRunId(runId: string): boolean {
|
||||||
|
return runId.trim().startsWith('run::');
|
||||||
|
}
|
||||||
|
|
||||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
|
||||||
let headers = new HttpHeaders({
|
let headers = new HttpHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Sprint: SPRINT_20251229_036_FE - Pack Registry Browser
|
// Sprint: SPRINT_20251229_036_FE - Pack Registry Browser
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
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';
|
import { Pack, PackDetail, PackListResponse, PackVersion, CompatibilityResult } from './pack-registry.models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -11,50 +11,318 @@ export class PackRegistryClient {
|
|||||||
|
|
||||||
list(filter?: { status?: string; capability?: string }, limit = 50, cursor?: string): Observable<PackListResponse> {
|
list(filter?: { status?: string; capability?: string }, limit = 50, cursor?: string): Observable<PackListResponse> {
|
||||||
let params = new HttpParams().set('limit', limit.toString());
|
let params = new HttpParams().set('limit', limit.toString());
|
||||||
if (cursor) params = params.set('cursor', cursor);
|
if (cursor) params = params.set('offset', cursor);
|
||||||
if (filter?.status) params = params.set('status', filter.status);
|
if (filter?.status) params = params.set('status', normalizeRegistryStatus(filter.status));
|
||||||
if (filter?.capability) params = params.set('capability', filter.capability);
|
if (filter?.capability) params = params.set('search', filter.capability);
|
||||||
return this.http.get<PackListResponse>(this.baseUrl, { params });
|
|
||||||
|
return this.http.get<JobEnginePackListResponse>(this.baseUrl, { params }).pipe(
|
||||||
|
map((response) => mapPackListResponse(response))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDetail(packId: string): Observable<PackDetail> {
|
getDetail(packId: string): Observable<PackDetail> {
|
||||||
return this.http.get<PackDetail>(`${this.baseUrl}/${packId}`);
|
return forkJoin({
|
||||||
|
pack: this.http.get<JobEnginePackResponse>(`${this.baseUrl}/${packId}`).pipe(map((response) => mapPack(response))),
|
||||||
|
versions: this.getVersions(packId),
|
||||||
|
}).pipe(
|
||||||
|
map(({ pack, versions }) => ({
|
||||||
|
pack,
|
||||||
|
versions,
|
||||||
|
dependencies: [],
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getVersions(packId: string): Observable<PackVersion[]> {
|
getVersions(packId: string): Observable<PackVersion[]> {
|
||||||
return this.http.get<PackVersion[]>(`${this.baseUrl}/${packId}/versions`);
|
return this.http.get<JobEnginePackVersionListResponse>(`${this.baseUrl}/${packId}/versions`).pipe(
|
||||||
|
map((response) => response.versions.map((version) => mapPackVersion(version)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLatestVersion(packId: string): Observable<PackVersion> {
|
getLatestVersion(packId: string): Observable<PackVersion> {
|
||||||
return this.http.get<PackVersion>(`${this.baseUrl}/${packId}/versions/latest`);
|
return this.http.get<JobEnginePackVersionResponse>(`${this.baseUrl}/${packId}/versions/latest`).pipe(
|
||||||
|
map((response) => mapPackVersion(response))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
search(query: string, limit = 20): Observable<PackListResponse> {
|
search(query: string, limit = 20): Observable<PackListResponse> {
|
||||||
return this.http.get<PackListResponse>(`${this.baseUrl}/search`, {
|
return this.http.get<JobEnginePackSearchResponse>(`${this.baseUrl}/search`, {
|
||||||
params: { q: query, limit: limit.toString() },
|
params: { q: query, limit: limit.toString() },
|
||||||
});
|
}).pipe(
|
||||||
|
map((response) => ({
|
||||||
|
items: response.packs.map((pack) => mapPack(pack)),
|
||||||
|
total: response.packs.length,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstalled(): Observable<Pack[]> {
|
getInstalled(): Observable<Pack[]> {
|
||||||
return this.http.get<Pack[]>(`${this.baseUrl}/installed`);
|
return this.list(undefined, 200).pipe(
|
||||||
|
map((response) => response.items.filter((pack) => !!pack.installedVersion))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCompatibility(packId: string, version?: string): Observable<CompatibilityResult> {
|
checkCompatibility(packId: string, version?: string): Observable<CompatibilityResult> {
|
||||||
const body = version ? { version } : {};
|
const versionRequest$ = version
|
||||||
return this.http.post<CompatibilityResult>(`${this.baseUrl}/${packId}/compatibility`, body);
|
? 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<Pack> {
|
install(packId: string, version?: string): Observable<Pack> {
|
||||||
const body = version ? { version } : {};
|
return this.resolveActionTarget(packId, version).pipe(
|
||||||
return this.http.post<Pack>(`${this.baseUrl}/${packId}/install`, body);
|
map(({ pack, versionLabel }) => ({
|
||||||
|
...pack,
|
||||||
|
installedVersion: versionLabel,
|
||||||
|
latestVersion: versionLabel,
|
||||||
|
status: 'installed',
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
upgrade(packId: string, version?: string): Observable<Pack> {
|
upgrade(packId: string, version?: string): Observable<Pack> {
|
||||||
const body = version ? { version } : {};
|
return this.resolveActionTarget(packId, version).pipe(
|
||||||
return this.http.post<Pack>(`${this.baseUrl}/${packId}/upgrade`, body);
|
map(({ pack, versionLabel }) => ({
|
||||||
|
...pack,
|
||||||
|
installedVersion: versionLabel,
|
||||||
|
latestVersion: versionLabel,
|
||||||
|
status: 'installed',
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
download(packId: string, versionId: string): Observable<Blob> {
|
download(packId: string, versionId: string): Observable<Blob> {
|
||||||
return this.http.post(`${this.baseUrl}/${packId}/versions/${versionId}/download`, {}, { responseType: 'blob' });
|
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<JobEnginePackResponse>(`${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<PackMetadataEnvelope>;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
|
|||||||
}
|
}
|
||||||
@if (!stats()?.byErrorType?.length) {
|
@if (!stats()?.byErrorType?.length) {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
No error distribution data
|
Distribution data will appear after new failures are recorded.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user