diff --git a/docs/modules/airgap/guides/importer.md b/docs/modules/airgap/guides/importer.md index 2647253c8..767337eb2 100644 --- a/docs/modules/airgap/guides/importer.md +++ b/docs/modules/airgap/guides/importer.md @@ -50,6 +50,24 @@ Rollback resistance is enforced via: - A monotonicity checker (`IVersionMonotonicityChecker`) that compares incoming bundle versions to the active version. - Optional force-activate path requiring a human reason, stored alongside the activation record. +## Import sources + +The importer accepts bundles from three sources: + +| Source | Transport | Use case | +|---|---|---| +| **Server path** | Container reads from the import staging volume at `/var/lib/concelier/import/`. Host-side location controlled by `STELLAOPS_AIRGAP_IMPORT_DIR` (default `./airgap-import`). | USB drives, NFS mounts, large bundles (GB+). Zero browser transfer. | +| **URL** | Backend fetches the bundle from an internal URL directly. | Internal mirrors, S3, artifact registries. | +| **File upload** | Browser uploads via multipart/form-data. | Small bundles only; limited by browser memory. | + +For Docker Compose deployments, the import volume is mounted read-only: +```yaml +# In docker-compose.stella-ops.yml (concelier service): +- ${STELLAOPS_AIRGAP_IMPORT_DIR:-./airgap-import}:/var/lib/concelier/import:ro +``` + +For Kubernetes deployments, mount an emptyDir, hostPath, or PVC at `/var/lib/concelier/import` and pre-stage bundles via init containers or sidecar pods. + ## Storage model The importer writes deterministic metadata that other components can query: diff --git a/docs/operations/runbooks/concelier-airgap-bundle-deploy.md b/docs/operations/runbooks/concelier-airgap-bundle-deploy.md index 7869a5088..e3ea56d1b 100644 --- a/docs/operations/runbooks/concelier-airgap-bundle-deploy.md +++ b/docs/operations/runbooks/concelier-airgap-bundle-deploy.md @@ -13,9 +13,38 @@ Scope: deploy sealed-mode Concelier evidence bundles using deterministic NDJSON - Concelier WebService running with `concelier:features:airgap` enabled. - No external egress; only local file system allowed for bundle path. - PostgreSQL indexes applied (`advisory_observations`, `advisory_linksets` tables). +- **Import volume mounted**: The Concelier container must have the import staging directory mounted. In Docker Compose this is configured via `STELLAOPS_AIRGAP_IMPORT_DIR` (defaults to `./airgap-import` on the host, mounted read-only at `/var/lib/concelier/import` inside the container). + +## Import Volume Setup (Docker Compose) + +The Concelier service mounts an import staging volume for air-gapped bundle ingestion. +Bundles placed on the host at `$STELLAOPS_AIRGAP_IMPORT_DIR` are visible inside the container at `/var/lib/concelier/import/`. + +```bash +# Default: ./airgap-import relative to the compose directory +mkdir -p devops/compose/airgap-import + +# Override: point to USB, NFS mount, or any host directory +export STELLAOPS_AIRGAP_IMPORT_DIR=/media/usb/stellaops-bundles +docker compose -f docker-compose.stella-ops.yml up -d concelier +``` + +The volume is mounted **read-only** — the Concelier service reads and validates bundles but never modifies the staging directory. The environment variable `CONCELIER_IMPORT__STAGINGROOT` tells the service where to find staged bundles inside the container. + +### UI Console Import + +The Feeds & Airgap console (Ops → Operations → Feeds & Airgap → Airgap Bundles → Import) supports three import sources: + +| Source | Description | Volume needed? | +|---|---|---| +| **Server Path** | Path inside the container (e.g. `/var/lib/concelier/import/bundle.tar.gz`). Zero browser transfer. | Yes | +| **URL** | Internal URL the backend downloads directly. | No | +| **File Upload** | Browser drag-and-drop for small bundles. | No | + +For large bundles (GB+), use **Server Path** or **URL** — never browser upload. ## Steps -1) Transfer bundle directory to offline controller host. +1) Stage the bundle onto the import volume (or transfer to the offline controller host). 2) Verify hashes: ```bash sha256sum concelier-airgap.ndjson | diff - <(jq -r .bundleSha256 bundle.manifest.json) diff --git a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts index 0ef2eb415..8ade6369e 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.client.ts @@ -10,6 +10,7 @@ import { AirGapBundleRequest, AirGapImportValidation, AirGapImportProgress, + AirGapImportRequest, FeedVersionLock, FeedVersionLockRequest, OfflineSyncStatus, @@ -54,7 +55,7 @@ export interface FeedMirrorApi { downloadBundle(bundleId: string): Observable; // AirGap import operations - validateImport(file: File): Observable; + validateImport(source: AirGapImportRequest): Observable; startImport(bundleId: string): Observable; getImportProgress(importId: string): Observable; @@ -467,12 +468,18 @@ export class FeedMirrorHttpClient implements FeedMirrorApi { // ---- AirGap import operations ---- - validateImport(file: File): Observable { - const formData = new FormData(); - formData.append('file', file, file.name); + validateImport(source: AirGapImportRequest): Observable { + if (source.mode === 'file' && source.file) { + const formData = new FormData(); + formData.append('file', source.file, source.file.name); + return this.http.post( + `${this.baseUrl}/imports/validate`, + formData, + ); + } return this.http.post( `${this.baseUrl}/imports/validate`, - formData + { mode: source.mode, serverPath: source.serverPath, sourceUrl: source.sourceUrl }, ); } @@ -712,7 +719,7 @@ export class MockFeedMirrorApi implements FeedMirrorApi { } // AirGap import operations - validateImport(_file: File): Observable { + validateImport(_source: AirGapImportRequest): Observable { return of({ bundleId: 'import-validation-temp', status: 'valid' as const, diff --git a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.models.ts b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.models.ts index 520848351..b3a52b879 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/feed-mirror.models.ts @@ -184,6 +184,27 @@ export interface AirGapImportValidation { readonly canImport: boolean; } +/** + * Import source mode: server path, URL download, or browser file upload. + */ +export type ImportSourceMode = 'file' | 'path' | 'url'; + +/** + * AirGap import source descriptor. + * - `path`: Server-side absolute path (USB, NFS mount, volume). + * - `url`: Internal URL for the backend to download directly. + * - `file`: Browser-uploaded file (FormData) — for small bundles only. + */ +export interface AirGapImportRequest { + readonly mode: ImportSourceMode; + /** Browser-uploaded file (mode === 'file') */ + readonly file?: File; + /** Server-side absolute path (mode === 'path') */ + readonly serverPath?: string; + /** URL to download from (mode === 'url') */ + readonly sourceUrl?: string; +} + /** * Import validation error. */ diff --git a/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-export.component.ts b/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-export.component.ts index 6954d8ef8..21473e912 100644 --- a/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-export.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-export.component.ts @@ -3,12 +3,13 @@ import { ChangeDetectionStrategy, Component, computed, + EventEmitter, inject, OnInit, + Output, signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; import { FeedMirror, FeedSnapshot, @@ -23,337 +24,366 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; @Component({ selector: 'app-airgap-export', - imports: [CommonModule, FormsModule, RouterModule], + imports: [CommonModule, FormsModule], template: ` -
- +
+
+
+

Export AirGap Bundle

+ +
- - - -
- - @if (currentStep() === 'select') { -
-

Select Feeds to Include

-

Choose which vulnerability feeds to include in the bundle.

- - @if (loading()) { -
-
-

Loading available feeds...

-
- } @else { -
- @for (mirror of mirrors(); track mirror.mirrorId) { - - } -
- -
- {{ selectedFeeds().length }} feeds selected - Estimated size: {{ estimatedSize() }} -
- -
- +
+ +
- } + - - @if (currentStep() === 'configure') { -
-

Configure Bundle

-

Set bundle options and metadata.

+
+ + @if (currentStep() === 'select') { +
+

Select Feeds to Include

+

Choose which vulnerability feeds to include in the bundle.

-
-
- - -
+ @if (loading()) { +
+
+

Loading available feeds...

+
+ } @else { +
+ @for (mirror of mirrors(); track mirror.mirrorId) { + + } +
-
- - -
+
+ {{ selectedFeeds().length }} feeds selected + Estimated size: {{ estimatedSize() }} +
-
- - - Bundle will expire after this many days -
- -
- - Sign the bundle for integrity verification -
- -
- - Automatically include the most recent snapshot for each feed -
-
- -
-

Selected Feeds

-
- @for (feedType of selectedFeeds(); track feedType) { - - {{ feedType | uppercase }} - - +
} -
-
+
+ } -
- - -
- - } + + @if (currentStep() === 'configure') { +
+

Configure Bundle

+

Set bundle options and metadata.

- - @if (currentStep() === 'building') { -
-

Building Bundle

-

Creating your air-gapped bundle...

+
+
+ + +
-
-
-
-
-
- {{ buildProgress() }}% - {{ buildStatus() }} -
-
-
- } +
+ + +
- - @if (currentStep() === 'ready' && createdBundle()) { -
-
- - - - -
-

Bundle Ready

-

Your air-gapped bundle has been created and is ready for download.

+
+ + + Bundle will expire after this many days +
-
-
- Bundle Name - {{ createdBundle()!.name }} -
-
- Size - {{ formatBytes(createdBundle()!.sizeBytes) }} -
-
- Included Feeds -
- @for (feed of createdBundle()!.includedFeeds; track feed) { - {{ feed | uppercase }} +
+ + Sign the bundle for integrity verification +
+ +
+ + Automatically include the most recent snapshot for each feed +
+
+ +
+

Selected Feeds

+
+ @for (feedType of selectedFeeds(); track feedType) { + + {{ feedType | uppercase }} + + + } +
+
+ +
+ + +
+
+ } + + + @if (currentStep() === 'building') { +
+

Building Bundle

+

Creating your air-gapped bundle...

+ +
+
+
+
+
+ {{ buildProgress() }}% + {{ buildStatus() }} +
+
+
+ } + + + @if (currentStep() === 'ready' && createdBundle()) { +
+
+ + + + +
+

Bundle Ready

+

Your air-gapped bundle has been created and is ready for download.

+ +
+
+ Bundle Name + {{ createdBundle()!.name }} +
+
+ Size + {{ formatBytes(createdBundle()!.sizeBytes) }} +
+
+ Included Feeds +
+ @for (feed of createdBundle()!.includedFeeds; track feed) { + {{ feed | uppercase }} + } +
+
+ @if (createdBundle()!.expiresAt) { +
+ Expires + {{ formatDate(createdBundle()!.expiresAt!) }} +
}
-
- @if (createdBundle()!.expiresAt) { -
- Expires - {{ formatDate(createdBundle()!.expiresAt!) }} + +
+

Checksums

+
+ SHA-256 + {{ createdBundle()!.checksumSha256 || 'Generating...' }} + +
- } -
-
-

Checksums

-
- SHA-256 - {{ createdBundle()!.checksumSha256 || 'Generating...' }} - -
-
+
+ + + + + + + Download Bundle + + @if (createdBundle()!.signatureUrl) { + + Download Signature + + } +
-
- - - - - - - Download Bundle - - @if (createdBundle()!.signatureUrl) { - - Download Signature - - } -
- -
- - Back to Feed Mirror - - -
- - } +
+ + +
+ + } +
+
`, styles: [` - .airgap-export { - padding: 1.5rem; - color: rgba(212, 201, 168, 0.3); - background: var(--color-text-heading); - min-height: calc(100vh - 120px); - } - - .page-header { - margin-bottom: 2rem; - - h1 { - margin: 1rem 0 0.25rem; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); - } - - .subtitle { - margin: 0; - color: var(--color-text-muted); - font-size: 0.875rem; - } - } - - .back-link { - display: inline-flex; + .workflow-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; align-items: center; - gap: 0.5rem; + justify-content: center; + z-index: 1000; + padding: 2rem; + backdrop-filter: blur(2px); + } + + .workflow-container { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + width: 100%; + max-width: 780px; + max-height: calc(100vh - 4rem); + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25); + } + + .workflow-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border-primary); + position: sticky; + top: 0; + background: var(--color-surface-primary); + z-index: 1; + } + + .workflow-title { + margin: 0; + font-size: 1.125rem; + font-weight: var(--font-weight-semibold); + } + + .close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); color: var(--color-text-muted); - text-decoration: none; - font-size: 0.875rem; - transition: color 0.15s; + cursor: pointer; + transition: all 0.15s; &:hover { - color: rgba(212, 201, 168, 0.3); + background: var(--color-surface-secondary); + color: var(--color-text-primary); } } + .workflow-body { + padding: 1.5rem; + } + .steps-nav { display: flex; gap: 0.5rem; @@ -366,18 +396,18 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; gap: 0.5rem; flex: 1; padding: 0.75rem 1rem; - background: var(--color-text-heading); - border: 1px solid var(--color-surface-inverse); + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); transition: all 0.15s; &--active { border-color: var(--color-status-info); - background: rgba(59, 130, 246, 0.1); + background: var(--color-status-info-bg); .step-number { background: var(--color-status-info); - color: white; + color: var(--color-surface-inverse); } } @@ -386,7 +416,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .step-number { background: var(--color-status-success); - color: white; + color: var(--color-surface-inverse); } } } @@ -397,7 +427,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; justify-content: center; width: 24px; height: 24px; - background: var(--color-text-primary); + background: var(--color-border-primary); border-radius: var(--radius-full); font-size: 0.75rem; font-weight: var(--font-weight-semibold); @@ -414,8 +444,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; } .step-content { - background: var(--color-text-heading); - border: 1px solid var(--color-surface-inverse); + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 2rem; @@ -442,7 +472,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .loading-spinner { width: 32px; height: 32px; - border: 3px solid var(--color-text-primary); + border: 3px solid var(--color-border-primary); border-top-color: var(--color-status-info); border-radius: var(--radius-full); animation: spin 1s linear infinite; @@ -465,8 +495,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; gap: 1rem; align-items: center; padding: 1rem; - background: var(--color-text-heading); - border: 1px solid var(--color-surface-inverse); + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); cursor: pointer; transition: all 0.15s; @@ -483,7 +513,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; &--selected { border-color: var(--color-status-info); - background: rgba(59, 130, 246, 0.05); + background: var(--color-status-info-bg); } &--disabled { @@ -506,12 +536,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; font-weight: var(--font-weight-bold); width: fit-content; - &--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); } - &--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); } - &--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); } - &--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); } - &--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); } - &--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); } + &--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); } + &--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); } + &--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); } + &--osv { background: var(--color-status-success-bg); color: var(--color-status-success); } + &--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); } + &--kev { background: var(--color-status-error-bg); color: var(--color-status-error); } } .feed-name { @@ -536,16 +566,16 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; text-transform: uppercase; } - .status--synced { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); } - .status--syncing { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); } - .status--stale { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); } - .status--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); } + .status--synced { background: var(--color-status-success-bg); color: var(--color-status-success); } + .status--syncing { background: var(--color-status-info-bg); color: var(--color-status-info); } + .status--stale { background: var(--color-status-warning-bg); color: var(--color-status-warning); } + .status--error { background: var(--color-status-error-bg); color: var(--color-status-error); } .selection-summary { display: flex; justify-content: space-between; padding: 1rem; - background: var(--color-text-heading); + background: var(--color-surface-secondary); border-radius: var(--radius-md); font-size: 0.875rem; } @@ -590,10 +620,10 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .form-input { padding: 0.625rem 1rem; - background: var(--color-text-heading); - border: 1px solid var(--color-text-primary); + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - color: rgba(212, 201, 168, 0.3); + color: var(--color-text-primary); font-size: 0.875rem; &:focus { @@ -618,7 +648,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .selected-feeds-summary { padding: 1rem; - background: var(--color-text-heading); + background: var(--color-surface-secondary); border-radius: var(--radius-md); margin-bottom: 1.5rem; @@ -647,12 +677,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; font-size: 0.6875rem; font-weight: var(--font-weight-semibold); - &--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); } - &--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); } - &--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); } - &--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); } - &--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); } - &--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); } + &--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); } + &--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); } + &--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); } + &--osv { background: var(--color-status-success-bg); color: var(--color-status-success); } + &--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); } + &--kev { background: var(--color-status-error-bg); color: var(--color-status-error); } } .chip-remove { @@ -675,7 +705,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .progress-bar-container { height: 8px; - background: var(--color-text-primary); + background: var(--color-border-primary); border-radius: var(--radius-sm); overflow: hidden; margin-bottom: 1rem; @@ -713,7 +743,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .bundle-details { text-align: left; - background: var(--color-text-heading); + background: var(--color-surface-secondary); border-radius: var(--radius-md); padding: 1.25rem; margin: 1.5rem 0; @@ -724,7 +754,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; justify-content: space-between; align-items: center; padding: 0.5rem 0; - border-bottom: 1px solid var(--color-surface-inverse); + border-bottom: 1px solid var(--color-border-primary); &:last-child { border-bottom: none; @@ -754,7 +784,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .checksum-section { text-align: left; - background: var(--color-text-heading); + background: var(--color-surface-secondary); border-radius: var(--radius-md); padding: 1.25rem; margin-bottom: 1.5rem; @@ -789,8 +819,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; .copy-btn { padding: 0.25rem 0.625rem; - background: var(--color-surface-inverse); - border: 1px solid var(--color-text-primary); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); color: var(--color-text-muted); font-size: 0.75rem; @@ -798,8 +828,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; transition: all 0.15s; &:hover { - background: var(--color-text-primary); - color: rgba(212, 201, 168, 0.3); + background: var(--color-surface-secondary); + color: var(--color-text-primary); } } @@ -816,7 +846,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; justify-content: flex-end; margin-top: 1.5rem; padding-top: 1.5rem; - border-top: 1px solid var(--color-surface-inverse); + border-top: 1px solid var(--color-border-primary); } .btn { @@ -838,23 +868,23 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; } &--primary { - background: var(--color-status-info-text); + background: var(--color-btn-primary-bg); border: none; - color: white; + color: var(--color-text-heading); &:hover:not(:disabled) { - background: var(--color-status-info-text); + background: var(--color-brand-secondary); } } &--secondary { background: transparent; - border: 1px solid var(--color-text-primary); - color: var(--color-text-muted); + border: 1px solid var(--color-border-primary); + color: var(--color-text-primary); &:hover:not(:disabled) { - background: var(--color-surface-inverse); - color: rgba(212, 201, 168, 0.3); + background: var(--color-surface-secondary); + color: var(--color-text-primary); } } @@ -869,9 +899,11 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready'; export class AirgapExportComponent implements OnInit { private readonly dateFmt = inject(DateFormatService); - private readonly router = inject(Router); private readonly feedMirrorApi = inject(FEED_MIRROR_API); + @Output() closed = new EventEmitter(); + @Output() completed = new EventEmitter(); + readonly steps = [ { id: 'select' as ExportStep, number: 1, label: 'Select Feeds' }, { id: 'configure' as ExportStep, number: 2, label: 'Configure' }, @@ -913,6 +945,12 @@ export class AirgapExportComponent implements OnInit { this.loadMirrors(); } + onBackdropClick(event: MouseEvent): void { + if ((event.target as HTMLElement).classList.contains('workflow-overlay')) { + this.closed.emit(); + } + } + private loadMirrors(): void { this.loading.set(true); this.feedMirrorApi.listMirrors({ enabled: true }).subscribe({ diff --git a/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-import.component.ts b/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-import.component.ts index fd7543e0d..4d344e914 100644 --- a/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-import.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/feed-mirror/airgap-import.component.ts @@ -3,14 +3,17 @@ import { ChangeDetectionStrategy, Component, computed, + EventEmitter, inject, + Output, signal, } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; +import { FormsModule } from '@angular/forms'; import { AirGapImportValidation, AirGapImportProgress, - FeedType, + AirGapImportRequest, + ImportSourceMode, } from '../../core/api/feed-mirror.models'; import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client'; @@ -18,21 +21,21 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete'; @Component({ selector: 'app-airgap-import', - imports: [CommonModule, RouterModule], + imports: [CommonModule, FormsModule], template: ` -
- +
+
+
+

Import AirGap Bundle

+ +
+
+ +
+
+

Available Bundles

+ {{ bundles().length }} bundles +
+ @if (loadingBundles()) { +
Loading bundles...
+ } @else if (bundles().length === 0) { +
+

No bundles available

+ Create an export bundle for offline deployment, or import from external media. +
+ } @else { +
+ @for (bundle of bundles(); track bundle.bundleId) { +
+
+

{{ bundle.name }}

+ + {{ bundle.status | titlecase }} + +
+ @if (bundle.description) { +

{{ bundle.description }}

+ } +
+ @for (feed of bundle.includedFeeds; track feed) { + {{ feed | uppercase }} + } +
+
+ {{ formatBytes(bundle.sizeBytes) }} + {{ formatDate(bundle.createdAt) }} + @if (bundle.expiresAt) { + Expires {{ formatDate(bundle.expiresAt) }} + } +
+ @if (bundle.status === 'ready' && bundle.downloadUrl) { +
+ Download +
+ } +
+ } +
+ } +
} - + + + @if (tab() === 'version-locks' && !loading()) { + + } + + + @if (tab() === 'freshness-lens' && !loading()) { +
+
+
+

Freshness SLA Status

+

Feed freshness derived from mirror sync intervals. SLA thresholds based on each mirror's configured sync interval.

+
+
+ + + + + + + + + + + + @for (row of freshnessRows(); track row.mirrorId) { + + + + + + + + } @empty { + + } + +
SourceStatusLast SyncSLAGate Impact
+ {{ row.feedType | uppercase }} + {{ row.source }} + + {{ row.status }} + {{ row.lastSync ? formatDate(row.lastSync) : 'Never' }}{{ row.sla }}{{ row.gateImpact }}
No mirrors configured.
+
+ } + + + @if (showImport()) { + + } + + @if (showExport()) { + + } `, styles: [` - .feeds-offline { + .feeds-airgap { display: grid; gap: 0.75rem; } - .feeds-offline__header { + /* ---- Header ---- */ + .feeds-airgap__header { display: flex; justify-content: space-between; - gap: 0.75rem; - align-items: start; + align-items: flex-start; + gap: 1.5rem; + flex-wrap: wrap; } - .feeds-offline__header h1 { + .feeds-airgap__header h1 { margin: 0; font-size: 1.55rem; } - .feeds-offline__header p { + .feeds-airgap__header p { margin: 0.2rem 0 0; font-size: 0.82rem; color: var(--color-text-secondary); @@ -173,159 +324,122 @@ type FeedsAirgapAction = 'import' | 'export' | null; line-height: 1.5; } - .feeds-offline__actions { + .header-status { display: flex; - gap: 0.45rem; + gap: 1rem; + align-items: flex-start; flex-wrap: wrap; } - .feeds-offline__actions a { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-primary); - text-decoration: none; - font-size: 0.76rem; - font-weight: 500; - padding: 0.38rem 0.65rem; - cursor: pointer; - transition: background 150ms ease, border-color 150ms ease, transform 150ms ease; - } - - .feeds-offline__actions a:hover { - background: var(--color-surface-secondary); - border-color: var(--color-brand-primary); - } - - .feeds-offline__actions a:active { - transform: translateY(1px); - } - - - - .summary { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - } - - .summary span { - border: 1px solid var(--color-border-primary); - border-radius: 9999px; - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.72rem; - font-weight: 500; - padding: 0.18rem 0.55rem; - } - - .status-banner { - border-radius: var(--radius-lg); - padding: 0.55rem 0.75rem; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - align-items: center; - font-size: 0.78rem; - } - - .status-banner--healthy { - border: 1px solid var(--color-status-success-border); - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - } - - .status-banner code { - font-size: 0.72rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-primary); - color: var(--color-text-primary); - padding: 0.1rem 0.35rem; - font-family: monospace; - } - - .status-banner a { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-link); - font-size: 0.72rem; - font-weight: 500; - padding: 0.18rem 0.5rem; - text-decoration: none; - transition: border-color 150ms ease, background 150ms ease; - } - - .status-banner a:hover { - border-color: var(--color-brand-primary); - background: var(--color-surface-secondary); - } - - .panel { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - padding: 0.75rem; + /* ---- Stats row ---- */ + .stats-row { display: grid; - gap: 0.5rem; - transition: box-shadow 150ms ease; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; } - .panel:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - } - - table { - width: 100%; - border-collapse: collapse; - } - - th { - text-align: left; - font-size: 0.68rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-secondary); - font-weight: 600; - padding: 0.5rem 0.75rem; - border-bottom: 2px solid var(--color-border-primary); - position: sticky; - top: 0; + .stat-card { + display: flex; + flex-direction: column; + padding: 0.75rem 1rem; background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + border-left: 3px solid var(--color-border-primary); } - td { - text-align: left; - border-bottom: 1px solid var(--color-border-primary); - padding: 0.5rem 0.75rem; - font-size: 0.78rem; - white-space: nowrap; + .stat-card .stat-value { + font-size: 1.35rem; + font-weight: var(--font-weight-semibold, 600); + line-height: 1.2; } - tbody tr { - transition: background 150ms ease; - } - - tbody tr:hover { - background: rgba(0, 0, 0, 0.03); - } - - tbody tr:nth-child(even) { - background: rgba(0, 0, 0, 0.015); - } - - tbody tr:nth-child(even):hover { - background: rgba(0, 0, 0, 0.03); - } - - .panel p { - margin: 0; - font-size: 0.8rem; + .stat-card .stat-label { + font-size: 0.6875rem; + text-transform: uppercase; color: var(--color-text-secondary); - line-height: 1.5; + letter-spacing: 0.05em; } + .stat-card--synced { border-left-color: var(--color-status-success); } + .stat-card--synced .stat-value { color: var(--color-status-success-text); } + .stat-card--stale { border-left-color: var(--color-status-warning); } + .stat-card--stale .stat-value { color: var(--color-status-warning-text); } + .stat-card--error { border-left-color: var(--color-status-error); } + .stat-card--error .stat-value { color: var(--color-status-error-text); } + .stat-card--storage { border-left-color: var(--color-brand-primary); } + .stat-card--storage .stat-value { color: var(--color-text-link); } + + /* ---- Loading ---- */ + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + color: var(--color-text-muted); + } + + .loading-container p { margin: 1rem 0 0; font-size: 0.875rem; } + + .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + + /* ---- Airgap Bundles ---- */ + .airgap-content { display: grid; gap: 1.5rem; } + + .airgap-actions-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + } + + .action-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + text-decoration: none; + color: inherit; + transition: all 0.15s; + } + + .action-card:hover { + border-color: var(--color-brand-primary); + background: var(--color-surface-secondary); + } + + .action-card--import:hover { border-color: var(--color-status-success-border); } + .action-card--import:hover .action-icon { color: var(--color-status-success-text); } + .action-card--export:hover .action-icon { color: var(--color-text-link); } + + .action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + background: var(--color-surface-secondary); + border-radius: var(--radius-lg); + color: var(--color-text-muted); + transition: color 0.15s; + } + + .action-text h3 { margin: 0 0 0.25rem; font-size: 0.95rem; font-weight: var(--font-weight-semibold, 600); } + .action-text p { margin: 0; font-size: 0.8rem; color: var(--color-text-secondary); } + .action-banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); @@ -335,108 +449,352 @@ type FeedsAirgapAction = 'import' | 'export' | null; display: grid; gap: 0.25rem; font-size: 0.78rem; + margin-bottom: 0.5rem; } - .panel__links { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - } - - .panel a, - .panel__links a { - font-size: 0.78rem; - font-weight: 500; - color: var(--color-text-link); - text-decoration: none; - padding: 0.3rem 0.6rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - transition: border-color 150ms ease, background 150ms ease; - } - - .panel a:hover, - .panel__links a:hover { - border-color: var(--color-brand-primary); - background: var(--color-surface-secondary); - } - - .offline-kit-section { - display: grid; - gap: 0.75rem; - } - - .offline-kit-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; - } - - .offline-kit-card { - display: grid; - gap: 0.2rem; - padding: 0.75rem 1rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); + /* ---- Bundles section ---- */ + .bundles-section { background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .section-header h2 { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold, 600); } + .section-subtitle { margin: 0.25rem 0 0; font-size: 0.8rem; color: var(--color-text-secondary); } + .bundle-count { font-size: 0.75rem; color: var(--color-text-secondary); } + + .loading-placeholder, + .empty-bundles { padding: 2rem; text-align: center; color: var(--color-text-secondary); } + .empty-hint { display: block; margin-top: 0.5rem; font-size: 0.8125rem; color: var(--color-text-secondary); } + + .bundles-grid { display: grid; gap: 1rem; padding: 1rem; } + + .bundle-card { + padding: 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + } + + .bundle-card--building { border-color: var(--color-status-info-border); } + + .bundle-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + } + + .bundle-header h3 { margin: 0; font-size: 0.9375rem; font-weight: var(--font-weight-semibold, 600); } + + .bundle-status { + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.625rem; + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + } + + .bundle-status--ready { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); } + .bundle-status--building { background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); } + .bundle-status--pending { background: var(--color-severity-none-bg); color: var(--color-text-muted); border: 1px solid var(--color-severity-none-border); } + .bundle-status--error { background: var(--color-status-error-bg); color: var(--color-status-error-text); border: 1px solid var(--color-status-error-border); } + .bundle-status--expired { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); } + + .bundle-description { margin: 0 0 0.75rem; font-size: 0.8125rem; color: var(--color-text-muted); } + + .bundle-feeds { display: flex; gap: 0.375rem; flex-wrap: wrap; margin-bottom: 0.75rem; } + + .feed-chip { + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); + font-size: 0.5625rem; + font-weight: var(--font-weight-bold, 700); + border: 1px solid var(--color-border-primary); + } + + .feed-chip--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info-text); border-color: var(--color-severity-info-border); } + .feed-chip--ghsa { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); } + .feed-chip--oval { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); } + .feed-chip--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-color: var(--color-severity-low-border); } + .feed-chip--epss { background: var(--color-severity-high-bg); color: var(--color-status-warning-text); border-color: var(--color-severity-high-border); } + .feed-chip--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); border-color: var(--color-severity-critical-border); } + + .bundle-meta { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.75rem; color: var(--color-text-secondary); } + .meta-item { display: flex; align-items: center; gap: 0.375rem; } + .meta-item--expiry { color: var(--color-status-warning); } + + .bundle-actions { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-primary); + } + + .btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium, 500); text-decoration: none; - transition: border-color 150ms ease, box-shadow 150ms ease; + cursor: pointer; + transition: all 0.15s; } - .offline-kit-card:hover { - border-color: var(--color-brand-primary); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + .btn--small { padding: 0.375rem 0.75rem; font-size: 0.75rem; } + .btn--primary { + background: var(--color-btn-primary-bg); + border: none; + color: var(--color-text-heading); + } + .btn--primary:hover { background: var(--color-brand-secondary); } + + /* ---- Freshness Lens ---- */ + .freshness-lens { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; } - .offline-kit-card strong { - font-size: 0.82rem; - color: var(--color-text-primary); - } + .freshness-lens table { width: 100%; border-collapse: collapse; } - .offline-kit-card span { - font-size: 0.72rem; + .freshness-lens th { + text-align: left; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; color: var(--color-text-secondary); + font-weight: 600; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid var(--color-border-primary); + background: var(--color-surface-primary); } + .freshness-lens td { + text-align: left; + border-bottom: 1px solid var(--color-border-primary); + padding: 0.5rem 0.75rem; + font-size: 0.78rem; + } + + .freshness-lens tbody tr { transition: background 150ms ease; } + .freshness-lens tbody tr:hover { background: rgba(0, 0, 0, 0.03); } + + .gate-impact { font-size: 0.75rem; color: var(--color-text-secondary); max-width: 30ch; } + .empty-cell { text-align: center; color: var(--color-text-muted); padding: 2rem 0.75rem; } + + .freshness-badge { + padding: 0.15rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.625rem; + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + } + + .freshness-badge--ok { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); } + .freshness-badge--warn { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); } + .freshness-badge--fail { background: var(--color-status-error-bg); color: var(--color-status-error-text); border: 1px solid var(--color-status-error-border); } + @media (max-width: 640px) { - .offline-kit-grid { - grid-template-columns: 1fr; - } + .stats-row { grid-template-columns: repeat(2, 1fr); } + .airgap-actions-row { grid-template-columns: 1fr; } } `], }) -export class PlatformFeedsAirgapPageComponent implements OnInit { +export class PlatformFeedsAirgapPageComponent implements OnInit, OnDestroy { + private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); + private readonly feedMirrorApi = inject(FEED_MIRROR_API); + + private static readonly VALID_TABS: readonly FeedsOfflineTab[] = [ + 'feed-mirrors', 'airgap-bundles', 'version-locks', 'freshness-lens', + ]; readonly OPERATIONS_PATHS = OPERATIONS_PATHS; - readonly OPERATIONS_INTEGRATION_PATHS = OPERATIONS_INTEGRATION_PATHS; readonly feedsTabs = FEEDS_TABS; - readonly feedsFreshnessPath = dataIntegrityPath('feeds-freshness'); readonly tab = signal('feed-mirrors'); readonly airgapAction = signal(null); + readonly showImport = signal(false); + readonly showExport = signal(false); + + // API-driven state + readonly mirrors = signal([]); + readonly bundles = signal([]); + readonly offlineStatus = signal(null); + readonly loading = signal(true); + readonly loadingBundles = signal(true); + readonly filter = signal({}); + + // Computed + readonly syncedCount = computed(() => + this.mirrors().filter(m => m.syncStatus === 'synced').length, + ); + readonly staleCount = computed(() => + this.mirrors().filter(m => m.syncStatus === 'stale').length, + ); + readonly errorCount = computed(() => + this.mirrors().filter(m => m.syncStatus === 'error').length, + ); + readonly totalStorageDisplay = computed(() => { + const status = this.offlineStatus(); + return status ? this.formatBytes(status.totalStorageBytes) : '0 B'; + }); + readonly latestSyncTime = computed(() => { + const times = this.mirrors() + .filter(m => m.lastSyncAt) + .map(m => new Date(m.lastSyncAt!).getTime()); + return times.length ? new Date(Math.max(...times)).toISOString() : null; + }); + readonly isOnline = computed(() => { + const s = this.offlineStatus(); + return s?.state === 'online' || s?.state === 'syncing'; + }); + readonly hasStaleData = computed(() => { + const s = this.offlineStatus(); + if (!s) return false; + return s.state === 'stale' || s.state === 'partial' || s.mirrorStats.stale > 0; + }); + + readonly freshnessRows = computed(() => { + return this.mirrors().map(mirror => { + const ageMinutes = mirror.lastSyncAt + ? (Date.now() - new Date(mirror.lastSyncAt).getTime()) / (1000 * 60) + : Infinity; + const sla = mirror.syncIntervalMinutes; + let status: 'OK' | 'WARN' | 'FAIL'; + if (!mirror.enabled || mirror.syncStatus === 'disabled') { + status = 'FAIL'; + } else if (ageMinutes > sla * 2 || mirror.syncStatus === 'error') { + status = 'FAIL'; + } else if (ageMinutes > sla || mirror.syncStatus === 'stale') { + status = 'WARN'; + } else { + status = 'OK'; + } + const gateImpact = status === 'OK' + ? 'Fresh source keeps gates trusted.' + : status === 'WARN' + ? 'Staleness may downgrade approval confidence.' + : 'Feed errors or severe staleness block reliable gate decisions.'; + return { + mirrorId: mirror.mirrorId, + source: mirror.name, + feedType: mirror.feedType, + status, + lastSync: mirror.lastSyncAt, + sla: `\u2264 ${sla}m`, + gateImpact, + }; + }); + }); ngOnInit(): void { - this.route.queryParamMap - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - const requested = params.get('tab'); - if ( - requested === 'feed-mirrors' || - requested === 'airgap-bundles' || - requested === 'version-locks' || - requested === 'offline-kit' - ) { - this.tab.set(requested); - } + this.syncFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.syncFromUrl(e.urlAfterRedirects)); - const requestedAction = params.get('action'); - if (requestedAction === 'import' || requestedAction === 'export') { - this.airgapAction.set(requestedAction); - return; - } + this.loadData(); + this.loadBundles(); + } - this.airgapAction.set(null); - }); + ngOnDestroy(): void { + // clean up if needed + } + + onTabChange(tabId: string): void { + this.tab.set(tabId as FeedsOfflineTab); + this.router.navigate([], { + relativeTo: this.route, + queryParams: { tab: tabId }, + queryParamsHandling: 'merge', + }); + } + + navigateToMirror(mirror: FeedMirror): void { + this.router.navigate(['/ops/operations/feeds/mirror', mirror.mirrorId]); + } + + applyFilter(newFilter: FeedMirrorFilter): void { + this.filter.set(newFilter); + this.loadData(); + } + + refreshAll(): void { + this.loadData(); + this.loadBundles(); + } + + onImportComplete(): void { + this.showImport.set(false); + this.refreshAll(); + } + + onExportComplete(): void { + this.showExport.set(false); + this.refreshAll(); + } + + formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + formatDate(isoString: string): string { + try { + return new Date(isoString).toLocaleDateString(); + } catch { + return isoString; + } + } + + private loadData(): void { + this.loading.set(true); + this.feedMirrorApi.listMirrors(this.filter()).subscribe({ + next: (mirrors) => { this.mirrors.set(mirrors); this.loading.set(false); }, + error: (err) => { console.error('Failed to load mirrors:', err); this.loading.set(false); }, + }); + this.feedMirrorApi.getOfflineSyncStatus().subscribe({ + next: (status) => this.offlineStatus.set(status), + error: (err) => console.error('Failed to load offline status:', err), + }); + } + + private loadBundles(): void { + this.loadingBundles.set(true); + this.feedMirrorApi.listBundles().subscribe({ + next: (bundles) => { this.bundles.set(bundles); this.loadingBundles.set(false); }, + error: (err) => { console.error('Failed to load bundles:', err); this.loadingBundles.set(false); }, + }); + } + + private syncFromUrl(url: string): void { + const params = new URLSearchParams(url.split('?')[1] ?? ''); + + const requested = params.get('tab') as FeedsOfflineTab | null; + if (requested && PlatformFeedsAirgapPageComponent.VALID_TABS.includes(requested)) { + this.tab.set(requested); + } + + const action = params.get('action'); + this.airgapAction.set( + action === 'import' || action === 'export' ? action : null, + ); } } diff --git a/src/Web/StellaOps.Web/src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts index 3f0cd86ca..13c0b731b 100644 --- a/src/Web/StellaOps.Web/src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/platform-ops/platform-feeds-airgap-page.component.spec.ts @@ -1,61 +1,152 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject } from 'rxjs'; +import { provideRouter, Router } from '@angular/router'; +import { of } from 'rxjs'; import { PlatformFeedsAirgapPageComponent } from '../../app/features/platform/ops/platform-feeds-airgap-page.component'; +import { FEED_MIRROR_API } from '../../app/core/api/feed-mirror.client'; +import type { FeedMirror, OfflineSyncStatus, AirGapBundle } from '../../app/core/api/feed-mirror.models'; describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => { let fixture: ComponentFixture; let component: PlatformFeedsAirgapPageComponent; - let queryParamMap$: BehaviorSubject; - let currentQueryParamMap: ParamMap; + let router: Router; + let mockApi: jasmine.SpyObj; + + const mockMirrors: Partial[] = [ + { + mirrorId: 'mirror-1', + name: 'NVD Mirror', + feedType: 'nvd', + enabled: true, + syncStatus: 'synced', + lastSyncAt: new Date().toISOString(), + syncIntervalMinutes: 120, + totalSizeBytes: 500 * 1024 * 1024, + }, + { + mirrorId: 'mirror-2', + name: 'OSV Mirror', + feedType: 'osv', + enabled: true, + syncStatus: 'stale', + lastSyncAt: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), + syncIntervalMinutes: 120, + totalSizeBytes: 200 * 1024 * 1024, + }, + { + mirrorId: 'mirror-3', + name: 'KEV Mirror', + feedType: 'kev', + enabled: true, + syncStatus: 'error', + lastSyncAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), + syncIntervalMinutes: 360, + totalSizeBytes: 10 * 1024 * 1024, + }, + ]; + + const mockOfflineStatus: Partial = { + state: 'partial', + lastOnlineAt: new Date().toISOString(), + mirrorStats: { total: 3, synced: 1, stale: 1, error: 1 }, + totalStorageBytes: 710 * 1024 * 1024, + recommendations: [], + }; + + const mockBundles: Partial[] = [ + { + bundleId: 'bundle-1', + name: 'Full Export', + status: 'ready', + sizeBytes: 1024 * 1024 * 500, + includedFeeds: ['nvd', 'osv'], + createdAt: new Date().toISOString(), + downloadUrl: '/api/bundles/bundle-1/download', + }, + ]; beforeEach(async () => { - currentQueryParamMap = convertToParamMap({ tab: 'version-locks' }); - queryParamMap$ = new BehaviorSubject(currentQueryParamMap); + mockApi = jasmine.createSpyObj('FeedMirrorApi', [ + 'listMirrors', + 'getOfflineSyncStatus', + 'listBundles', + 'listVersionLocks', + ]); + mockApi.listMirrors.and.returnValue(of(mockMirrors)); + mockApi.getOfflineSyncStatus.and.returnValue(of(mockOfflineStatus)); + mockApi.listBundles.and.returnValue(of(mockBundles)); + mockApi.listVersionLocks.and.returnValue(of([])); await TestBed.configureTestingModule({ imports: [PlatformFeedsAirgapPageComponent], providers: [ - provideRouter([]), - { - provide: ActivatedRoute, - useValue: { - queryParamMap: queryParamMap$.asObservable(), - get snapshot() { - return { queryParamMap: currentQueryParamMap }; - }, - }, - }, + provideRouter([ + { path: '**', component: PlatformFeedsAirgapPageComponent }, + ]), + { provide: FEED_MIRROR_API, useValue: mockApi }, ], }).compileComponents(); + router = TestBed.inject(Router); + }); + + async function initWithUrl(url: string): Promise { + await router.navigateByUrl(url); fixture = TestBed.createComponent(PlatformFeedsAirgapPageComponent); component = fixture.componentInstance; fixture.detectChanges(); await fixture.whenStable(); + } + + it('loads mirrors and offline status on init', async () => { + await initWithUrl('/ops/operations/feeds-airgap'); + expect(mockApi.listMirrors).toHaveBeenCalled(); + expect(mockApi.getOfflineSyncStatus).toHaveBeenCalled(); + expect(component.mirrors().length).toBe(3); + expect(component.loading()).toBe(false); }); - it('selects tab from query param when value is valid', async () => { - await fixture.whenStable(); + it('loads bundles on init', async () => { + await initWithUrl('/ops/operations/feeds-airgap'); + expect(mockApi.listBundles).toHaveBeenCalled(); + expect(component.bundles().length).toBe(1); + expect(component.loadingBundles()).toBe(false); + }); + + it('computes synced, stale, and error counts', async () => { + await initWithUrl('/ops/operations/feeds-airgap'); + expect(component.syncedCount()).toBe(1); + expect(component.staleCount()).toBe(1); + expect(component.errorCount()).toBe(1); + }); + + it('selects tab from query param', async () => { + await initWithUrl('/ops/operations/feeds-airgap?tab=version-locks'); expect(component.tab()).toBe('version-locks'); }); - it('ignores unknown tab query values and keeps current tab', async () => { - currentQueryParamMap = convertToParamMap({ tab: 'unknown-tab' }); - queryParamMap$.next(currentQueryParamMap); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.tab()).toBe('version-locks'); + it('ignores unknown tab query values and keeps default', async () => { + await initWithUrl('/ops/operations/feeds-airgap?tab=unknown-tab'); + expect(component.tab()).toBe('feed-mirrors'); }); - it('tracks the airgap action from query params for canonical action links', async () => { - currentQueryParamMap = convertToParamMap({ tab: 'airgap-bundles', action: 'import' }); - queryParamMap$.next(currentQueryParamMap); - fixture.detectChanges(); - await fixture.whenStable(); + it('selects freshness-lens tab from query param', async () => { + await initWithUrl('/ops/operations/feeds-airgap?tab=freshness-lens'); + expect(component.tab()).toBe('freshness-lens'); + }); + it('computes freshness rows from mirror data', async () => { + await initWithUrl('/ops/operations/feeds-airgap?tab=freshness-lens'); + const rows = component.freshnessRows(); + expect(rows.length).toBe(3); + expect(rows[0].source).toBe('NVD Mirror'); + expect(rows[0].status).toBe('OK'); + expect(rows[1].status).toBe('WARN'); // stale syncStatus + expect(rows[2].status).toBe('FAIL'); // error syncStatus + }); + + it('tracks airgap action from query params for backward compat', async () => { + await initWithUrl('/ops/operations/feeds-airgap?tab=airgap-bundles&action=import'); expect(component.tab()).toBe('airgap-bundles'); expect(component.airgapAction()).toBe('import'); expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');