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.
498 lines
14 KiB
Markdown
498 lines
14 KiB
Markdown
# 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)
|