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:
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