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 { 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<FirstSignalResponse>(`${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`, {
|
||||
.get<FirstSignalResponse>(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<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 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',
|
||||
|
||||
@@ -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
|
||||
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<PackListResponse> {
|
||||
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<PackListResponse>(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<JobEnginePackListResponse>(this.baseUrl, { params }).pipe(
|
||||
map((response) => mapPackListResponse(response))
|
||||
);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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> {
|
||||
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> {
|
||||
return this.http.get<PackListResponse>(`${this.baseUrl}/search`, {
|
||||
return this.http.get<JobEnginePackSearchResponse>(`${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<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> {
|
||||
const body = version ? { version } : {};
|
||||
return this.http.post<CompatibilityResult>(`${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<Pack> {
|
||||
const body = version ? { version } : {};
|
||||
return this.http.post<Pack>(`${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<Pack> {
|
||||
const body = version ? { version } : {};
|
||||
return this.http.post<Pack>(`${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<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) {
|
||||
<div class="empty-state">
|
||||
No error distribution data
|
||||
Distribution data will appear after new failures are recorded.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user