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,365 @@
# StellaOps UI Accessibility Guide
## Overview
StellaOps web interface aims for WCAG 2.1 AA compliance. This document outlines accessibility requirements, testing procedures, and implementation patterns.
## WCAG 2.1 AA Compliance Checklist
### Perceivable
| Criterion | Requirement | Implementation |
|-----------|-------------|----------------|
| **1.1.1 Non-text Content** | Alt text for images | All `<img>` elements have `alt` attribute |
| **1.3.1 Info and Relationships** | Semantic HTML | Use proper heading hierarchy, lists, tables |
| **1.3.2 Meaningful Sequence** | Logical reading order | DOM order matches visual order |
| **1.4.1 Use of Color** | Color not sole indicator | Use icons + color, patterns + color |
| **1.4.3 Contrast (Minimum)** | 4.5:1 for text | All text meets contrast ratio |
| **1.4.4 Resize Text** | 200% zoom works | No horizontal scroll at 200% zoom |
| **1.4.11 Non-text Contrast** | 3:1 for UI components | Buttons, inputs, focus indicators |
### Operable
| Criterion | Requirement | Implementation |
|-----------|-------------|----------------|
| **2.1.1 Keyboard** | All functionality keyboard accessible | Tab navigation, keyboard shortcuts |
| **2.1.2 No Keyboard Trap** | Users can navigate away | Focus trap only in modals |
| **2.4.1 Bypass Blocks** | Skip navigation link | Skip link at top of page |
| **2.4.3 Focus Order** | Logical focus order | Tab order matches visual layout |
| **2.4.6 Headings and Labels** | Descriptive headings | Clear, unique page titles |
| **2.4.7 Focus Visible** | Focus indicator visible | Custom focus styles |
### Understandable
| Criterion | Requirement | Implementation |
|-----------|-------------|----------------|
| **3.1.1 Language of Page** | `lang` attribute set | `<html lang="en">` |
| **3.2.1 On Focus** | No unexpected changes | No auto-submit on focus |
| **3.3.1 Error Identification** | Errors identified | Error messages with field reference |
| **3.3.2 Labels or Instructions** | Labels provided | All inputs have labels |
### Robust
| Criterion | Requirement | Implementation |
|-----------|-------------|----------------|
| **4.1.1 Parsing** | Valid HTML | No duplicate IDs, proper nesting |
| **4.1.2 Name, Role, Value** | ARIA attributes | Proper ARIA for custom widgets |
## Color Contrast Requirements
### Severity Colors (with sufficient contrast)
```css
/* All severity colors meet 4.5:1 contrast on white background */
:root {
--color-critical: #991b1b; /* 7.8:1 on white */
--color-critical-bg: #fef2f2;
--color-high: #9a3412; /* 7.5:1 on white */
--color-high-bg: #fff7ed;
--color-medium: #854d0e; /* 7.2:1 on white */
--color-medium-bg: #fefce8;
--color-low: #166534; /* 6.8:1 on white */
--color-low-bg: #f0fdf4;
--color-info: #1e40af; /* 8.2:1 on white */
--color-info-bg: #eff6ff;
}
```
### Dark Mode Contrast
```css
/* Dark mode maintains contrast ratios */
[data-theme="dark"] {
--color-critical: #fca5a5; /* 4.5:1 on dark background */
--color-high: #fdba74;
--color-medium: #fde047;
--color-low: #86efac;
--color-info: #93c5fd;
}
```
## Keyboard Navigation
### Global Shortcuts
| Shortcut | Action | Scope |
|----------|--------|-------|
| `Cmd/Ctrl+K` | Open command palette | Global |
| `?` | Show keyboard help | Global |
| `Esc` | Close modal/Clear selection | Global |
| `g h` | Go to Home | Navigation |
| `g f` | Go to Findings | Navigation |
| `g p` | Go to Policy | Navigation |
| `g o` | Go to Ops | Navigation |
### List Navigation
| Shortcut | Action |
|----------|--------|
| `j` / `↓` | Move down |
| `k` / `↑` | Move up |
| `Enter` | Select/Open |
| `Space` | Toggle checkbox |
| `x` | Select current |
| `/` | Focus search |
### Implementation Example
```typescript
@Injectable({ providedIn: 'root' })
export class KeyboardShortcutService {
private shortcuts = new Map<string, ShortcutHandler>();
register(key: string, handler: ShortcutHandler): void {
this.shortcuts.set(key.toLowerCase(), handler);
}
@HostListener('window:keydown', ['$event'])
handleKeydown(event: KeyboardEvent): void {
// Ignore when typing in inputs
if (this.isTyping(event)) return;
const key = this.normalizeKey(event);
const handler = this.shortcuts.get(key);
if (handler) {
event.preventDefault();
handler.execute();
}
}
}
```
## Focus Management
### Focus Trap for Modals
```typescript
@Directive({ selector: '[appFocusTrap]' })
export class FocusTrapDirective implements AfterViewInit, OnDestroy {
private focusableElements: HTMLElement[] = [];
private previousActiveElement: HTMLElement | null = null;
ngAfterViewInit(): void {
this.previousActiveElement = document.activeElement as HTMLElement;
this.focusableElements = this.getFocusableElements();
this.focusFirst();
}
ngOnDestroy(): void {
this.previousActiveElement?.focus();
}
@HostListener('keydown.tab', ['$event'])
handleTab(event: KeyboardEvent): void {
const first = this.focusableElements[0];
const last = this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
```
### Focus Visible Styles
```css
/* Custom focus indicator */
*:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Remove default outline for mouse users */
*:focus:not(:focus-visible) {
outline: none;
}
/* High contrast mode focus */
@media (forced-colors: active) {
*:focus-visible {
outline: 3px solid CanvasText;
}
}
```
## Screen Reader Support
### ARIA Attributes
```html
<!-- Live regions for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ statusMessage }}
</div>
<!-- Loading states -->
<div aria-busy="true" aria-describedby="loading-message">
<span id="loading-message" class="sr-only">Loading findings...</span>
</div>
<!-- Error announcements -->
<div role="alert" aria-live="assertive">
{{ errorMessage }}
</div>
<!-- Navigation landmarks -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Breadcrumb">...</nav>
<main aria-labelledby="page-title">...</main>
```
### Screen Reader Announcements
```typescript
@Injectable({ providedIn: 'root' })
export class AnnouncerService {
private liveRegion: HTMLElement;
constructor() {
this.createLiveRegion();
}
announce(message: string, politeness: 'polite' | 'assertive' = 'polite'): void {
this.liveRegion.setAttribute('aria-live', politeness);
this.liveRegion.textContent = '';
// Force reflow
void this.liveRegion.offsetWidth;
this.liveRegion.textContent = message;
}
private createLiveRegion(): void {
this.liveRegion = document.createElement('div');
this.liveRegion.setAttribute('aria-live', 'polite');
this.liveRegion.setAttribute('aria-atomic', 'true');
this.liveRegion.className = 'sr-only';
document.body.appendChild(this.liveRegion);
}
}
```
## Form Accessibility
### Input Labels
```html
<!-- Visible label -->
<label for="cve-search">Search CVEs</label>
<input id="cve-search" type="text" aria-describedby="cve-search-hint">
<span id="cve-search-hint" class="hint">Enter CVE ID like CVE-2024-1234</span>
<!-- Hidden label for icon buttons -->
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<!-- Group labels -->
<fieldset>
<legend>Severity Filter</legend>
<label><input type="checkbox" name="severity" value="critical"> Critical</label>
<label><input type="checkbox" name="severity" value="high"> High</label>
</fieldset>
```
### Error Messages
```html
<div class="form-group" [class.has-error]="control.invalid && control.touched">
<label for="policy-name">Policy Name</label>
<input
id="policy-name"
[attr.aria-invalid]="control.invalid && control.touched"
[attr.aria-describedby]="control.invalid ? 'policy-name-error' : null">
<span id="policy-name-error" role="alert" *ngIf="control.invalid && control.touched">
{{ getErrorMessage(control) }}
</span>
</div>
```
## Motion and Animation
### Reduced Motion Support
```css
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Safe animations (opacity, transform only) */
.fade-in {
animation: fadeIn 200ms ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Alternative for reduced motion */
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
opacity: 1;
}
}
```
## Testing Procedures
### Automated Testing
```bash
# axe-core CLI
npm run test:a11y
# Lighthouse accessibility audit
lighthouse --only-categories=accessibility https://localhost:4200
# Pa11y for CI
pa11y https://localhost:4200 --standard WCAG2AA
```
### Manual Testing Checklist
- [ ] Tab through entire page - all interactive elements focusable
- [ ] Keyboard shortcuts work without mouse
- [ ] Focus indicator visible on all focusable elements
- [ ] Screen reader announces page changes
- [ ] Zoom to 200% - no horizontal scroll
- [ ] High contrast mode renders correctly
- [ ] Reduced motion preference respected
### Screen Reader Testing
| Platform | Screen Reader | Browser |
|----------|---------------|---------|
| Windows | NVDA | Firefox |
| Windows | JAWS | Chrome |
| macOS | VoiceOver | Safari |
| iOS | VoiceOver | Safari |
| Android | TalkBack | Chrome |
## Related Documentation
- [Information Architecture](./information-architecture.md)
- [Component Library](./components.md)
- [SPRINT_040 - Keyboard Accessibility](../../implplan/SPRINT_20251229_040_FE_keyboard_accessibility.md)

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)

View File

@@ -0,0 +1,253 @@
# StellaOps UI Information Architecture
## Overview
This document defines the information architecture (IA) for the StellaOps web interface, including navigation structure, route hierarchy, and role-based access patterns.
## Navigation Structure
### Primary Navigation
```
┌─────────────────────────────────────────────────────────────────┐
│ StellaOps │
├─────────────────────────────────────────────────────────────────┤
│ 🏠 Home │
│ 📊 Analyze │
│ ├── Findings │
│ ├── Vulnerabilities │
│ ├── Reachability │
│ ├── Graph Explorer │
│ ├── Unknowns [SPRINT_033] │
│ └── Binaries [SPRINT_038] │
│ 🔒 Proof │
│ ├── Proof Chain │
│ ├── CVSS Receipts │
│ └── Attestations │
│ 📜 Policy Studio │
│ ├── Packs │
│ ├── Editor │
│ ├── Simulation [SPRINT_021b] │
│ ├── Approvals │
│ └── Governance [SPRINT_021a] │
│ 🔗 Integrations │
│ ├── Hub [SPRINT_011] │
│ ├── Registries [SPRINT_012] │
│ ├── SCM [SPRINT_013] │
│ ├── CI/CD [SPRINT_014] │
│ └── Hosts [SPRINT_011] │
│ ⚙️ Ops │
│ ├── Health [SPRINT_032] │
│ ├── Orchestrator [existing] │
│ │ ├── Jobs │
│ │ ├── Quotas │
│ │ ├── Dead-Letter [SPRINT_030] │
│ │ └── SLO [SPRINT_031] │
│ ├── Scheduler [SPRINT_017] │
│ ├── Packs [SPRINT_036] │
│ ├── Signals [SPRINT_037] │
│ ├── Feeds [SPRINT_020] │
│ │ ├── Mirrors │
│ │ ├── Snapshots │
│ │ └── AirGap │
│ ├── Scanner [SPRINT_025] │
│ ├── AOC [SPRINT_027] │
│ └── Exports [SPRINT_016] │
│ 🔐 Admin │
│ ├── Users [existing] │
│ ├── Tenants [existing] │
│ ├── Tokens [existing] │
│ ├── Audit [SPRINT_028] │
│ ├── Trust [SPRINT_018c] │
│ │ ├── Keys │
│ │ ├── Issuers [SPRINT_024] │
│ │ └── Certificates │
│ ├── Notifications [SPRINT_018b] │
│ ├── Registry Admin [SPRINT_023] │
│ └── Quotas [SPRINT_029] │
└─────────────────────────────────────────────────────────────────┘
```
### Route Hierarchy
| Route | Component | Scope Required | Sprint |
|-------|-----------|----------------|--------|
| `/` | HomeDashboard | authenticated | existing |
| `/dashboard/sources` | SourcesDashboard | authenticated | existing |
| `/analyze/findings` | FindingsContainer | findings.read | existing |
| `/analyze/findings/:scanId` | FindingsContainer | findings.read | existing |
| `/analyze/vulnerabilities` | VulnerabilityExplorer | vulnerabilities.read | existing |
| `/analyze/vulnerabilities/:vulnId` | VulnerabilityDetail | vulnerabilities.read | existing |
| `/analyze/reachability` | ReachabilityCenter | reachability.read | existing |
| `/analyze/graph` | GraphExplorer | graph.read | existing |
| `/analyze/unknowns` | UnknownsList | scanner.read | SPRINT_033 |
| `/analyze/binaries` | BinaryIndexBrowser | binaryindex.read | SPRINT_038 |
| `/proof/:subjectDigest` | ProofChain | proof.read | existing |
| `/cvss/receipts/:receiptId` | CvssReceipt | cvss.read | existing |
| `/policy-studio/packs` | PolicyWorkspace | policy.read | existing |
| `/policy-studio/packs/:packId/editor` | PolicyEditor | policy.author | existing |
| `/policy-studio/packs/:packId/simulate` | PolicySimulation | policy.simulate | existing |
| `/policy-studio/packs/:packId/approvals` | PolicyApprovals | policy.review | existing |
| `/admin/policy/simulation` | PolicySimulationStudio | policy.simulate | SPRINT_021b |
| `/admin/policy/governance` | PolicyGovernance | policy.admin | SPRINT_021a |
| `/integrations` | IntegrationHub | integrations.read | SPRINT_011 |
| `/integrations/registries` | RegistryIntegrations | integrations.read | SPRINT_012 |
| `/integrations/scm` | ScmIntegrations | integrations.read | SPRINT_013 |
| `/integrations/ci` | CiIntegrations | integrations.read | SPRINT_014 |
| `/ops/health` | PlatformHealth | ops.health | SPRINT_032 |
| `/ops/orchestrator` | OrchestratorDashboard | orch.read | existing |
| `/ops/orchestrator/jobs` | OrchestratorJobs | orch.read | existing |
| `/ops/orchestrator/jobs/:jobId` | OrchestratorJobDetail | orch.read | existing |
| `/ops/orchestrator/quotas` | OrchestratorQuotas | orch.operator | existing |
| `/ops/orchestrator/dead-letter` | DeadLetterManagement | orch.admin | SPRINT_030 |
| `/ops/orchestrator/slo` | SloMonitoring | ops.read | SPRINT_031 |
| `/ops/scheduler` | SchedulerOps | scheduler.read | SPRINT_017 |
| `/ops/packs` | PackRegistry | orchestrator.read | SPRINT_036 |
| `/ops/signals` | SignalsDashboard | signals.read | SPRINT_037 |
| `/ops/feeds` | FeedMirrorOps | feeds.read | SPRINT_020 |
| `/ops/scanner` | ScannerOps | scanner.admin | SPRINT_025 |
| `/ops/aoc` | AocComplianceDashboard | ops.audit | SPRINT_027 |
| `/ops/exports` | EvidenceExports | evidence.read | SPRINT_016 |
| `/admin/users` | UserManagement | ui.admin | existing |
| `/admin/tenants` | TenantManagement | ui.admin | existing |
| `/admin/tokens` | TokenManagement | ui.admin | existing |
| `/admin/audit` | UnifiedAuditLog | audit.read | SPRINT_028 |
| `/admin/trust` | TrustDashboard | trust.admin | SPRINT_018c |
| `/admin/notifications` | NotificationAdmin | notify.admin | SPRINT_018b |
| `/admin/registry` | RegistryAdmin | registry.admin | SPRINT_023 |
| `/admin/quotas` | QuotaDashboard | quota.admin | SPRINT_029 |
## Role-Based Access Matrix
### Roles and Scopes
| Role | Scopes | Description |
|------|--------|-------------|
| **Viewer** | *.read | Read-only access to all features |
| **Developer** | scanner.read, findings.read, policy.read, proof.read | Day-to-day development workflow |
| **Security Engineer** | policy.*, findings.*, vulnerabilities.*, proof.* | Security triage and policy management |
| **Operator** | orch.*, scheduler.*, ops.*, integrations.* | Platform operations |
| **Admin** | *.admin, ui.admin | Full administrative access |
| **Tenant Admin** | tenant.admin, quota.admin | Tenant-level administration |
### Feature Visibility by Role
| Feature | Viewer | Developer | Security | Operator | Admin |
|---------|--------|-----------|----------|----------|-------|
| Home Dashboard | ✅ | ✅ | ✅ | ✅ | ✅ |
| Findings | ✅ | ✅ | ✅ | ✅ | ✅ |
| Vulnerability Triage | ❌ | ✅ | ✅ | ❌ | ✅ |
| Policy Editor | ❌ | ❌ | ✅ | ❌ | ✅ |
| Policy Simulation | ❌ | ✅ | ✅ | ❌ | ✅ |
| Orchestrator Jobs | ✅ | ✅ | ✅ | ✅ | ✅ |
| Dead-Letter Queue | ❌ | ❌ | ❌ | ✅ | ✅ |
| SLO Monitoring | ❌ | ❌ | ❌ | ✅ | ✅ |
| Platform Health | ❌ | ❌ | ❌ | ✅ | ✅ |
| Integration Hub | ❌ | ✅ | ❌ | ✅ | ✅ |
| User Management | ❌ | ❌ | ❌ | ❌ | ✅ |
| Audit Log | ❌ | ❌ | ✅ | ✅ | ✅ |
## Navigation State Management
### Breadcrumb Strategy
All nested routes should display breadcrumbs for context:
```
Home > Analyze > Findings > CVE-2024-1234
Home > Ops > Orchestrator > Jobs > job-12345
Home > Policy Studio > Packs > production-baseline > Editor
```
### Deep Linking
All significant states should be deep-linkable:
- Filter states encoded in URL query params
- Tab selections encoded in URL fragments
- Modal states use route params where appropriate
### Navigation Guards
```typescript
// Guard priority order
1. AuthGuard - Verify authentication
2. RoleGuard - Check required scope
3. FeatureGuard - Check feature flags
4. OnboardingGuard - Redirect to onboarding if incomplete
5. OfflineGuard - Redirect to offline view if disconnected
```
## Search and Discovery
### Global Search (SPRINT_034)
Cmd+K / Ctrl+K opens command palette with:
- Entity search (CVEs, artifacts, policies, jobs)
- Navigation shortcuts (g h, g f, g p)
- Quick actions (>scan, >vex, >policy)
### Contextual Search
Each list view includes contextual search:
- Findings: CVE ID, artifact, package
- Jobs: Job ID, type, status
- Policies: Name, rule content
- Audit: Actor, action, resource
## Mobile and Responsive Considerations
### Breakpoints
| Breakpoint | Min Width | Navigation |
|------------|-----------|------------|
| xs | 0px | Bottom nav, collapsed sidebar |
| sm | 576px | Collapsed sidebar |
| md | 768px | Collapsed sidebar with expand |
| lg | 992px | Full sidebar |
| xl | 1200px | Full sidebar with details panel |
### Touch Interactions
- Swipe left/right for list actions
- Pull to refresh on list views
- Long press for context menu
## Accessibility Navigation
### Keyboard Navigation
| Key | Action |
|-----|--------|
| Tab | Next focusable element |
| Shift+Tab | Previous focusable element |
| Enter | Activate selection |
| Esc | Close modal / Clear selection |
| ? | Show keyboard shortcuts |
| / | Focus search |
| j/k | Navigate list items |
### Skip Links
```html
<a class="skip-link" href="#main-content">Skip to main content</a>
<a class="skip-link" href="#main-nav">Skip to navigation</a>
```
### ARIA Landmarks
```html
<header role="banner">...</header>
<nav role="navigation" aria-label="Main navigation">...</nav>
<main role="main" id="main-content">...</main>
<aside role="complementary">...</aside>
<footer role="contentinfo">...</footer>
```
## Related Documentation
- [UI Architecture](./architecture.md)
- [Accessibility Guide](./accessibility.md)
- [Offline Implementation](./offline-implementation.md)
- [Component Library](./components.md)

View File

@@ -0,0 +1,497 @@
# StellaOps Offline-First Implementation Guide
## Overview
StellaOps supports offline operation for air-gapped environments. This document describes the implementation strategy for offline mode detection, data caching, and graceful degradation.
## Architecture
### Offline Mode States
```
┌─────────────────────────────────────────────────────────────────┐
│ Offline Mode State Machine │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ /health fails ┌──────────────┐ │
│ │ ONLINE │ ─────────────────> │ DEGRADED │ │
│ └─────────┘ 3 retries └──────────────┘ │
│ ↑ │ │
│ │ /health succeeds │ 30s timeout │
│ │ ↓ │
│ │ ┌──────────────┐ │
│ └───────────────────────── │ OFFLINE │ │
│ manual reconnect └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Mode Behaviors
| State | Read Operations | Write Operations | UI Indicators |
|-------|-----------------|------------------|---------------|
| **Online** | Live data | Enabled | None |
| **Degraded** | Live + cache fallback | Enabled with queue | Yellow banner |
| **Offline** | Cache only | Disabled | Red banner |
## Service Worker Strategy
### Caching Strategy
```typescript
// sw-config.ts
const cacheStrategies = {
// Static assets: Cache-first
static: {
pattern: /\.(js|css|woff2?|png|svg)$/,
strategy: 'cache-first',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
// API contracts: Stale-while-revalidate
apiContracts: {
pattern: /\/api\/v1\/openapi/,
strategy: 'stale-while-revalidate',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
// Dynamic data: Network-first with cache fallback
data: {
pattern: /\/api\/v1\/(findings|artifacts|policies)/,
strategy: 'network-first',
maxAge: 60 * 60 * 1000, // 1 hour
fallbackCache: true,
},
// Mutations: Network-only with queue
mutations: {
pattern: /\/api\/v1\/.+\/(create|update|delete)/,
strategy: 'network-only',
queueOffline: true,
},
};
```
### Service Worker Registration
```typescript
// main.ts
if ('serviceWorker' in navigator && environment.production) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered:', registration);
// Check for updates hourly
setInterval(() => registration.update(), 60 * 60 * 1000);
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
```
## IndexedDB Schema
### Database Structure
```typescript
// db-schema.ts
const DB_NAME = 'stellaops-offline';
const DB_VERSION = 1;
interface OfflineDatabase {
// Cached API responses
apiCache: {
key: string; // API endpoint + params hash
value: {
data: any;
timestamp: number;
etag?: string;
};
};
// Pending mutations
mutationQueue: {
id: string; // UUID
endpoint: string;
method: 'POST' | 'PUT' | 'DELETE';
body: any;
timestamp: number;
retries: number;
};
// Offline bundle assets
bundleAssets: {
hash: string; // Content hash
type: 'jwks' | 'advisory' | 'vex' | 'manifest';
data: ArrayBuffer;
importedAt: number;
};
// User preferences
preferences: {
key: string;
value: any;
};
}
```
### IndexedDB Operations
```typescript
// offline-db.service.ts
@Injectable({ providedIn: 'root' })
export class OfflineDbService {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// API cache store
const apiCache = db.createObjectStore('apiCache', { keyPath: 'key' });
apiCache.createIndex('timestamp', 'timestamp');
// Mutation queue store
const mutations = db.createObjectStore('mutationQueue', { keyPath: 'id' });
mutations.createIndex('timestamp', 'timestamp');
// Bundle assets store
const bundles = db.createObjectStore('bundleAssets', { keyPath: 'hash' });
bundles.createIndex('type', 'type');
// Preferences store
db.createObjectStore('preferences', { keyPath: 'key' });
};
});
}
async getCached<T>(key: string): Promise<T | null> {
return this.get<{ data: T }>('apiCache', key).then(r => r?.data ?? null);
}
async setCached<T>(key: string, data: T, etag?: string): Promise<void> {
await this.put('apiCache', {
key,
value: { data, timestamp: Date.now(), etag },
});
}
async queueMutation(mutation: Omit<MutationQueue, 'id' | 'timestamp' | 'retries'>): Promise<string> {
const id = crypto.randomUUID();
await this.put('mutationQueue', {
...mutation,
id,
timestamp: Date.now(),
retries: 0,
});
return id;
}
}
```
## Offline Mode Service
### Mode Detection
```typescript
// offline-mode.service.ts
@Injectable({ providedIn: 'root' })
export class OfflineModeService {
private readonly healthUrl = '/health';
private readonly maxRetries = 3;
private readonly retryDelay = 1000;
readonly mode$ = new BehaviorSubject<'online' | 'degraded' | 'offline'>('online');
readonly bundleInfo$ = new BehaviorSubject<BundleInfo | null>(null);
constructor(private http: HttpClient) {
this.startHealthPolling();
window.addEventListener('online', () => this.checkHealth());
window.addEventListener('offline', () => this.mode$.next('offline'));
}
private async checkHealth(): Promise<boolean> {
for (let i = 0; i < this.maxRetries; i++) {
try {
await firstValueFrom(
this.http.get(this.healthUrl, { responseType: 'text' })
.pipe(timeout(3000))
);
this.mode$.next('online');
return true;
} catch {
await this.delay(this.retryDelay * Math.pow(2, i));
}
}
const currentMode = this.mode$.value;
this.mode$.next(currentMode === 'online' ? 'degraded' : 'offline');
return false;
}
private startHealthPolling(): void {
interval(30000).pipe(
startWith(0),
switchMap(() => this.checkHealth())
).subscribe();
}
async importBundle(file: File): Promise<void> {
const manifest = await this.extractManifest(file);
await this.validateSignature(manifest);
await this.storeAssets(file, manifest);
this.bundleInfo$.next({
version: manifest.version,
createdAt: new Date(manifest.created_at),
expiresAt: new Date(manifest.expires_at),
});
}
}
```
## Graceful Degradation
### Read-Only Guard
```typescript
// read-only.guard.ts
@Injectable({ providedIn: 'root' })
export class ReadOnlyGuard implements CanActivate {
constructor(
private offlineMode: OfflineModeService,
private toast: ToastService
) {}
canActivate(route: ActivatedRouteSnapshot): boolean {
const requiresWrite = route.data['requiresWrite'] as boolean;
const isOffline = this.offlineMode.mode$.value === 'offline';
if (requiresWrite && isOffline) {
this.toast.warning('This action requires an online connection');
return false;
}
return true;
}
}
```
### Offline Banner Component
```typescript
// offline-banner.component.ts
@Component({
selector: 'app-offline-banner',
template: `
<div *ngIf="mode$ | async as mode"
[class]="'offline-banner offline-banner--' + mode"
role="status"
aria-live="polite">
<ng-container [ngSwitch]="mode">
<ng-container *ngSwitchCase="'degraded'">
<mat-icon>wifi_off</mat-icon>
<span>Connection unstable. Some features may be limited.</span>
<button mat-button (click)="retry()">Retry</button>
</ng-container>
<ng-container *ngSwitchCase="'offline'">
<mat-icon>cloud_off</mat-icon>
<span>
Offline Mode - Data as of {{ bundleDate | date:'medium' }}
</span>
<button mat-button (click)="importBundle()">Import Bundle</button>
</ng-container>
</ng-container>
</div>
`,
})
export class OfflineBannerComponent {
mode$ = this.offlineMode.mode$;
bundleDate = this.offlineMode.bundleInfo$.value?.createdAt;
constructor(private offlineMode: OfflineModeService) {}
}
```
### Disabled Mutation Buttons
```typescript
// mutation-button.directive.ts
@Directive({ selector: '[appMutationButton]' })
export class MutationButtonDirective implements OnInit, OnDestroy {
private sub!: Subscription;
constructor(
private el: ElementRef<HTMLButtonElement>,
private offlineMode: OfflineModeService
) {}
ngOnInit(): void {
this.sub = this.offlineMode.mode$.subscribe(mode => {
const button = this.el.nativeElement;
const isOffline = mode === 'offline';
button.disabled = isOffline;
button.title = isOffline ? 'Requires online connection' : '';
button.classList.toggle('mutation-disabled', isOffline);
});
}
ngOnDestroy(): void {
this.sub.unsubscribe();
}
}
```
## Bundle Freshness
### Freshness Indicators
```typescript
// bundle-freshness.component.ts
@Component({
selector: 'app-bundle-freshness',
template: `
<div class="bundle-freshness" [class]="'freshness--' + status">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ message }}</span>
<span class="age">Updated {{ age | relativeTime }}</span>
</div>
`,
})
export class BundleFreshnessComponent {
@Input() bundleDate!: Date;
get status(): 'fresh' | 'stale' | 'expired' {
const ageMs = Date.now() - this.bundleDate.getTime();
const ageDays = ageMs / (24 * 60 * 60 * 1000);
if (ageDays < 7) return 'fresh';
if (ageDays < 30) return 'stale';
return 'expired';
}
get icon(): string {
switch (this.status) {
case 'fresh': return 'check_circle';
case 'stale': return 'warning';
case 'expired': return 'error';
}
}
get message(): string {
switch (this.status) {
case 'fresh': return 'Data is current';
case 'stale': return 'Data may be outdated';
case 'expired': return 'Data is seriously outdated';
}
}
}
```
## Mutation Queue Sync
### Queue Processing
```typescript
// mutation-sync.service.ts
@Injectable({ providedIn: 'root' })
export class MutationSyncService {
private processing = false;
constructor(
private db: OfflineDbService,
private http: HttpClient,
private offlineMode: OfflineModeService
) {
// Process queue when coming online
this.offlineMode.mode$.pipe(
filter(mode => mode === 'online'),
switchMap(() => this.processQueue())
).subscribe();
}
async processQueue(): Promise<void> {
if (this.processing) return;
this.processing = true;
try {
const mutations = await this.db.getPendingMutations();
for (const mutation of mutations) {
try {
await firstValueFrom(
this.http.request(mutation.method, mutation.endpoint, {
body: mutation.body,
})
);
await this.db.removeMutation(mutation.id);
} catch (error) {
await this.db.incrementRetries(mutation.id);
if (mutation.retries >= 3) {
await this.db.moveToDlq(mutation.id);
}
}
}
} finally {
this.processing = false;
}
}
}
```
## Testing Offline Mode
### E2E Test Helpers
```typescript
// offline.e2e-spec.ts
describe('Offline Mode', () => {
beforeEach(() => {
// Intercept health check
cy.intercept('/health', { forceNetworkError: true });
});
it('should show offline banner after health check fails', () => {
cy.visit('/');
cy.get('.offline-banner').should('be.visible');
cy.get('.offline-banner').should('contain', 'Offline Mode');
});
it('should disable mutation buttons in offline mode', () => {
cy.visit('/findings');
cy.get('[data-testid="create-vex-button"]').should('be.disabled');
});
it('should show cached data in offline mode', () => {
// Pre-populate cache
cy.window().then(win => {
win.localStorage.setItem('apiCache', JSON.stringify({
'/api/v1/findings': mockFindings,
}));
});
cy.visit('/findings');
cy.get('[data-testid="findings-table"]').should('have.length.gt', 0);
});
});
```
## Related Documentation
- [SPRINT_026 - Offline Kit Integration](../../implplan/SPRINT_20251229_026_PLATFORM_offline_kit_integration.md)
- [UI Architecture](./architecture.md)
- [Component Library](./components.md)