Rewrite UI API clients from /api/v2/releases to /api/v1/release-orchestrator

Completes Sprint 323 TASK-001 using Option C (direct URL rewrite):
- release-management.client.ts: readBaseUrl and legacyBaseUrl now use
  /api/v1/release-orchestrator/releases, eliminating the v2 proxy dependency
- All 15+ component files updated: activity, approvals, runs, versions,
  bundle-organizer, sidebar queries, topology pages
- Spec files updated to match new URL patterns
- Added /releases/activity and /releases/versions backend route aliases
  in ReleaseEndpoints.cs with ListActivity and ListVersions handlers
- Fixed orphaned audit-log-dashboard.component import → audit-log-table
- Both Angular build and JobEngine build pass clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 00:16:32 +03:00
parent f96c6cb9ed
commit a4c4690fef
22 changed files with 257 additions and 138 deletions

View File

@@ -41,7 +41,7 @@ describe('ApprovalHttpClient', () => {
const req = httpMock.expectOne(
(request) =>
request.method === 'GET' &&
request.url === '/api/v2/releases/approvals' &&
request.url === '/api/v1/release-orchestrator/approvals' &&
request.params.get('environment') === 'prod' &&
!request.params.has('status')
);

View File

@@ -132,8 +132,8 @@ export interface ReleaseManagementApi {
export class ReleaseManagementHttpClient implements ReleaseManagementApi {
private readonly http = inject(HttpClient);
private readonly context = inject(PlatformContextStore);
private readonly readBaseUrl = '/api/v2/releases';
private readonly legacyBaseUrl = '/api/v1/releases';
private readonly readBaseUrl = '/api/v1/release-orchestrator/releases';
private readonly legacyBaseUrl = '/api/v1/release-orchestrator/releases';
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> {
const page = Math.max(1, filter?.page ?? 1);
@@ -238,7 +238,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
createRelease(request: CreateManagedReleaseRequest): Observable<ManagedRelease> {
const slug = this.toSlug(`${request.name}-${request.version}`);
return this.http
.post<LegacyCreateBundleResponse>('/api/v1/release-control/bundles', {
.post<LegacyCreateBundleResponse>('/api/v1/release-orchestrator/releases', {
slug,
name: request.name,
description: request.description,
@@ -507,7 +507,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
.set('offset', String(offset));
return this.http
.get<PlatformListResponse<BundleSummaryDto>>('/api/v1/release-control/bundles', { params: bundleParams })
.get<PlatformListResponse<BundleSummaryDto>>('/api/v1/release-orchestrator/releases', { params: bundleParams })
.pipe(
map((response) => {
let items = (response.items ?? []).map((bundle) => this.mapBundleToRelease(bundle));

View File

@@ -28,7 +28,7 @@ describe('OpenApiContextParamMap', () => {
expect(request.request.method).toBe('GET');
request.flush({
paths: {
'/api/v2/releases/activity': {
'/api/v1/release-orchestrator/releases/activity': {
get: {
parameters: [
{ name: 'tenant', in: 'query' },
@@ -51,7 +51,7 @@ describe('OpenApiContextParamMap', () => {
await initPromise;
expect(service.getContextParams('/api/v2/releases/activity') ?? new Set()).toEqual(
expect(service.getContextParams('/api/v1/release-orchestrator/releases/activity') ?? new Set()).toEqual(
new Set(['tenant', 'regions', 'timeWindow']),
);
expect(service.getContextParams('/api/v2/topology/environments/env-01') ?? new Set()).toEqual(

View File

@@ -56,7 +56,7 @@ describe('EnvironmentPosturePageComponent', () => {
req.url === '/api/v2/topology/environments' && req.params.get('environment') === 'dev',
);
const runsReq = httpMock.expectOne((req) =>
req.url === '/api/v2/releases/activity' && req.params.get('environment') === 'dev',
req.url === '/api/v1/release-orchestrator/releases/activity' && req.params.get('environment') === 'dev',
);
const findingsReq = httpMock.expectOne((req) =>
req.url === '/api/v2/security/findings' && req.params.get('environment') === 'dev',

View File

@@ -48,9 +48,9 @@ describe('GlobalContextHttpInterceptor', () => {
});
it('propagates only the context parameters declared by the OpenAPI route map', () => {
http.get('/api/v2/releases/activity').subscribe();
http.get('/api/v1/release-orchestrator/releases/activity').subscribe();
const request = httpMock.expectOne('/api/v2/releases/activity?tenant=demo-prod&regions=apac,eu-west,us-east,us-west&region=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h&stage=prod');
const request = httpMock.expectOne('/api/v1/release-orchestrator/releases/activity?tenant=demo-prod&regions=apac,eu-west,us-east,us-west&region=apac,eu-west,us-east,us-west&environments=dev,stage&environment=dev,stage&timeWindow=24h&stage=prod');
request.flush({ items: [] });
});

View File

@@ -190,7 +190,7 @@ describe('TopologyEnvironmentDetailPageComponent', () => {
{ agentId: 'agent-c', agentName: 'agent-c', regionId: 'eu-west', environmentId: 'prod', status: 'active', capabilities: ['docker_host'], assignedTargetCount: 1, lastHeartbeatAt: '2026-03-31T09:45:00Z' },
],
});
httpMock.expectOne((req) => req.url === '/api/v2/releases/activity' && req.params.get('environment') === 'prod').flush({ items: [] });
httpMock.expectOne((req) => req.url === '/api/v1/release-orchestrator/releases/activity' && req.params.get('environment') === 'prod').flush({ items: [] });
httpMock.expectOne((req) => req.url === '/api/v2/security/findings' && req.params.get('environment') === 'prod').flush({ items: [] });
httpMock.expectOne((req) => req.url === '/api/v2/evidence/packs' && req.params.get('environment') === 'prod').flush({ items: [] });
httpMock.expectOne('/api/v1/environments/prod/readiness').flush({ items: [] });

View File

@@ -185,7 +185,7 @@ const mockTopologyDataService = {
const mockHttpClient = {
get: jasmine.createSpy('get').and.callFake((url: string) => {
switch (url) {
case '/api/v2/releases/activity':
case '/api/v1/release-orchestrator/releases/activity':
return of({
items: [
{

View File

@@ -5,7 +5,7 @@ export const auditLogRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./audit-log-dashboard.component').then((m) => m.AuditLogDashboardComponent),
import('./audit-log-table.component').then((m) => m.AuditLogTableComponent),
},
// Event detail (deep link)
{

View File

@@ -108,7 +108,7 @@ export interface MaterializeReleaseControlBundleVersionRequestDto {
@Injectable({ providedIn: 'root' })
export class BundleOrganizerApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/release-control/bundles';
private readonly baseUrl = '/api/v1/release-orchestrator/releases';
listBundles(limit = 100, offset = 0): Observable<ReleaseControlBundleSummaryDto[]> {
const params = new HttpParams()

View File

@@ -160,7 +160,7 @@ export const policyDecisioningRoutes: Routes = [
},
{
path: 'baselines',
title: 'Policy Packs',
title: 'Release Policies',
loadComponent: () =>
import('../policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent,
@@ -184,8 +184,8 @@ export const policyDecisioningRoutes: Routes = [
},
{
path: 'packs',
title: 'Policy Packs',
data: { breadcrumb: 'Packs' },
title: 'Release Policies',
data: { breadcrumb: 'Release Policies' },
loadComponent: () =>
import('./policy-pack-shell.component').then(
(m) => m.PolicyPackShellComponent,
@@ -452,8 +452,8 @@ export const policyDecisioningRoutes: Routes = [
{
path: 'log',
loadComponent: () =>
import('../audit-log/audit-log-dashboard.component').then(
(m) => m.AuditLogDashboardComponent,
import('../audit-log/audit-log-table.component').then(
(m) => m.AuditLogTableComponent,
),
},
{

View File

@@ -818,13 +818,13 @@ export class ReleaseDetailComponent {
const params = this.contextParams();
const detail$ = this.http.get<PlatformItemResponse<ReleaseDetailProjection>>(`/api/v2/releases/${releaseId}`).pipe(map((r) => r.item), catchError(() => of(null)));
const activity$ = this.http.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v2/releases/activity', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as ReleaseActivityProjection[])));
const approvals$ = this.http.get<PlatformListResponse<ReleaseApprovalProjection>>('/api/v2/releases/approvals', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as ReleaseApprovalProjection[])));
const detail$ = this.http.get<PlatformItemResponse<ReleaseDetailProjection>>(`/api/v1/release-orchestrator/releases/${releaseId}`).pipe(map((r) => r.item), catchError(() => of(null)));
const activity$ = this.http.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v1/release-orchestrator/releases/activity', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as ReleaseActivityProjection[])));
const approvals$ = this.http.get<PlatformListResponse<ReleaseApprovalProjection>>('/api/v1/release-orchestrator/approvals', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as ReleaseApprovalProjection[])));
const findings$ = this.http.get<SecurityFindingsResponse>('/api/v2/security/findings', { params: params.set('pivot', 'release') }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as SecurityFindingProjection[])));
const disposition$ = this.http.get<PlatformListResponse<SecurityDispositionProjection>>('/api/v2/security/disposition', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as SecurityDispositionProjection[])));
const sbom$ = this.http.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params: params.set('mode', 'table') }).pipe(map((r) => (r.table ?? []).filter((i) => i.releaseId === releaseId)), catchError(() => of([] as SecuritySbomComponentRow[])));
const baseline$ = this.http.get<PlatformListResponse<{ releaseId: string; name: string }>>('/api/v2/releases', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId !== releaseId).map((i) => ({ releaseId: i.releaseId, name: i.name }))), catchError(() => of([] as Array<{ releaseId: string; name: string }>)));
const baseline$ = this.http.get<PlatformListResponse<{ releaseId: string; name: string }>>('/api/v1/release-orchestrator/releases', { params }).pipe(map((r) => (r.items ?? []).filter((i) => i.releaseId !== releaseId).map((i) => ({ releaseId: i.releaseId, name: i.name }))), catchError(() => of([] as Array<{ releaseId: string; name: string }>)));
forkJoin({ detail: detail$, activity: activity$, approvals: approvals$, findings: findings$, disposition: disposition$, sbom: sbom$, baseline: baseline$ }).pipe(take(1)).subscribe({
next: ({ detail, activity, approvals, findings, disposition, sbom, baseline }) => {
@@ -853,7 +853,7 @@ export class ReleaseDetailComponent {
}
private loadRunWorkbench(runId: string, background = false): void {
const runBase = `/api/v2/releases/runs/${runId}`;
const runBase = `/api/v1/release-orchestrator/deployments/${runId}`;
const runDetail$ = this.http.get<PlatformItemResponse<ReleaseRunDetailProjectionDto>>(runBase).pipe(map((r) => r.item), catchError(() => of(null)));
const timeline$ = this.http.get<PlatformItemResponse<ReleaseRunTimelineProjectionDto>>(`${runBase}/timeline`).pipe(map((r) => r.item), catchError(() => of(null)));
const gate$ = this.http.get<PlatformItemResponse<ReleaseRunGateDecisionProjectionDto>>(`${runBase}/gate-decision`).pipe(map((r) => r.item), catchError(() => of(null)));

View File

@@ -602,7 +602,7 @@ export class ReleaseDetailPageComponent {
this.activityLoading.set(true);
const params = new HttpParams().set('limit', '200').set('offset', '0');
this.http
.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v2/releases/activity', { params })
.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v1/release-orchestrator/releases/activity', { params })
.pipe(take(1))
.subscribe({
next: (response) => {

View File

@@ -6,8 +6,9 @@ import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { PageActionService } from '../../core/services/page-action.service';
import { StellaHelperContextService } from '../../shared/components/stella-helper/stella-helper-context.service';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
import { FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
import { DateFormatService } from '../../core/i18n/date-format.service';
@@ -18,7 +19,6 @@ import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/c
import { ModalComponent } from '../../shared/components/modal/modal.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
interface ReleaseActivityProjection {
activityId: string;
@@ -52,21 +52,21 @@ function deriveOutcomeIcon(status: string): string {
if (lower.includes('approved')) return 'check_circle';
if (lower.includes('blocked') || lower.includes('rejected')) return 'block';
if (lower.includes('failed')) return 'error';
if (lower.includes('pending_approval')) return 'pending';
if (lower.includes('pending_approval')) return 'event_busy';
return 'play_circle';
}
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule, TimelineListComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent, StatusBadgeComponent, RelativeTimePipe],
imports: [RouterLink, FormsModule, TimelineListComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent, RelativeTimePipe],
template: `
<section class="activity">
<div class="page-hdr">
<div class="hdr-row">
<div>
<h1>Deployments</h1>
<p class="page-sub">Deployment pipeline and approval queue.</p>
<p class="page-sub">Track container deployments across your environments. Approve promotions, review gate status, and monitor rollout progress. Deployments are created when a release is promoted from one environment to another.</p>
</div>
<app-page-action-outlet />
</div>
@@ -224,6 +224,20 @@ function deriveOutcomeIcon(status: string): string {
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--xs"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
}
</div>
} @else if (timelineEvents().length === 0) {
<div class="empty-state">
<h3 class="empty-state__title">{{ pipelineEmptyTitle() }}</h3>
<p>{{ pipelineEmptyDescription() }}</p>
<p class="empty-hint">{{ pipelineEmptyHint() }}</p>
<div class="empty-state__actions">
@if (hasActivePipelineFilters()) {
<button type="button" class="btn btn-secondary btn--sm" (click)="clearPipelineFilters()">Clear filters</button>
} @else {
<a [routerLink]="['/releases/deployments/new']" class="btn btn-primary btn--sm">Create Deployment</a>
<a [routerLink]="['/releases/promotion-graph']" class="btn btn-secondary btn--sm">Learn about promotions</a>
}
</div>
</div>
} @else {
<div class="timeline-container">
<app-timeline-list
@@ -393,9 +407,11 @@ function deriveOutcomeIcon(status: string): string {
.pending-lane__link{display:inline-flex;align-items:center;gap:.25rem;background:none;border:none;font-size:.8rem;font-weight:500;color:var(--color-status-warning-text);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.pending-lane__link:hover{color:var(--color-text-primary)}
.empty-state{text-align:center;padding:1.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg)}
.empty-state p{margin:.15rem 0}
.empty-state{text-align:center;padding:1.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg);display:grid;gap:.35rem;justify-items:center}
.empty-state__title{margin:0;font-size:.9rem;font-weight:600;color:var(--color-text-primary)}
.empty-state p{margin:.15rem 0;max-width:48ch}
.empty-hint{font-size:.72rem;color:var(--color-text-muted);opacity:.7}
.empty-state__actions{display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center;margin-top:.35rem}
.apc__btns{display:flex;border-top:1px solid var(--color-border-primary)}
.apc__btns .btn{flex:1;justify-content:center;border-radius:0;border:none;border-right:1px solid var(--color-border-primary)}
@@ -473,6 +489,7 @@ function deriveOutcomeIcon(status: string): string {
export class ReleasesActivityComponent implements OnInit, OnDestroy {
private readonly dateFmt = inject(DateFormatService);
private readonly pageAction = inject(PageActionService);
private readonly helperCtx = inject(StellaHelperContextService);
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
@@ -481,11 +498,17 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
private readonly approvalApi = inject(APPROVAL_API);
ngOnInit(): void {
// Deployments are created by release start/promotion, not directly
this.pageAction.set({
label: 'Create Deployment',
action: () => {
void this.router.navigate(['/releases/deployments/new']);
},
});
}
ngOnDestroy(): void {
this.pageAction.clear();
this.helperCtx.clearScope('releases-activity');
}
readonly loading = signal(false);
@@ -605,6 +628,19 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
const start = (this.currentPage() - 1) * this.pipelinePageSize;
return all.slice(start, start + this.pipelinePageSize);
});
readonly helperContexts = computed(() => {
const contexts: string[] = [];
if (this.pendingApprovals().length > 0) {
contexts.push('approval-pending');
}
if (this.rows().some((row) => row.status.toLowerCase().includes('blocked'))) {
contexts.push('gate-blocked');
}
if (!this.loading() && this.timelineEvents().length === 0) {
contexts.push('empty-list');
}
return contexts;
});
toggleApprovalSort(): void {
this.approvalSortAsc.update(v => !v);
@@ -776,6 +812,10 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
this.load();
}
});
effect(() => {
this.helperCtx.setScope('releases-activity', this.helperContexts());
}, { allowSignalWrites: true });
}
mergeQuery(next: Record<string, string>): Record<string, string | null> {
@@ -801,6 +841,43 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
this.pageSize.set(event.pageSize);
}
hasActivePipelineFilters(): boolean {
return this.statusFilter() !== '' ||
this.envFilter() !== '' ||
this.outcomeFilter() !== '' ||
this.searchQuery().trim().length > 0;
}
clearPipelineFilters(): void {
this.statusFilter.set('');
this.envFilter.set('');
this.outcomeFilter.set('');
this.searchQuery.set('');
this.currentPage.set(1);
this.load();
}
pipelineEmptyTitle(): string {
if (this.hasActivePipelineFilters()) {
return 'No deployment runs match the active filters';
}
return 'No deployment runs yet';
}
pipelineEmptyDescription(): string {
if (this.hasActivePipelineFilters()) {
return 'Stella has deployment history for this scope, but the current status, lane, environment, or outcome filter narrowed the pipeline to zero rows.';
}
return 'Deployment runs are created when you promote a release from one environment to another. Each run then moves through your configured gates, evidence checks, and approval steps before it reaches the target.';
}
pipelineEmptyHint(): string {
if (this.hasActivePipelineFilters()) {
return 'Clear the filters to return to the full activity stream.';
}
return 'Start from Releases, choose a release, and promote it to create the first run shown here.';
}
deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' {
return item.releaseName.toLowerCase().includes('hotfix') ? 'hotfix' : 'standard';
}
@@ -954,7 +1031,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
if (region) params = params.set('region', region);
if (environment) params = params.set('environment', environment);
this.http.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v2/releases/activity', { params }).pipe(take(1)).subscribe({
this.http.get<PlatformListResponse<ReleaseActivityProjection>>('/api/v1/release-orchestrator/releases/activity', { params }).pipe(take(1)).subscribe({
next: (response) => {
const sorted = [...(response?.items ?? [])].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
this.rows.set(sorted);

View File

@@ -225,7 +225,7 @@ export class EnvironmentPosturePageComponent {
);
const runs$ = this.http
.get<PlatformListResponse<ReleaseActivityRow>>('/api/v2/releases/activity', { params: envParams })
.get<PlatformListResponse<ReleaseActivityRow>>('/api/v1/release-orchestrator/releases/activity', { params: envParams })
.pipe(
map((response) => response.items ?? []),
catchError(() => of([] as ReleaseActivityRow[])),

View File

@@ -978,7 +978,7 @@ export class EnvironmentsCommandComponent implements OnInit, OnDestroy {
this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe(t => this.drawerTargets.set(t));
this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe(h => this.drawerHosts.set(h));
const params = new HttpParams().set('environment', environmentId).set('limit', '20');
this.http.get<PlatformListResponse<ReleaseActivity>>('/api/v2/releases/activity', { params })
this.http.get<PlatformListResponse<ReleaseActivity>>('/api/v1/release-orchestrator/releases/activity', { params })
.pipe(take(1), catchError(() => of({ items: [] as ReleaseActivity[] })))
.subscribe(r => { this.drawerReleases.set(r?.items ?? []); this.drawerLoading.set(false); });
}

View File

@@ -918,7 +918,7 @@ export class TopologyEnvironmentDetailPageComponent {
targets: this.topologyApi.list<TopologyTarget>('/api/v2/topology/targets', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))),
hosts: this.topologyApi.list<TopologyHost>('/api/v2/topology/hosts', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))),
agents: this.topologyApi.list<TopologyAgent>('/api/v2/topology/agents', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))),
runs: this.http.get<PlatformListResponse<ReleaseActivityRow>>('/api/v2/releases/activity', { params }).pipe(take(1), catchError(() => of({ items: [] }))),
runs: this.http.get<PlatformListResponse<ReleaseActivityRow>>('/api/v1/release-orchestrator/releases/activity', { params }).pipe(take(1), catchError(() => of({ items: [] }))),
findings: this.http.get<PlatformListResponse<SecurityFindingRow>>('/api/v2/security/findings', { params }).pipe(take(1), catchError(() => of({ items: [] }))),
capsules: this.http.get<PlatformListResponse<EvidenceCapsuleRow>>('/api/v2/evidence/packs', { params }).pipe(take(1), catchError(() => of({ items: [] }))),
readiness: this.topologySetup.getEnvironmentReadiness(environmentId).pipe(catchError(() => of({ items: [] as ReadinessReport[] }))),

View File

@@ -820,7 +820,7 @@ export class TopologyGraphPageComponent {
.set('environment', environmentId)
.set('limit', '20');
this.http
.get<PlatformListResponse<ReleaseActivity>>('/api/v2/releases/activity', { params })
.get<PlatformListResponse<ReleaseActivity>>('/api/v1/release-orchestrator/releases/activity', { params })
.pipe(
take(1),
map((r) => r?.items ?? []),

View File

@@ -204,7 +204,7 @@ export class RunVisualizationShellService {
private readonly workflowVisualization = inject(WorkflowVisualizationService);
loadContext(runId: string): Observable<RunVisualizationContext> {
const runBase = `/api/v2/releases/runs/${encodeURIComponent(runId)}`;
const runBase = `/api/v1/release-orchestrator/deployments/${encodeURIComponent(runId)}`;
return forkJoin({
detail: this.http.get<ReleaseRunDetailProjectionDto>(runBase),

View File

@@ -25,6 +25,11 @@ import type { ApprovalApi } from '../../core/api/approval.client';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
import { SidebarPreferenceService } from './sidebar-preference.service';
import { NAVIGATION_GROUPS } from '../../core/navigation/navigation.config';
import type {
NavGroup as CanonicalNavGroup,
NavItem as CanonicalNavItem,
} from '../../core/navigation/navigation.types';
/**
* Navigation structure for the shell.
@@ -37,6 +42,8 @@ export interface NavSection {
route: string;
menuGroupId?: string;
menuGroupLabel?: string;
tooltip?: string;
badgeTooltip?: string;
badge$?: () => number | null;
sparklineData$?: () => number[];
children?: NavItem[];
@@ -52,9 +59,25 @@ interface DisplayNavSection extends NavSection {
interface NavSectionGroup {
id: string;
label: string;
description?: string;
sections: DisplayNavSection[];
}
const LOCAL_TO_CANONICAL_GROUP_ID: Readonly<Record<string, string>> = {
home: 'home',
'release-control': 'release-control',
security: 'security',
evidence: 'evidence',
operations: 'ops',
'setup-admin': 'admin',
};
const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
'/setup/integrations': 'Connect source control, registries, notifications, and delivery systems',
'/setup/identity-access': 'Manage sign-in, access rules, and operator scopes',
'/setup/preferences': 'Personal defaults for helper behavior, theme, and working context',
};
/**
* AppSidebarComponent - Permanent dark left navigation rail.
*
@@ -682,7 +705,16 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.RELEASE_PUBLISH,
],
},
// ── Group 2: Security (absorbs former Policy group) ──────────────
{
id: 'release-policies',
label: 'Release Policies',
icon: 'clipboard',
route: '/ops/policy/packs',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
// ── Group 2: Security ────────────────────────────────────────────
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
@@ -739,19 +771,6 @@ export class AppSidebarComponent implements AfterViewInit {
menuGroupLabel: 'Security',
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
},
{
id: 'sec-risk-governance',
label: 'Risk & Governance',
icon: 'shield',
route: '/ops/policy/governance',
menuGroupId: 'security',
menuGroupLabel: 'Security',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
children: [
{ id: 'sec-simulation', label: 'Simulation', route: '/ops/policy/simulation', icon: 'play' },
{ id: 'sec-policy-audit', label: 'Policy Audit', route: '/ops/policy/audit', icon: 'list' },
],
},
// ── Group 3: Evidence (trimmed from 7 to 4) ──────────────────────
{
id: 'evidence-overview',
@@ -806,33 +825,7 @@ export class AppSidebarComponent implements AfterViewInit {
},
// Replay & Verify, Bundles, Trust — removed from nav, still routable.
// Accessible from Evidence Overview, Decision Capsules detail, and Audit Log filters.
// ── Group 4: Operations (trimmed, absorbs Policy Packs) ──────────
{
id: 'ops',
label: 'Operations Hub',
icon: 'settings',
route: '/ops/operations',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
sparklineData$: () => this.doctorTrendService.platformTrend(),
requireAnyScope: [
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.HEALTH_READ,
StellaOpsScopes.NOTIFY_VIEWER,
StellaOpsScopes.POLICY_READ,
],
},
{
id: 'ops-policy-packs',
label: 'Policy Packs',
icon: 'clipboard',
route: '/ops/policy/packs',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
// ── Group 4: Operations ─────────────────────────────────────────
{
id: 'ops-jobs',
label: 'Scheduled Jobs',
@@ -857,30 +850,6 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.VEX_READ,
],
},
{
id: 'ops-agents',
label: 'Agent Fleet',
icon: 'cpu',
route: '/ops/operations/agents',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
],
},
{
id: 'ops-signals',
label: 'Signals',
icon: 'radio',
route: '/ops/operations/signals',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.HEALTH_READ,
],
},
{
id: 'ops-scripts',
label: 'Scripts',
@@ -903,7 +872,6 @@ export class AppSidebarComponent implements AfterViewInit {
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
},
// Runtime Drift, Notifications, Watchlist — removed from nav, still routable.
// Accessible from Operations Hub landing page.
// ── Group 5: Settings ────────────────────────────────────────────
{
id: 'setup-integrations',
@@ -1210,7 +1178,7 @@ export class AppSidebarComponent implements AfterViewInit {
private loadActionBadges(): void {
// Blocked gates count
this.http.get<{ items?: unknown[] }>('/api/v2/releases/versions?gateStatus=block&limit=0').pipe(
this.http.get<{ items?: unknown[] }>('/api/v1/release-orchestrator/releases/versions?gateStatus=block&limit=0').pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe({
next: (res) => this.blockedGatesCount.set(res.items?.length ?? 0),
@@ -1226,7 +1194,7 @@ export class AppSidebarComponent implements AfterViewInit {
});
// Failed runs in last 24h
this.http.get<{ items?: unknown[] }>('/api/v2/releases/activity?outcome=failed&limit=0').pipe(
this.http.get<{ items?: unknown[] }>('/api/v1/release-orchestrator/releases/activity?outcome=failed&limit=0').pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe({
next: (res) => this.failedRunsCount.set(res.items?.length ?? 0),