Files
git.stella-ops.org/docs/modules/ui/offline-implementation.md
master a4badc275e 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.
2025-12-29 19:12:38 +02:00

14 KiB

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

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

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

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

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

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

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

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

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

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

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

// 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);
  });
});