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/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',
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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> {
|
||||
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))));
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user