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:
master
2026-03-09 07:53:46 +02:00
parent 0473a5876a
commit 310e9f84fe
12 changed files with 403 additions and 72 deletions

View File

@@ -41,6 +41,9 @@ const canonicalRoutes = [
'/releases/hotfixes/new',
'/releases/environments',
'/releases/deployments',
'/releases/investigation/timeline',
'/releases/investigation/deploy-diff',
'/releases/investigation/change-trace',
'/security',
'/security/posture',
'/security/triage',
@@ -54,6 +57,7 @@ const canonicalRoutes = [
'/evidence',
'/evidence/overview',
'/evidence/capsules',
'/evidence/threads',
'/evidence/verify-replay',
'/evidence/proofs',
'/evidence/exports',
@@ -88,6 +92,7 @@ const canonicalRoutes = [
'/ops/integrations/notifications',
'/ops/integrations/sbom-sources',
'/ops/integrations/activity',
'/ops/integrations/registry-admin',
'/ops/policy',
'/ops/policy/overview',
'/ops/policy/baselines',

View File

@@ -19,7 +19,6 @@ import {
import {
NOTIFY_API,
NOTIFY_API_BASE_URL,
NOTIFY_TENANT_ID,
NotifyApiHttpClient,
MockNotifyClient,
} from './core/api/notify.client';
@@ -262,6 +261,28 @@ import {
MockIdentityProviderClient,
} 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 = {
providers: [
provideRouter(routes, withComponentInputBinding()),
@@ -306,15 +327,8 @@ export const appConfig: ApplicationConfig = {
provide: AUTHORITY_CONSOLE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/console', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/console`;
}
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return resolveApiBaseUrl(gatewayBase, '/console');
},
},
AuthorityConsoleApiHttpClient,
@@ -539,7 +553,7 @@ export const appConfig: ApplicationConfig = {
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return resolveApiBaseUrl(gatewayBase, '/api/v1');
},
},
OrchestratorHttpClient,
@@ -617,12 +631,7 @@ export const appConfig: ApplicationConfig = {
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/v1/runs', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/v1/runs`;
}
return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs');
},
},
AiRunsHttpClient,
@@ -635,25 +644,14 @@ export const appConfig: ApplicationConfig = {
provide: CONSOLE_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/console', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/console`;
}
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return resolveApiBaseUrl(gatewayBase, '/console');
},
},
{
provide: EVENT_SOURCE_FACTORY,
useValue: DEFAULT_EVENT_SOURCE_FACTORY,
},
{
provide: NOTIFY_TENANT_ID,
useValue: 'tenant-dev',
},
NotifyApiHttpClient,
MockNotifyClient,
{

View File

@@ -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: [] });
});
});

View File

@@ -91,8 +91,9 @@ export class EvidencePackHttpClient implements EvidencePackApi {
listByRun(runId: string, options: EvidencePackQueryOptions = {}): Observable<EvidencePackListResponse> {
const traceId = options.traceId ?? generateTraceId();
const params = this.buildQueryParams({ runId });
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))));
}

View 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([]);
});
});

View File

@@ -362,15 +362,16 @@ export class NotifyApiHttpClient implements NotifyApi {
}
private buildHeaders(): HttpHeaders {
if (!this.tenantId) {
const tenant = this.resolveTenantId();
if (!tenant) {
return new HttpHeaders();
}
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
return new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
}
private buildHeadersWithTrace(traceId: string): HttpHeaders {
const tenant = this.tenantId || this.authSession.getActiveTenantId() || '';
const tenant = this.resolveTenantId();
return new HttpHeaders({
'X-StellaOps-Tenant': tenant,
'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 {
let params = new HttpParams();
if (options.pageToken) {
@@ -720,4 +725,3 @@ export class MockNotifyClient implements NotifyApi {
}).pipe(delay(100));
}
}

View File

@@ -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']);
});
});

View File

@@ -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);
});
});

View File

@@ -52,30 +52,39 @@
<h3>Run Stream</h3>
<label>
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>
</header>
<app-first-signal-card [runId]="runId()" [enableRealTime]="true" [pollIntervalMs]="5000" />
<div class="events">
@for (evt of runEvents(); track evt) {
<div class="event">
<div class="meta">
<span class="kind">{{ evt.kind }}</span>
<span class="time">{{ evt.updatedAt }}</span>
@if (activeRunId(); as runId) {
<app-first-signal-card [runId]="runId" [enableRealTime]="true" [pollIntervalMs]="5000" />
<div class="events">
@for (evt of runEvents(); track evt) {
<div class="event">
<div class="meta">
<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 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>
}
@if (runEvents().length === 0) {
<p class="empty">No events yet.</p>
}
</div>
}
@if (runEvents().length === 0) {
<p class="empty">No events yet for {{ runId }}.</p>
}
</div>
} @else {
<p class="empty">Run stream will start after console status reports a completed run or you enter a concrete run ID.</p>
}
</section>
</section>

View File

@@ -1,5 +1,5 @@
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 { 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 runSub: ReturnType<ConsoleStatusService['subscribeToRun']> | null = null;
private currentStreamRunId: string | null = null;
readonly status = this.store.status;
readonly loading = this.store.loading;
readonly error = this.store.error;
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 {
this.pollSub = this.service.startPolling(30000);
this.startRunStream();
}
ngOnDestroy(): void {
@@ -39,8 +54,12 @@ export class ConsoleStatusComponent implements OnInit, OnDestroy {
this.service.fetchStatus();
}
startRunStream(): void {
this.runSub?.unsubscribe();
this.runSub = this.service.subscribeToRun(this.runId());
updateRunId(value: string): void {
this.runIdInput.set(value);
}
private normalizeRunId(value: string): string | null {
const runId = value.trim();
return runId.length > 0 && runId.toLowerCase() !== 'last' ? runId : null;
}
}

View File

@@ -1,5 +1,5 @@
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 { PackRegistryClient } from '../../../core/api/pack-registry.client';
@@ -17,14 +17,10 @@ export class PackRegistryBrowserService {
private readonly packRegistryClient = inject(PackRegistryClient);
loadDashboard(): Observable<PackRegistryBrowserViewModel> {
return forkJoin({
listed: this.packRegistryClient.list(undefined, 200),
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));
return this.packRegistryClient.list(undefined, 200).pipe(
map((listed) => {
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));
const capabilitySet = new Set<string>();
@@ -114,8 +110,8 @@ export class PackRegistryBrowserService {
);
}
private toRow(pack: Pack, installedPack?: Pack): PackRegistryRow {
const installedVersion = installedPack?.version ?? pack.installedVersion;
private toRow(pack: Pack): PackRegistryRow {
const installedVersion = pack.installedVersion;
const status = this.resolveStatus(pack.status, installedVersion, pack.latestVersion);
const primaryAction: PackPrimaryAction = installedVersion ? 'upgrade' : 'install';

View File

@@ -88,6 +88,9 @@ const canonicalRoutes = [
'/releases/hotfixes/new',
'/releases/environments',
'/releases/deployments',
'/releases/investigation/timeline',
'/releases/investigation/deploy-diff',
'/releases/investigation/change-trace',
'/security',
'/security/posture',
'/security/triage',
@@ -101,6 +104,7 @@ const canonicalRoutes = [
'/evidence',
'/evidence/overview',
'/evidence/capsules',
'/evidence/threads',
'/evidence/verify-replay',
'/evidence/proofs',
'/evidence/exports',
@@ -135,6 +139,7 @@ const canonicalRoutes = [
'/ops/integrations/notifications',
'/ops/integrations/sbom-sources',
'/ops/integrations/activity',
'/ops/integrations/registry-admin',
'/ops/policy',
'/ops/policy/overview',
'/ops/policy/baselines',