UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization

Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
master
2025-12-29 19:12:38 +02:00
parent 41552d26ec
commit a4badc275e
286 changed files with 50918 additions and 992 deletions

View File

@@ -0,0 +1,529 @@
# StellaOps UI API Strategy
## Overview
This document describes the API client architecture, code generation strategy, and data fetching patterns used in the StellaOps web interface.
## API Client Architecture
### Client Organization
```
src/app/core/api/
├── generated/ # Auto-generated from OpenAPI
│ ├── scanner-client.ts
│ ├── policy-client.ts
│ ├── orchestrator-client.ts
│ └── ...
├── services/ # Higher-level service wrappers
│ ├── findings.service.ts
│ ├── policy.service.ts
│ └── ...
├── interceptors/ # HTTP interceptors
│ ├── auth.interceptor.ts
│ ├── error.interceptor.ts
│ ├── cache.interceptor.ts
│ └── retry.interceptor.ts
└── api.module.ts
```
### OpenAPI Client Generation
```bash
# Generate clients from OpenAPI specs
npm run api:generate
# Script in package.json
{
"scripts": {
"api:generate": "openapi-generator-cli generate -g typescript-angular -i ../api/openapi/*.yaml -o src/app/core/api/generated"
}
}
```
### Generated Client Configuration
```typescript
// api.module.ts
@NgModule({
imports: [HttpClientModule],
providers: [
{ provide: BASE_PATH, useValue: environment.apiUrl },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: RetryInterceptor, multi: true },
],
})
export class ApiModule {}
```
## HTTP Interceptors
### Authentication Interceptor
```typescript
// auth.interceptor.ts
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.auth.getAccessToken();
if (token && this.shouldAddToken(req)) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
return next.handle(req).pipe(
catchError(error => {
if (error.status === 401) {
return this.handle401(req, next);
}
return throwError(() => error);
})
);
}
private handle401(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return this.auth.refreshToken().pipe(
switchMap(newToken => {
req = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` },
});
return next.handle(req);
}),
catchError(() => {
this.auth.logout();
return throwError(() => new Error('Session expired'));
})
);
}
}
```
### Error Interceptor
```typescript
// error.interceptor.ts
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(
private errorHandler: ErrorHandlerService,
private toast: ToastService
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
const apiError = this.parseError(error);
// Handle different error types
switch (apiError.type) {
case 'validation':
// Let component handle validation errors
break;
case 'auth':
// Handled by auth interceptor
break;
case 'rate_limit':
this.toast.warning(`Rate limited. Retry in ${apiError.retryAfter}s`);
break;
case 'service':
this.toast.error('Service temporarily unavailable');
break;
default:
this.errorHandler.handle(apiError);
}
return throwError(() => apiError);
})
);
}
private parseError(error: HttpErrorResponse): ApiError {
return {
type: this.getErrorType(error.status),
status: error.status,
message: error.error?.message || error.message,
code: error.error?.code,
details: error.error?.details,
retryAfter: error.headers.get('Retry-After'),
requestId: error.headers.get('X-Request-ID'),
};
}
}
```
### Cache Interceptor
```typescript
// cache.interceptor.ts
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
constructor(private cache: CacheService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Only cache GET requests
if (req.method !== 'GET') {
return next.handle(req);
}
// Check for no-cache header
if (req.headers.has('x-no-cache')) {
return next.handle(req);
}
const cacheKey = this.getCacheKey(req);
const cached = this.cache.get(cacheKey);
// Stale-while-revalidate pattern
if (cached) {
// Return cached immediately
const cached$ = of(new HttpResponse({ body: cached.data }));
// Revalidate in background
const fresh$ = this.revalidate(req, next, cacheKey, cached.etag);
return merge(cached$, fresh$).pipe(
filter(response => response instanceof HttpResponse)
);
}
return this.fetchAndCache(req, next, cacheKey);
}
private revalidate(
req: HttpRequest<any>,
next: HttpHandler,
cacheKey: string,
etag: string
): Observable<HttpEvent<any>> {
const conditionalReq = req.clone({
setHeaders: { 'If-None-Match': etag },
});
return next.handle(conditionalReq).pipe(
tap(event => {
if (event instanceof HttpResponse && event.status !== 304) {
this.cache.set(cacheKey, {
data: event.body,
etag: event.headers.get('ETag'),
timestamp: Date.now(),
});
}
}),
filter(event => event instanceof HttpResponse && event.status !== 304),
catchError(() => EMPTY) // Silently fail revalidation
);
}
}
```
### Retry Interceptor
```typescript
// retry.interceptor.ts
@Injectable()
export class RetryInterceptor implements HttpInterceptor {
private retryConfig = {
maxRetries: 3,
retryDelay: 1000,
retryableStatuses: [408, 500, 502, 503, 504],
};
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
retryWhen(errors =>
errors.pipe(
mergeMap((error, index) => {
if (
index < this.retryConfig.maxRetries &&
this.shouldRetry(error)
) {
const delay = this.calculateDelay(error, index);
return timer(delay);
}
return throwError(() => error);
})
)
)
);
}
private shouldRetry(error: HttpErrorResponse): boolean {
return this.retryConfig.retryableStatuses.includes(error.status);
}
private calculateDelay(error: HttpErrorResponse, retryIndex: number): number {
// Use Retry-After header if present
const retryAfter = error.headers.get('Retry-After');
if (retryAfter) {
return parseInt(retryAfter, 10) * 1000;
}
// Exponential backoff with jitter
const baseDelay = this.retryConfig.retryDelay;
const exponential = Math.pow(2, retryIndex) * baseDelay;
const jitter = Math.random() * baseDelay;
return exponential + jitter;
}
}
```
## Data Fetching Patterns
### Service Layer Pattern
```typescript
// findings.service.ts
@Injectable({ providedIn: 'root' })
export class FindingsService {
private readonly cache = new Map<string, Observable<Finding[]>>();
constructor(private api: ScannerApiService) {}
getFindings(params: FindingsQuery): Observable<PaginatedResponse<Finding>> {
return this.api.listFindings(params).pipe(
map(response => this.transformResponse(response)),
shareReplay(1)
);
}
getFinding(id: string): Observable<Finding> {
const cacheKey = `finding:${id}`;
if (!this.cache.has(cacheKey)) {
this.cache.set(cacheKey, this.api.getFinding(id).pipe(
map(response => this.transformFinding(response)),
shareReplay({ bufferSize: 1, refCount: true })
));
}
return this.cache.get(cacheKey)!;
}
invalidateCache(id?: string): void {
if (id) {
this.cache.delete(`finding:${id}`);
} else {
this.cache.clear();
}
}
}
```
### Pagination with Virtual Scrolling
```typescript
// infinite-scroll.service.ts
@Injectable()
export class InfiniteScrollService<T> {
private items$ = new BehaviorSubject<T[]>([]);
private loading$ = new BehaviorSubject<boolean>(false);
private cursor: string | null = null;
private hasMore = true;
readonly state$ = combineLatest([
this.items$,
this.loading$,
]).pipe(
map(([items, loading]) => ({ items, loading, hasMore: this.hasMore }))
);
constructor(private fetchFn: (cursor: string | null) => Observable<PaginatedResponse<T>>) {}
loadMore(): void {
if (this.loading$.value || !this.hasMore) return;
this.loading$.next(true);
this.fetchFn(this.cursor).subscribe({
next: response => {
this.items$.next([...this.items$.value, ...response.items]);
this.cursor = response.nextCursor;
this.hasMore = !!response.nextCursor;
this.loading$.next(false);
},
error: () => {
this.loading$.next(false);
},
});
}
reset(): void {
this.items$.next([]);
this.cursor = null;
this.hasMore = true;
}
}
```
### Real-time Updates with SSE
```typescript
// sse.service.ts
@Injectable({ providedIn: 'root' })
export class SseService {
connect<T>(url: string): Observable<T> {
return new Observable(observer => {
const eventSource = new EventSource(url);
eventSource.onmessage = event => {
try {
observer.next(JSON.parse(event.data));
} catch (e) {
observer.error(new Error('Failed to parse SSE data'));
}
};
eventSource.onerror = () => {
observer.error(new Error('SSE connection failed'));
eventSource.close();
};
return () => eventSource.close();
}).pipe(
retryWhen(errors => errors.pipe(delay(5000)))
);
}
}
// Usage in component
@Component({...})
export class JobDetailComponent {
jobUpdates$ = this.sse.connect<JobUpdate>(`/api/v1/orchestrator/jobs/${this.jobId}/stream`);
}
```
### Optimistic Updates
```typescript
// optimistic-update.service.ts
@Injectable()
export class OptimisticUpdateService<T extends { id: string }> {
private optimisticItems = new Map<string, T>();
constructor(
private store: Store<T>,
private api: ApiService<T>
) {}
async update(id: string, changes: Partial<T>): Promise<void> {
const original = this.store.get(id);
if (!original) return;
// Apply optimistic update
const optimistic = { ...original, ...changes };
this.optimisticItems.set(id, original);
this.store.update(id, optimistic);
try {
// Persist to server
const result = await firstValueFrom(this.api.update(id, changes));
this.store.update(id, result);
this.optimisticItems.delete(id);
} catch (error) {
// Rollback on failure
this.store.update(id, original);
this.optimisticItems.delete(id);
throw error;
}
}
}
```
## Error Handling
### API Error Types
```typescript
// api-error.ts
interface ApiError {
type: 'validation' | 'auth' | 'rate_limit' | 'service' | 'network' | 'unknown';
status: number;
message: string;
code?: string;
details?: Record<string, string[]>;
retryAfter?: string;
requestId?: string;
}
// Error type mapping
function getErrorType(status: number): ApiError['type'] {
if (status === 0) return 'network';
if (status === 400) return 'validation';
if (status === 401 || status === 403) return 'auth';
if (status === 429) return 'rate_limit';
if (status >= 500) return 'service';
return 'unknown';
}
```
### Component Error Handling
```typescript
// findings.component.ts
@Component({...})
export class FindingsComponent {
findings$: Observable<Finding[]>;
error$ = new Subject<ApiError>();
ngOnInit(): void {
this.findings$ = this.findingsService.getFindings(this.params).pipe(
catchError(error => {
this.error$.next(error);
return of([]);
})
);
}
}
```
## Testing
### API Mock Service
```typescript
// api-mock.service.ts
@Injectable()
export class ApiMockService {
private mocks = new Map<string, any>();
mock(endpoint: string, response: any): void {
this.mocks.set(endpoint, response);
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const mock = this.mocks.get(req.url);
if (mock) {
return of(new HttpResponse({ body: mock }));
}
return next.handle(req);
}
}
// Usage in tests
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: ApiMockService, multi: true },
],
});
const mockService = TestBed.inject(ApiMockService);
mockService.mock('/api/v1/findings', mockFindings);
});
```
## Related Documentation
- [UI Architecture](./architecture.md)
- [Offline Implementation](./offline-implementation.md)
- [SPRINT_039 - Error Boundary Patterns](../../implplan/SPRINT_20251229_039_FE_error_boundary_patterns.md)