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:
365
docs/modules/ui/accessibility.md
Normal file
365
docs/modules/ui/accessibility.md
Normal 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)
|
||||
529
docs/modules/ui/api-strategy.md
Normal file
529
docs/modules/ui/api-strategy.md
Normal 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)
|
||||
253
docs/modules/ui/information-architecture.md
Normal file
253
docs/modules/ui/information-architecture.md
Normal 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)
|
||||
497
docs/modules/ui/offline-implementation.md
Normal file
497
docs/modules/ui/offline-implementation.md
Normal 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)
|
||||
Reference in New Issue
Block a user