fix(web): unify API base URL resolution and repair frontend service clients
- Introduce resolveApiBaseUrl() helper for consistent URL construction - Fix evidence-pack queries to use public /v1/evidence-packs with runId param - Resolve notify tenant from active context instead of hard-coded override - Gate console run stream on concrete run ID (remove synthetic 'last' token) - Remove unnecessary installed-pack probe from dashboard load - Expand canonical route inventory with investigation and registry surfaces Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,9 @@ const canonicalRoutes = [
|
|||||||
'/releases/hotfixes/new',
|
'/releases/hotfixes/new',
|
||||||
'/releases/environments',
|
'/releases/environments',
|
||||||
'/releases/deployments',
|
'/releases/deployments',
|
||||||
|
'/releases/investigation/timeline',
|
||||||
|
'/releases/investigation/deploy-diff',
|
||||||
|
'/releases/investigation/change-trace',
|
||||||
'/security',
|
'/security',
|
||||||
'/security/posture',
|
'/security/posture',
|
||||||
'/security/triage',
|
'/security/triage',
|
||||||
@@ -54,6 +57,7 @@ const canonicalRoutes = [
|
|||||||
'/evidence',
|
'/evidence',
|
||||||
'/evidence/overview',
|
'/evidence/overview',
|
||||||
'/evidence/capsules',
|
'/evidence/capsules',
|
||||||
|
'/evidence/threads',
|
||||||
'/evidence/verify-replay',
|
'/evidence/verify-replay',
|
||||||
'/evidence/proofs',
|
'/evidence/proofs',
|
||||||
'/evidence/exports',
|
'/evidence/exports',
|
||||||
@@ -88,6 +92,7 @@ const canonicalRoutes = [
|
|||||||
'/ops/integrations/notifications',
|
'/ops/integrations/notifications',
|
||||||
'/ops/integrations/sbom-sources',
|
'/ops/integrations/sbom-sources',
|
||||||
'/ops/integrations/activity',
|
'/ops/integrations/activity',
|
||||||
|
'/ops/integrations/registry-admin',
|
||||||
'/ops/policy',
|
'/ops/policy',
|
||||||
'/ops/policy/overview',
|
'/ops/policy/overview',
|
||||||
'/ops/policy/baselines',
|
'/ops/policy/baselines',
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
NOTIFY_API,
|
NOTIFY_API,
|
||||||
NOTIFY_API_BASE_URL,
|
NOTIFY_API_BASE_URL,
|
||||||
NOTIFY_TENANT_ID,
|
|
||||||
NotifyApiHttpClient,
|
NotifyApiHttpClient,
|
||||||
MockNotifyClient,
|
MockNotifyClient,
|
||||||
} from './core/api/notify.client';
|
} from './core/api/notify.client';
|
||||||
@@ -262,6 +261,28 @@ import {
|
|||||||
MockIdentityProviderClient,
|
MockIdentityProviderClient,
|
||||||
} from './core/api/identity-provider.client';
|
} from './core/api/identity-provider.client';
|
||||||
|
|
||||||
|
function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
|
||||||
|
const normalizedBase = (baseUrl ?? '').trim();
|
||||||
|
|
||||||
|
if (!normalizedBase) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(path, normalizedBase).toString();
|
||||||
|
} catch {
|
||||||
|
if (path.startsWith('/')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseWithoutTrailingSlash = normalizedBase.endsWith('/')
|
||||||
|
? normalizedBase.slice(0, -1)
|
||||||
|
: normalizedBase;
|
||||||
|
|
||||||
|
return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
@@ -306,15 +327,8 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provide: AUTHORITY_CONSOLE_API_BASE_URL,
|
provide: AUTHORITY_CONSOLE_API_BASE_URL,
|
||||||
deps: [AppConfigService],
|
deps: [AppConfigService],
|
||||||
useFactory: (config: AppConfigService) => {
|
useFactory: (config: AppConfigService) => {
|
||||||
const authorityBase = config.config.apiBaseUrls.authority;
|
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||||
try {
|
return resolveApiBaseUrl(gatewayBase, '/console');
|
||||||
return new URL('/console', authorityBase).toString();
|
|
||||||
} catch {
|
|
||||||
const normalized = authorityBase.endsWith('/')
|
|
||||||
? authorityBase.slice(0, -1)
|
|
||||||
: authorityBase;
|
|
||||||
return `${normalized}/console`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AuthorityConsoleApiHttpClient,
|
AuthorityConsoleApiHttpClient,
|
||||||
@@ -539,7 +553,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
deps: [AppConfigService],
|
deps: [AppConfigService],
|
||||||
useFactory: (config: AppConfigService) => {
|
useFactory: (config: AppConfigService) => {
|
||||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||||
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
return resolveApiBaseUrl(gatewayBase, '/api/v1');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OrchestratorHttpClient,
|
OrchestratorHttpClient,
|
||||||
@@ -617,12 +631,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
deps: [AppConfigService],
|
deps: [AppConfigService],
|
||||||
useFactory: (config: AppConfigService) => {
|
useFactory: (config: AppConfigService) => {
|
||||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||||
try {
|
return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs');
|
||||||
return new URL('/v1/runs', gatewayBase).toString();
|
|
||||||
} catch {
|
|
||||||
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
|
||||||
return `${normalized}/v1/runs`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AiRunsHttpClient,
|
AiRunsHttpClient,
|
||||||
@@ -635,25 +644,14 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provide: CONSOLE_API_BASE_URL,
|
provide: CONSOLE_API_BASE_URL,
|
||||||
deps: [AppConfigService],
|
deps: [AppConfigService],
|
||||||
useFactory: (config: AppConfigService) => {
|
useFactory: (config: AppConfigService) => {
|
||||||
const authorityBase = config.config.apiBaseUrls.authority;
|
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||||
try {
|
return resolveApiBaseUrl(gatewayBase, '/console');
|
||||||
return new URL('/console', authorityBase).toString();
|
|
||||||
} catch {
|
|
||||||
const normalized = authorityBase.endsWith('/')
|
|
||||||
? authorityBase.slice(0, -1)
|
|
||||||
: authorityBase;
|
|
||||||
return `${normalized}/console`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: EVENT_SOURCE_FACTORY,
|
provide: EVENT_SOURCE_FACTORY,
|
||||||
useValue: DEFAULT_EVENT_SOURCE_FACTORY,
|
useValue: DEFAULT_EVENT_SOURCE_FACTORY,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: NOTIFY_TENANT_ID,
|
|
||||||
useValue: 'tenant-dev',
|
|
||||||
},
|
|
||||||
NotifyApiHttpClient,
|
NotifyApiHttpClient,
|
||||||
MockNotifyClient,
|
MockNotifyClient,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
|
import { EvidencePackHttpClient, EVIDENCE_PACK_API_BASE_URL } from './evidence-pack.client';
|
||||||
|
|
||||||
|
class FakeAuthSessionStore {
|
||||||
|
getActiveTenantId(): string | null {
|
||||||
|
return 'demo-prod';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EvidencePackHttpClient', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let client: EvidencePackHttpClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
EvidencePackHttpClient,
|
||||||
|
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||||
|
{ provide: EVIDENCE_PACK_API_BASE_URL, useValue: '/v1/evidence-packs' },
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
client = TestBed.inject(EvidencePackHttpClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists evidence packs for a run through the public collection route', () => {
|
||||||
|
client.listByRun('run-42', { traceId: 'trace-ep-42' }).subscribe((response) => {
|
||||||
|
expect(response.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((pending) => pending.url === '/v1/evidence-packs');
|
||||||
|
expect(request.request.method).toBe('GET');
|
||||||
|
expect(request.request.params.get('runId')).toBe('run-42');
|
||||||
|
expect(request.request.headers.get('X-StellaOps-Tenant')).toBe('demo-prod');
|
||||||
|
expect(request.request.headers.get('X-Stella-Trace-Id')).toBe('trace-ep-42');
|
||||||
|
request.flush({ count: 0, packs: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,8 +91,9 @@ export class EvidencePackHttpClient implements EvidencePackApi {
|
|||||||
|
|
||||||
listByRun(runId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
|
listByRun(runId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
|
||||||
const traceId = options.traceId ?? generateTraceId();
|
const traceId = options.traceId ?? generateTraceId();
|
||||||
|
const params = this.buildQueryParams({ runId });
|
||||||
return this.http
|
return this.http
|
||||||
.get<EvidencePackListResponse>(`/v1/runs/${runId}/evidence-packs`, { headers: this.buildHeaders(traceId) })
|
.get<EvidencePackListResponse>(this.baseUrl, { headers: this.buildHeaders(traceId), params })
|
||||||
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
.pipe(catchError((err) => throwError(() => this.mapError(err, traceId))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
src/Web/StellaOps.Web/src/app/core/api/notify.client.spec.ts
Normal file
68
src/Web/StellaOps.Web/src/app/core/api/notify.client.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||||
|
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||||
|
import { NOTIFY_API_BASE_URL, NOTIFY_TENANT_ID, NotifyApiHttpClient } from './notify.client';
|
||||||
|
|
||||||
|
class FakeAuthSessionStore {
|
||||||
|
getActiveTenantId(): string | null {
|
||||||
|
return 'session-tenant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTenantActivationService {
|
||||||
|
activeTenantId(): string | null {
|
||||||
|
return 'demo-prod';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotifyApiHttpClient', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let client: NotifyApiHttpClient;
|
||||||
|
|
||||||
|
function configure(optionalProviders: unknown[] = []): void {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
NotifyApiHttpClient,
|
||||||
|
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||||
|
{ provide: TenantActivationService, useClass: FakeTenantActivationService },
|
||||||
|
{ provide: NOTIFY_API_BASE_URL, useValue: '/api/v1/notify' },
|
||||||
|
...optionalProviders,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
client = TestBed.inject(NotifyApiHttpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (httpMock) {
|
||||||
|
httpMock.verify();
|
||||||
|
}
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the active tenant context when no static override is supplied', () => {
|
||||||
|
configure();
|
||||||
|
|
||||||
|
client.listChannels().subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne('/api/v1/notify/channels');
|
||||||
|
expect(request.request.headers.get('X-StellaOps-Tenant')).toBe('demo-prod');
|
||||||
|
request.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an explicit tenant override when one is supplied', () => {
|
||||||
|
configure([{ provide: NOTIFY_TENANT_ID, useValue: 'tenant-explicit' }]);
|
||||||
|
|
||||||
|
client.listRules().subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne('/api/v1/notify/rules');
|
||||||
|
expect(request.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-explicit');
|
||||||
|
request.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -362,15 +362,16 @@ export class NotifyApiHttpClient implements NotifyApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildHeaders(): HttpHeaders {
|
private buildHeaders(): HttpHeaders {
|
||||||
if (!this.tenantId) {
|
const tenant = this.resolveTenantId();
|
||||||
|
if (!tenant) {
|
||||||
return new HttpHeaders();
|
return new HttpHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
|
return new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildHeadersWithTrace(traceId: string): HttpHeaders {
|
private buildHeadersWithTrace(traceId: string): HttpHeaders {
|
||||||
const tenant = this.tenantId || this.authSession.getActiveTenantId() || '';
|
const tenant = this.resolveTenantId();
|
||||||
return new HttpHeaders({
|
return new HttpHeaders({
|
||||||
'X-StellaOps-Tenant': tenant,
|
'X-StellaOps-Tenant': tenant,
|
||||||
'X-Stella-Trace-Id': traceId,
|
'X-Stella-Trace-Id': traceId,
|
||||||
@@ -379,6 +380,10 @@ export class NotifyApiHttpClient implements NotifyApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveTenantId(): string {
|
||||||
|
return this.tenantId || this.tenantService.activeTenantId() || this.authSession.getActiveTenantId() || '';
|
||||||
|
}
|
||||||
|
|
||||||
private buildPaginationParams(options: NotifyQueryOptions): HttpParams {
|
private buildPaginationParams(options: NotifyQueryOptions): HttpParams {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (options.pageToken) {
|
if (options.pageToken) {
|
||||||
@@ -720,4 +725,3 @@ export class MockNotifyClient implements NotifyApi {
|
|||||||
}).pipe(delay(100));
|
}).pipe(delay(100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { firstValueFrom, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { PackRegistryClient } from './pack-registry.client';
|
||||||
|
import type { Pack, PackListResponse } from './pack-registry.models';
|
||||||
|
import { PackRegistryBrowserService } from '../../features/pack-registry/services/pack-registry-browser.service';
|
||||||
|
|
||||||
|
describe('PackRegistryBrowserService', () => {
|
||||||
|
let service: PackRegistryBrowserService;
|
||||||
|
let client: {
|
||||||
|
list: jasmine.Spy;
|
||||||
|
getInstalled: jasmine.Spy;
|
||||||
|
getVersions: jasmine.Spy;
|
||||||
|
checkCompatibility: jasmine.Spy;
|
||||||
|
install: jasmine.Spy;
|
||||||
|
upgrade: jasmine.Spy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listedPacks: Pack[] = [
|
||||||
|
{
|
||||||
|
id: 'pack-alpha',
|
||||||
|
name: 'Alpha Pack',
|
||||||
|
version: '1.2.0',
|
||||||
|
description: 'Alpha pack',
|
||||||
|
author: 'Stella Ops',
|
||||||
|
isOfficial: true,
|
||||||
|
platformCompatibility: '>=1.0.0',
|
||||||
|
capabilities: ['deploy', 'scan'],
|
||||||
|
status: 'installed',
|
||||||
|
installedVersion: '1.1.0',
|
||||||
|
latestVersion: '1.2.0',
|
||||||
|
updatedAt: '2026-03-09T08:00:00Z',
|
||||||
|
signature: 'sig-alpha',
|
||||||
|
signedBy: 'stellaops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pack-beta',
|
||||||
|
name: 'Beta Pack',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'Beta pack',
|
||||||
|
author: 'Stella Ops',
|
||||||
|
isOfficial: true,
|
||||||
|
platformCompatibility: '>=1.0.0',
|
||||||
|
capabilities: ['observe'],
|
||||||
|
status: 'available',
|
||||||
|
latestVersion: '2.0.0',
|
||||||
|
updatedAt: '2026-03-09T09:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = {
|
||||||
|
list: jasmine.createSpy('list'),
|
||||||
|
getInstalled: jasmine.createSpy('getInstalled'),
|
||||||
|
getVersions: jasmine.createSpy('getVersions'),
|
||||||
|
checkCompatibility: jasmine.createSpy('checkCompatibility'),
|
||||||
|
install: jasmine.createSpy('install'),
|
||||||
|
upgrade: jasmine.createSpy('upgrade'),
|
||||||
|
};
|
||||||
|
client.list.and.returnValue(of<PackListResponse>({ items: listedPacks, total: listedPacks.length }));
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
PackRegistryBrowserService,
|
||||||
|
{ provide: PackRegistryClient, useValue: client as unknown as PackRegistryClient },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(PackRegistryBrowserService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the dashboard from the canonical pack list without probing /installed', async () => {
|
||||||
|
const viewModel = await firstValueFrom(service.loadDashboard());
|
||||||
|
|
||||||
|
expect(client.list).toHaveBeenCalledWith(undefined, 200);
|
||||||
|
expect(client.getInstalled).not.toHaveBeenCalled();
|
||||||
|
expect(viewModel.totalCount).toBe(2);
|
||||||
|
expect(viewModel.installedCount).toBe(1);
|
||||||
|
expect(viewModel.upgradeAvailableCount).toBe(1);
|
||||||
|
expect(viewModel.capabilities).toEqual(['deploy', 'observe', 'scan']);
|
||||||
|
expect(viewModel.packs.map((pack) => pack.id)).toEqual(['pack-alpha', 'pack-beta']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
|
import type { ConsoleStatusDto } from '../api/console-status.models';
|
||||||
|
import { ConsoleStatusComponent } from '../../features/console/console-status.component';
|
||||||
|
import { FirstSignalCardComponent } from '../../features/runs/components/first-signal-card/first-signal-card.component';
|
||||||
|
import { ConsoleStatusService } from './console-status.service';
|
||||||
|
import { ConsoleStatusStore } from './console-status.store';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-first-signal-card',
|
||||||
|
standalone: true,
|
||||||
|
template: '',
|
||||||
|
})
|
||||||
|
class StubFirstSignalCardComponent {
|
||||||
|
@Input() runId = '';
|
||||||
|
@Input() enableRealTime = false;
|
||||||
|
@Input() pollIntervalMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeConsoleStatusService {
|
||||||
|
readonly startPolling = jasmine.createSpy('startPolling').and.returnValue(new Subscription());
|
||||||
|
readonly fetchStatus = jasmine.createSpy('fetchStatus');
|
||||||
|
readonly subscribeToRun = jasmine.createSpy('subscribeToRun').and.callFake(() => new Subscription());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConsoleStatusComponent', () => {
|
||||||
|
let fixture: ComponentFixture<ConsoleStatusComponent>;
|
||||||
|
let component: ConsoleStatusComponent;
|
||||||
|
let store: ConsoleStatusStore;
|
||||||
|
let service: FakeConsoleStatusService;
|
||||||
|
|
||||||
|
const statusFixture: ConsoleStatusDto = {
|
||||||
|
backlog: 2,
|
||||||
|
queueLagMs: 250,
|
||||||
|
activeRuns: 1,
|
||||||
|
pendingRuns: 1,
|
||||||
|
healthy: true,
|
||||||
|
lastCompletedRunId: 'run-123',
|
||||||
|
lastCompletedAt: '2026-03-09T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ConsoleStatusComponent],
|
||||||
|
providers: [
|
||||||
|
ConsoleStatusStore,
|
||||||
|
{ provide: ConsoleStatusService, useClass: FakeConsoleStatusService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(ConsoleStatusComponent, {
|
||||||
|
remove: { imports: [FirstSignalCardComponent] },
|
||||||
|
add: { imports: [StubFirstSignalCardComponent] },
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConsoleStatusComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
store = TestBed.inject(ConsoleStatusStore);
|
||||||
|
service = TestBed.inject(ConsoleStatusService) as unknown as FakeConsoleStatusService;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for a concrete run id before opening the run stream', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(service.startPolling).toHaveBeenCalledWith(30000);
|
||||||
|
expect(service.subscribeToRun).not.toHaveBeenCalled();
|
||||||
|
expect(component.activeRunId()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Run stream will start after console status reports a completed run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes to the last completed run when console status provides one', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
store.setStatus(statusFixture);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.activeRunId()).toBe('run-123');
|
||||||
|
expect(service.subscribeToRun).toHaveBeenCalledWith('run-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the synthetic last token and keeps streaming the concrete run id', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
store.setStatus(statusFixture);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
component.updateRunId('last');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.activeRunId()).toBe('run-123');
|
||||||
|
expect(service.subscribeToRun).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,30 +52,39 @@
|
|||||||
<h3>Run Stream</h3>
|
<h3>Run Stream</h3>
|
||||||
<label>
|
<label>
|
||||||
Run ID
|
Run ID
|
||||||
<input type="text" [value]="runId()" (input)="runId.set($any($event.target).value)" (change)="startRunStream()" />
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="runIdInput()"
|
||||||
|
[attr.placeholder]="status()?.lastCompletedRunId ?? 'Latest completed run will appear here'"
|
||||||
|
(input)="updateRunId($any($event.target).value)"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</header>
|
</header>
|
||||||
<app-first-signal-card [runId]="runId()" [enableRealTime]="true" [pollIntervalMs]="5000" />
|
@if (activeRunId(); as runId) {
|
||||||
<div class="events">
|
<app-first-signal-card [runId]="runId" [enableRealTime]="true" [pollIntervalMs]="5000" />
|
||||||
@for (evt of runEvents(); track evt) {
|
<div class="events">
|
||||||
<div class="event">
|
@for (evt of runEvents(); track evt) {
|
||||||
<div class="meta">
|
<div class="event">
|
||||||
<span class="kind">{{ evt.kind }}</span>
|
<div class="meta">
|
||||||
<span class="time">{{ evt.updatedAt }}</span>
|
<span class="kind">{{ evt.kind }}</span>
|
||||||
|
<span class="time">{{ evt.updatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail">
|
||||||
|
<span class="run">Run {{ evt.runId }}</span>
|
||||||
|
<span class="message">{{ evt.message || '...' }}</span>
|
||||||
|
@if (evt.progressPercent != null) {
|
||||||
|
<span class="progress">{{ evt.progressPercent }}%</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail">
|
}
|
||||||
<span class="run">Run {{ evt.runId }}</span>
|
@if (runEvents().length === 0) {
|
||||||
<span class="message">{{ evt.message || '...' }}</span>
|
<p class="empty">No events yet for {{ runId }}.</p>
|
||||||
@if (evt.progressPercent != null) {
|
}
|
||||||
<span class="progress">{{ evt.progressPercent }}%</span>
|
</div>
|
||||||
}
|
} @else {
|
||||||
</div>
|
<p class="empty">Run stream will start after console status reports a completed run or you enter a concrete run ID.</p>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
@if (runEvents().length === 0) {
|
|
||||||
<p class="empty">No events yet.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
import { ConsoleStatusService } from '../../core/console/console-status.service';
|
import { ConsoleStatusService } from '../../core/console/console-status.service';
|
||||||
import { ConsoleStatusStore } from '../../core/console/console-status.store';
|
import { ConsoleStatusStore } from '../../core/console/console-status.store';
|
||||||
@@ -18,16 +18,31 @@ export class ConsoleStatusComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private pollSub: ReturnType<ConsoleStatusService['startPolling']> | null = null;
|
private pollSub: ReturnType<ConsoleStatusService['startPolling']> | null = null;
|
||||||
private runSub: ReturnType<ConsoleStatusService['subscribeToRun']> | null = null;
|
private runSub: ReturnType<ConsoleStatusService['subscribeToRun']> | null = null;
|
||||||
|
private currentStreamRunId: string | null = null;
|
||||||
|
|
||||||
readonly status = this.store.status;
|
readonly status = this.store.status;
|
||||||
readonly loading = this.store.loading;
|
readonly loading = this.store.loading;
|
||||||
readonly error = this.store.error;
|
readonly error = this.store.error;
|
||||||
readonly runEvents = this.store.runEvents;
|
readonly runEvents = this.store.runEvents;
|
||||||
readonly runId = signal<string>('last');
|
readonly runIdInput = signal('');
|
||||||
|
readonly activeRunId = computed(() => this.normalizeRunId(this.runIdInput()) ?? this.status()?.lastCompletedRunId ?? null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const runId = this.activeRunId();
|
||||||
|
if (runId === this.currentStreamRunId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runSub?.unsubscribe();
|
||||||
|
this.store.clearEvents();
|
||||||
|
this.currentStreamRunId = runId;
|
||||||
|
this.runSub = runId ? this.service.subscribeToRun(runId) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.pollSub = this.service.startPolling(30000);
|
this.pollSub = this.service.startPolling(30000);
|
||||||
this.startRunStream();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -39,8 +54,12 @@ export class ConsoleStatusComponent implements OnInit, OnDestroy {
|
|||||||
this.service.fetchStatus();
|
this.service.fetchStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
startRunStream(): void {
|
updateRunId(value: string): void {
|
||||||
this.runSub?.unsubscribe();
|
this.runIdInput.set(value);
|
||||||
this.runSub = this.service.subscribeToRun(this.runId());
|
}
|
||||||
|
|
||||||
|
private normalizeRunId(value: string): string | null {
|
||||||
|
const runId = value.trim();
|
||||||
|
return runId.length > 0 && runId.toLowerCase() !== 'last' ? runId : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, catchError, forkJoin, map, of, switchMap } from 'rxjs';
|
import { Observable, catchError, map, of, switchMap } from 'rxjs';
|
||||||
|
|
||||||
import { CompatibilityResult, Pack, PackStatus, PackVersion } from '../../../core/api/pack-registry.models';
|
import { CompatibilityResult, Pack, PackStatus, PackVersion } from '../../../core/api/pack-registry.models';
|
||||||
import { PackRegistryClient } from '../../../core/api/pack-registry.client';
|
import { PackRegistryClient } from '../../../core/api/pack-registry.client';
|
||||||
@@ -17,14 +17,10 @@ export class PackRegistryBrowserService {
|
|||||||
private readonly packRegistryClient = inject(PackRegistryClient);
|
private readonly packRegistryClient = inject(PackRegistryClient);
|
||||||
|
|
||||||
loadDashboard(): Observable<PackRegistryBrowserViewModel> {
|
loadDashboard(): Observable<PackRegistryBrowserViewModel> {
|
||||||
return forkJoin({
|
return this.packRegistryClient.list(undefined, 200).pipe(
|
||||||
listed: this.packRegistryClient.list(undefined, 200),
|
map((listed) => {
|
||||||
installed: this.packRegistryClient.getInstalled().pipe(catchError(() => of([] as Pack[]))),
|
|
||||||
}).pipe(
|
|
||||||
map(({ listed, installed }) => {
|
|
||||||
const installedById = new Map(installed.map((pack) => [pack.id, pack] as const));
|
|
||||||
const rows = listed.items
|
const rows = listed.items
|
||||||
.map((pack) => this.toRow(pack, installedById.get(pack.id)))
|
.map((pack) => this.toRow(pack))
|
||||||
.sort((left, right) => this.compareRows(left, right));
|
.sort((left, right) => this.compareRows(left, right));
|
||||||
|
|
||||||
const capabilitySet = new Set<string>();
|
const capabilitySet = new Set<string>();
|
||||||
@@ -114,8 +110,8 @@ export class PackRegistryBrowserService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRow(pack: Pack, installedPack?: Pack): PackRegistryRow {
|
private toRow(pack: Pack): PackRegistryRow {
|
||||||
const installedVersion = installedPack?.version ?? pack.installedVersion;
|
const installedVersion = pack.installedVersion;
|
||||||
const status = this.resolveStatus(pack.status, installedVersion, pack.latestVersion);
|
const status = this.resolveStatus(pack.status, installedVersion, pack.latestVersion);
|
||||||
const primaryAction: PackPrimaryAction = installedVersion ? 'upgrade' : 'install';
|
const primaryAction: PackPrimaryAction = installedVersion ? 'upgrade' : 'install';
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ const canonicalRoutes = [
|
|||||||
'/releases/hotfixes/new',
|
'/releases/hotfixes/new',
|
||||||
'/releases/environments',
|
'/releases/environments',
|
||||||
'/releases/deployments',
|
'/releases/deployments',
|
||||||
|
'/releases/investigation/timeline',
|
||||||
|
'/releases/investigation/deploy-diff',
|
||||||
|
'/releases/investigation/change-trace',
|
||||||
'/security',
|
'/security',
|
||||||
'/security/posture',
|
'/security/posture',
|
||||||
'/security/triage',
|
'/security/triage',
|
||||||
@@ -101,6 +104,7 @@ const canonicalRoutes = [
|
|||||||
'/evidence',
|
'/evidence',
|
||||||
'/evidence/overview',
|
'/evidence/overview',
|
||||||
'/evidence/capsules',
|
'/evidence/capsules',
|
||||||
|
'/evidence/threads',
|
||||||
'/evidence/verify-replay',
|
'/evidence/verify-replay',
|
||||||
'/evidence/proofs',
|
'/evidence/proofs',
|
||||||
'/evidence/exports',
|
'/evidence/exports',
|
||||||
@@ -135,6 +139,7 @@ const canonicalRoutes = [
|
|||||||
'/ops/integrations/notifications',
|
'/ops/integrations/notifications',
|
||||||
'/ops/integrations/sbom-sources',
|
'/ops/integrations/sbom-sources',
|
||||||
'/ops/integrations/activity',
|
'/ops/integrations/activity',
|
||||||
|
'/ops/integrations/registry-admin',
|
||||||
'/ops/policy',
|
'/ops/policy',
|
||||||
'/ops/policy/overview',
|
'/ops/policy/overview',
|
||||||
'/ops/policy/baselines',
|
'/ops/policy/baselines',
|
||||||
|
|||||||
Reference in New Issue
Block a user