feat(airgap): multi-source import (server path, URL, file upload) with overlay UX

Import now supports three sources: server-side path (USB/NFS volumes),
backend URL download, and browser file upload. Export/import workflows
refactored from routed pages to overlay dialogs. Docs updated with
volume mount instructions and source comparison table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 15:33:21 +03:00
parent 6e72ad844e
commit 3a95f315bd
11 changed files with 1641 additions and 882 deletions

View File

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

View File

@@ -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)

View File

@@ -10,6 +10,7 @@ import {
AirGapBundleRequest,
AirGapImportValidation,
AirGapImportProgress,
AirGapImportRequest,
FeedVersionLock,
FeedVersionLockRequest,
OfflineSyncStatus,
@@ -54,7 +55,7 @@ export interface FeedMirrorApi {
downloadBundle(bundleId: string): Observable<SnapshotDownloadProgress>;
// AirGap import operations
validateImport(file: File): Observable<AirGapImportValidation>;
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation>;
startImport(bundleId: string): Observable<AirGapImportProgress>;
getImportProgress(importId: string): Observable<AirGapImportProgress>;
@@ -467,12 +468,18 @@ export class FeedMirrorHttpClient implements FeedMirrorApi {
// ---- AirGap import operations ----
validateImport(file: File): Observable<AirGapImportValidation> {
const formData = new FormData();
formData.append('file', file, file.name);
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation> {
if (source.mode === 'file' && source.file) {
const formData = new FormData();
formData.append('file', source.file, source.file.name);
return this.http.post<AirGapImportValidation>(
`${this.baseUrl}/imports/validate`,
formData,
);
}
return this.http.post<AirGapImportValidation>(
`${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<AirGapImportValidation> {
validateImport(_source: AirGapImportRequest): Observable<AirGapImportValidation> {
return of({
bundleId: 'import-validation-temp',
status: 'valid' as const,

View File

@@ -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.
*/

View File

@@ -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: `
<div class="airgap-export">
<header class="page-header">
<a routerLink="/ops/feeds" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
</svg>
Back to Feed Mirror
</a>
<h1>Export AirGap Bundle</h1>
<p class="subtitle">Create a vulnerability feed bundle for air-gapped deployment</p>
</header>
<div class="workflow-overlay" (click)="onBackdropClick($event)">
<div class="workflow-container">
<header class="workflow-header">
<h2 class="workflow-title">Export AirGap Bundle</h2>
<button type="button" class="close-btn" (click)="closed.emit()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<!-- Step Progress -->
<nav class="steps-nav">
@for (step of steps; track step.id) {
<div
class="step"
[class.step--active]="currentStep() === step.id"
[class.step--completed]="isStepCompleted(step.id)"
>
<span class="step-number">{{ step.number }}</span>
<span class="step-label">{{ step.label }}</span>
</div>
}
</nav>
<div class="content-area">
<!-- Select Feeds Step -->
@if (currentStep() === 'select') {
<section class="step-content">
<h2>Select Feeds to Include</h2>
<p>Choose which vulnerability feeds to include in the bundle.</p>
@if (loading()) {
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Loading available feeds...</p>
</div>
} @else {
<div class="feed-selection">
@for (mirror of mirrors(); track mirror.mirrorId) {
<label
class="feed-option"
[class.feed-option--selected]="isSelected(mirror.feedType)"
[class.feed-option--disabled]="mirror.syncStatus === 'error'"
>
<input
type="checkbox"
[checked]="isSelected(mirror.feedType)"
[disabled]="mirror.syncStatus === 'error'"
(change)="toggleFeed(mirror.feedType)"
/>
<div class="feed-info">
<span class="feed-badge" [ngClass]="'feed-badge--' + mirror.feedType">
{{ mirror.feedType | uppercase }}
</span>
<span class="feed-name">{{ mirror.name }}</span>
</div>
<div class="feed-meta">
<span class="feed-status" [ngClass]="'status--' + mirror.syncStatus">
{{ mirror.syncStatus | titlecase }}
</span>
<span class="feed-size">{{ formatBytes(mirror.totalSizeBytes) }}</span>
@if (mirror.lastSyncAt) {
<span class="feed-updated">{{ formatDate(mirror.lastSyncAt) }}</span>
}
</div>
</label>
}
</div>
<div class="selection-summary">
<span>{{ selectedFeeds().length }} feeds selected</span>
<span class="estimated-size">Estimated size: {{ estimatedSize() }}</span>
</div>
<div class="step-actions">
<button
type="button"
class="btn btn--primary"
[disabled]="selectedFeeds().length === 0"
(click)="proceedToConfigure()"
>
Continue
</button>
<div class="workflow-body">
<!-- Step Progress -->
<nav class="steps-nav">
@for (step of steps; track step.id) {
<div
class="step"
[class.step--active]="currentStep() === step.id"
[class.step--completed]="isStepCompleted(step.id)"
>
<span class="step-number">{{ step.number }}</span>
<span class="step-label">{{ step.label }}</span>
</div>
}
</section>
}
</nav>
<!-- Configure Step -->
@if (currentStep() === 'configure') {
<section class="step-content">
<h2>Configure Bundle</h2>
<p>Set bundle options and metadata.</p>
<div class="content-area">
<!-- Select Feeds Step -->
@if (currentStep() === 'select') {
<section class="step-content">
<h2>Select Feeds to Include</h2>
<p>Choose which vulnerability feeds to include in the bundle.</p>
<div class="config-form">
<div class="form-group">
<label for="bundleName">Bundle Name *</label>
<input
type="text"
id="bundleName"
[ngModel]="bundleName()"
(ngModelChange)="bundleName.set($event)"
placeholder="e.g., Full Feed Bundle - December 2025"
class="form-input"
/>
</div>
@if (loading()) {
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Loading available feeds...</p>
</div>
} @else {
<div class="feed-selection">
@for (mirror of mirrors(); track mirror.mirrorId) {
<label
class="feed-option"
[class.feed-option--selected]="isSelected(mirror.feedType)"
[class.feed-option--disabled]="mirror.syncStatus === 'error'"
>
<input
type="checkbox"
[checked]="isSelected(mirror.feedType)"
[disabled]="mirror.syncStatus === 'error'"
(change)="toggleFeed(mirror.feedType)"
/>
<div class="feed-info">
<span class="feed-badge" [ngClass]="'feed-badge--' + mirror.feedType">
{{ mirror.feedType | uppercase }}
</span>
<span class="feed-name">{{ mirror.name }}</span>
</div>
<div class="feed-meta">
<span class="feed-status" [ngClass]="'status--' + mirror.syncStatus">
{{ mirror.syncStatus | titlecase }}
</span>
<span class="feed-size">{{ formatBytes(mirror.totalSizeBytes) }}</span>
@if (mirror.lastSyncAt) {
<span class="feed-updated">{{ formatDate(mirror.lastSyncAt) }}</span>
}
</div>
</label>
}
</div>
<div class="form-group">
<label for="bundleDescription">Description</label>
<textarea
id="bundleDescription"
[ngModel]="bundleDescription()"
(ngModelChange)="bundleDescription.set($event)"
placeholder="Optional description for this bundle..."
rows="3"
class="form-input"
></textarea>
</div>
<div class="selection-summary">
<span>{{ selectedFeeds().length }} feeds selected</span>
<span class="estimated-size">Estimated size: {{ estimatedSize() }}</span>
</div>
<div class="form-group">
<label for="expirationDays">Expiration (days)</label>
<input
type="number"
id="expirationDays"
[ngModel]="expirationDays()"
(ngModelChange)="expirationDays.set($event)"
min="7"
max="365"
class="form-input form-input--short"
/>
<span class="form-hint">Bundle will expire after this many days</span>
</div>
<div class="form-group form-group--checkbox">
<label>
<input
type="checkbox"
[checked]="includeSignature()"
(change)="includeSignature.set(!includeSignature())"
/>
<span>Include cryptographic signature</span>
</label>
<span class="form-hint">Sign the bundle for integrity verification</span>
</div>
<div class="form-group form-group--checkbox">
<label>
<input
type="checkbox"
[checked]="useLatestSnapshots()"
(change)="useLatestSnapshots.set(!useLatestSnapshots())"
/>
<span>Use latest snapshots</span>
</label>
<span class="form-hint">Automatically include the most recent snapshot for each feed</span>
</div>
</div>
<div class="selected-feeds-summary">
<h3>Selected Feeds</h3>
<div class="feeds-list">
@for (feedType of selectedFeeds(); track feedType) {
<span class="feed-chip" [ngClass]="'feed-chip--' + feedType">
{{ feedType | uppercase }}
<button type="button" class="chip-remove" (click)="toggleFeed(feedType)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<div class="step-actions">
<button
type="button"
class="btn btn--primary"
[disabled]="selectedFeeds().length === 0"
(click)="proceedToConfigure()"
>
Continue
</button>
</span>
</div>
}
</div>
</div>
</section>
}
<div class="step-actions">
<button type="button" class="btn btn--secondary" (click)="currentStep.set('select')">
Back
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!bundleName()"
(click)="startBuild()"
>
Create Bundle
</button>
</div>
</section>
}
<!-- Configure Step -->
@if (currentStep() === 'configure') {
<section class="step-content">
<h2>Configure Bundle</h2>
<p>Set bundle options and metadata.</p>
<!-- Building Step -->
@if (currentStep() === 'building') {
<section class="step-content">
<h2>Building Bundle</h2>
<p>Creating your air-gapped bundle...</p>
<div class="config-form">
<div class="form-group">
<label for="bundleName">Bundle Name *</label>
<input
type="text"
id="bundleName"
[ngModel]="bundleName()"
(ngModelChange)="bundleName.set($event)"
placeholder="e.g., Full Feed Bundle - December 2025"
class="form-input"
/>
</div>
<div class="build-progress">
<div class="progress-bar-container">
<div class="progress-bar" [style.width.%]="buildProgress()"></div>
</div>
<div class="progress-details">
<span class="progress-percent">{{ buildProgress() }}%</span>
<span class="progress-status">{{ buildStatus() }}</span>
</div>
</div>
</section>
}
<div class="form-group">
<label for="bundleDescription">Description</label>
<textarea
id="bundleDescription"
[ngModel]="bundleDescription()"
(ngModelChange)="bundleDescription.set($event)"
placeholder="Optional description for this bundle..."
rows="3"
class="form-input"
></textarea>
</div>
<!-- Ready Step -->
@if (currentStep() === 'ready' && createdBundle()) {
<section class="step-content step-content--ready">
<div class="ready-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Bundle Ready</h2>
<p>Your air-gapped bundle has been created and is ready for download.</p>
<div class="form-group">
<label for="expirationDays">Expiration (days)</label>
<input
type="number"
id="expirationDays"
[ngModel]="expirationDays()"
(ngModelChange)="expirationDays.set($event)"
min="7"
max="365"
class="form-input form-input--short"
/>
<span class="form-hint">Bundle will expire after this many days</span>
</div>
<div class="bundle-details">
<div class="detail-row">
<span class="detail-label">Bundle Name</span>
<span class="detail-value">{{ createdBundle()!.name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Size</span>
<span class="detail-value">{{ formatBytes(createdBundle()!.sizeBytes) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Included Feeds</span>
<div class="detail-value feeds-row">
@for (feed of createdBundle()!.includedFeeds; track feed) {
<span class="feed-badge-small" [ngClass]="'feed-badge--' + feed">{{ feed | uppercase }}</span>
<div class="form-group form-group--checkbox">
<label>
<input
type="checkbox"
[checked]="includeSignature()"
(change)="includeSignature.set(!includeSignature())"
/>
<span>Include cryptographic signature</span>
</label>
<span class="form-hint">Sign the bundle for integrity verification</span>
</div>
<div class="form-group form-group--checkbox">
<label>
<input
type="checkbox"
[checked]="useLatestSnapshots()"
(change)="useLatestSnapshots.set(!useLatestSnapshots())"
/>
<span>Use latest snapshots</span>
</label>
<span class="form-hint">Automatically include the most recent snapshot for each feed</span>
</div>
</div>
<div class="selected-feeds-summary">
<h3>Selected Feeds</h3>
<div class="feeds-list">
@for (feedType of selectedFeeds(); track feedType) {
<span class="feed-chip" [ngClass]="'feed-chip--' + feedType">
{{ feedType | uppercase }}
<button type="button" class="chip-remove" (click)="toggleFeed(feedType)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</span>
}
</div>
</div>
<div class="step-actions">
<button type="button" class="btn btn--secondary" (click)="currentStep.set('select')">
Back
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!bundleName()"
(click)="startBuild()"
>
Create Bundle
</button>
</div>
</section>
}
<!-- Building Step -->
@if (currentStep() === 'building') {
<section class="step-content">
<h2>Building Bundle</h2>
<p>Creating your air-gapped bundle...</p>
<div class="build-progress">
<div class="progress-bar-container">
<div class="progress-bar" [style.width.%]="buildProgress()"></div>
</div>
<div class="progress-details">
<span class="progress-percent">{{ buildProgress() }}%</span>
<span class="progress-status">{{ buildStatus() }}</span>
</div>
</div>
</section>
}
<!-- Ready Step -->
@if (currentStep() === 'ready' && createdBundle()) {
<section class="step-content step-content--ready">
<div class="ready-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<h2>Bundle Ready</h2>
<p>Your air-gapped bundle has been created and is ready for download.</p>
<div class="bundle-details">
<div class="detail-row">
<span class="detail-label">Bundle Name</span>
<span class="detail-value">{{ createdBundle()!.name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Size</span>
<span class="detail-value">{{ formatBytes(createdBundle()!.sizeBytes) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Included Feeds</span>
<div class="detail-value feeds-row">
@for (feed of createdBundle()!.includedFeeds; track feed) {
<span class="feed-badge-small" [ngClass]="'feed-badge--' + feed">{{ feed | uppercase }}</span>
}
</div>
</div>
@if (createdBundle()!.expiresAt) {
<div class="detail-row">
<span class="detail-label">Expires</span>
<span class="detail-value">{{ formatDate(createdBundle()!.expiresAt!) }}</span>
</div>
}
</div>
</div>
@if (createdBundle()!.expiresAt) {
<div class="detail-row">
<span class="detail-label">Expires</span>
<span class="detail-value">{{ formatDate(createdBundle()!.expiresAt!) }}</span>
<div class="checksum-section">
<h3>Checksums</h3>
<div class="checksum-row">
<span class="checksum-label">SHA-256</span>
<code class="checksum-value">{{ createdBundle()!.checksumSha256 || 'Generating...' }}</code>
<button type="button" class="copy-btn" (click)="copyChecksum('sha256')">Copy</button>
</div>
</div>
}
</div>
<div class="checksum-section">
<h3>Checksums</h3>
<div class="checksum-row">
<span class="checksum-label">SHA-256</span>
<code class="checksum-value">{{ createdBundle()!.checksumSha256 || 'Generating...' }}</code>
<button type="button" class="copy-btn" (click)="copyChecksum('sha256')">Copy</button>
</div>
</div>
<div class="download-section">
<a
[href]="createdBundle()!.downloadUrl"
class="btn btn--primary btn--large"
[class.btn--disabled]="!createdBundle()!.downloadUrl"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download Bundle
</a>
@if (createdBundle()!.signatureUrl) {
<a [href]="createdBundle()!.signatureUrl" class="btn btn--secondary">
Download Signature
</a>
}
</div>
<div class="download-section">
<a
[href]="createdBundle()!.downloadUrl"
class="btn btn--primary btn--large"
[class.btn--disabled]="!createdBundle()!.downloadUrl"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download Bundle
</a>
@if (createdBundle()!.signatureUrl) {
<a [href]="createdBundle()!.signatureUrl" class="btn btn--secondary">
Download Signature
</a>
}
</div>
<div class="step-actions">
<a routerLink="/ops/feeds" class="btn btn--secondary">
Back to Feed Mirror
</a>
<button type="button" class="btn btn--secondary" (click)="reset()">
Create Another Bundle
</button>
</div>
</section>
}
<div class="step-actions">
<button type="button" class="btn btn--secondary" (click)="completed.emit()">
Done
</button>
<button type="button" class="btn btn--secondary" (click)="reset()">
Create Another Bundle
</button>
</div>
</section>
}
</div>
</div>
</div>
</div>
`,
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<void>();
@Output() completed = new EventEmitter<void>();
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({

View File

@@ -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: `
<div class="airgap-import">
<header class="page-header">
<a routerLink="/ops/feeds" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
</svg>
Back to Feed Mirror
</a>
<h1>Import AirGap Bundle</h1>
<p class="subtitle">Import a vulnerability feed bundle from external media for offline use</p>
</header>
<div class="workflow-overlay" (click)="onOverlayClick($event)">
<div class="workflow-container">
<header class="workflow-header">
<h2>Import AirGap Bundle</h2>
<button type="button" class="close-btn" (click)="closed.emit()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<div class="airgap-import">
<!-- Progress Steps -->
<nav class="steps-nav">
@for (step of steps; track step.id) {
@@ -51,58 +54,108 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
<!-- Upload Step -->
@if (currentStep() === 'upload') {
<section class="step-content">
<h2>Select Bundle File</h2>
<p>Choose an AirGap bundle file (.tar.gz or .zip) from your file system or external media.</p>
<h2>Select Bundle Source</h2>
<p>Choose how to provide the airgap bundle for import.</p>
<div
class="drop-zone"
[class.drop-zone--dragover]="isDragOver()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="drop-zone-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div class="source-modes">
<button type="button" [class.active]="sourceMode() === 'path'" (click)="sourceMode.set('path')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
Server Path
</button>
<button type="button" [class.active]="sourceMode() === 'url'" (click)="sourceMode.set('url')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
URL
</button>
<button type="button" [class.active]="sourceMode() === 'file'" (click)="sourceMode.set('file')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<p class="drop-zone-text">
Drag and drop your bundle file here, or
<label class="file-input-label">
browse
<input
type="file"
accept=".tar.gz,.zip,.tgz"
(change)="onFileSelect($event)"
class="file-input"
/>
</label>
</p>
<p class="drop-zone-hint">Supported formats: .tar.gz, .tgz, .zip</p>
</div>
File Upload
</button>
</div>
@if (selectedFile()) {
<div class="selected-file">
<div class="file-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
@if (sourceMode() === 'path') {
<div class="source-input">
<label for="serverPath">Server-side file or directory path</label>
<input id="serverPath" type="text"
placeholder="/var/lib/concelier/import/bundle-2026-04.tar.gz"
[ngModel]="serverPath()" (ngModelChange)="serverPath.set($event)" />
<p class="hint">Path inside the Concelier container. Stage bundles to the import volume
(<code>/var/lib/concelier/import/</code>) — map host directories via
<code>STELLAOPS_AIRGAP_IMPORT_DIR</code> in compose. Supports .tar.gz, .tgz, .zip or directories.</p>
</div>
}
@if (sourceMode() === 'url') {
<div class="source-input">
<label for="sourceUrl">Bundle URL</label>
<input id="sourceUrl" type="url"
placeholder="https://mirror.internal/bundles/latest.tar.gz"
[ngModel]="sourceUrl()" (ngModelChange)="sourceUrl.set($event)" />
<p class="hint">Internal URL to download the bundle from. The server fetches it directly — no browser transfer.</p>
</div>
}
@if (sourceMode() === 'file') {
<div
class="drop-zone"
[class.drop-zone--dragover]="isDragOver()"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="drop-zone-content">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<div class="file-details">
<span class="file-name">{{ selectedFile()!.name }}</span>
<span class="file-size">{{ formatBytes(selectedFile()!.size) }}</span>
</div>
<p class="drop-zone-text">
Drag and drop your bundle file here, or
<label class="file-input-label">
browse
<input
type="file"
accept=".tar.gz,.zip,.tgz"
(change)="onFileSelect($event)"
class="file-input"
/>
</label>
</p>
<p class="drop-zone-hint">Supported formats: .tar.gz, .tgz, .zip — for small bundles only</p>
</div>
<button type="button" class="remove-file" (click)="removeFile()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
@if (selectedFile()) {
<div class="selected-file">
<div class="file-info">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<div class="file-details">
<span class="file-name">{{ selectedFile()!.name }}</span>
<span class="file-size">{{ formatBytes(selectedFile()!.size) }}</span>
</div>
</div>
<button type="button" class="remove-file" (click)="removeFile()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
}
}
@if (canProceed()) {
<div class="step-actions">
<button type="button" class="btn btn--primary" (click)="startValidation()">
Continue to Validation
@@ -304,9 +357,9 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
}
<div class="step-actions">
<a routerLink="/ops/feeds" class="btn btn--primary">
<button type="button" class="btn btn--primary" (click)="completed.emit()">
View Feed Mirrors
</a>
</button>
<button type="button" class="btn btn--secondary" (click)="reset()">
Import Another Bundle
</button>
@@ -315,43 +368,72 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
}
</div>
</div>
</div>
</div>
`,
styles: [`
.airgap-import {
padding: 1.5rem;
color: rgba(212, 201, 168, 0.3);
background: var(--color-text-heading);
min-height: calc(100vh - 120px);
.workflow-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
backdrop-filter: blur(2px);
}
.page-header {
margin-bottom: 2rem;
.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);
}
h1 {
margin: 1rem 0 0.25rem;
font-size: 1.5rem;
.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;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
}
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
.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);
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.15s;
&:hover {
color: rgba(212, 201, 168, 0.3);
}
.airgap-import {
padding: 1.5rem;
}
// Steps Navigation
@@ -368,18 +450,18 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
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);
}
}
@@ -388,7 +470,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
.step-number {
background: var(--color-status-success);
color: white;
color: var(--color-surface-inverse);
}
}
}
@@ -399,7 +481,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
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);
@@ -417,8 +499,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
}
.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;
@@ -435,8 +517,82 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
}
// Drop Zone
/* Source mode selector */
.source-modes {
display: flex;
gap: 0;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 1.25rem;
}
.source-modes button {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.6rem 1rem;
background: var(--color-surface-primary);
border: none;
border-right: 1px solid var(--color-border-primary);
color: var(--color-text-muted);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium, 500);
cursor: pointer;
transition: all 0.15s;
}
.source-modes button:last-child { border-right: none; }
.source-modes button:hover:not(.active) {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.source-modes button.active {
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
}
.source-input {
display: grid;
gap: 0.375rem;
margin-bottom: 1rem;
}
.source-input label {
font-size: 0.8125rem;
font-weight: var(--font-weight-medium, 500);
color: var(--color-text-muted);
}
.source-input input {
padding: 0.625rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: 'JetBrains Mono', monospace;
transition: border-color 150ms ease;
}
.source-input input:focus {
outline: none;
border-color: var(--color-brand-primary);
}
.source-input .hint {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
.drop-zone {
border: 2px dashed var(--color-text-primary);
border: 2px dashed var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 3rem 2rem;
text-align: center;
@@ -486,8 +642,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
justify-content: space-between;
margin-top: 1rem;
padding: 1rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
background: var(--color-status-info-bg);
border: 1px solid var(--color-status-info-border);
border-radius: var(--radius-md);
}
@@ -526,8 +682,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(212, 201, 168, 0.3);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
@@ -551,7 +707,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
}
.progress-ring-bg {
stroke: var(--color-text-primary);
stroke: var(--color-border-primary);
}
.progress-ring-fill {
@@ -577,13 +733,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
// Validation Result
.validation-result {
padding: 1.5rem;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.2);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-lg);
&--valid {
background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2);
background: var(--color-status-success-bg);
border-color: var(--color-status-success-border);
}
}
@@ -628,7 +784,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
.bundle-contents {
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid var(--color-border-primary);
h3 {
margin: 0 0 0.75rem;
@@ -653,12 +809,12 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
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-text); border-color: var(--color-severity-info-border); }
&--ghsa { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
&--oval { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
&--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-color: var(--color-severity-low-border); }
&--epss { background: var(--color-severity-high-bg); color: var(--color-status-warning-text); border-color: var(--color-severity-high-border); }
&--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); border-color: var(--color-severity-critical-border); }
}
.content-stats {
@@ -686,7 +842,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
.validation-warnings {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid var(--color-border-primary);
h3 {
margin: 0 0 0.5rem;
@@ -699,7 +855,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
background: var(--color-status-error-bg);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
font-size: 0.8125rem;
@@ -712,11 +868,11 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
.warning-item {
padding: 0.5rem;
background: rgba(234, 179, 8, 0.1);
background: var(--color-status-warning-bg);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
font-size: 0.8125rem;
color: var(--color-status-warning-border);
color: var(--color-status-warning-text);
}
// Import Progress
@@ -726,7 +882,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
.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;
@@ -783,7 +939,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
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 {
@@ -798,23 +954,23 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
transition: all 0.15s;
&--primary {
background: var(--color-status-info-text);
background: var(--color-btn-primary-bg);
border: none;
color: white;
color: var(--color-text-heading);
&:hover {
background: var(--color-status-info-text);
background: var(--color-brand-secondary);
}
}
&--secondary {
background: transparent;
border: 1px solid var(--color-text-primary);
border: 1px solid var(--color-border-primary);
color: var(--color-text-muted);
&:hover {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
}
@@ -822,11 +978,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AirgapImportComponent {
private readonly router = inject(Router);
@Output() closed = new EventEmitter<void>();
@Output() completed = new EventEmitter<void>();
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
readonly steps = [
{ id: 'upload' as ImportStep, number: 1, label: 'Select File' },
{ id: 'upload' as ImportStep, number: 1, label: 'Source' },
{ id: 'validate' as ImportStep, number: 2, label: 'Validate' },
{ id: 'review' as ImportStep, number: 3, label: 'Review' },
{ id: 'import' as ImportStep, number: 4, label: 'Import' },
@@ -834,6 +992,9 @@ export class AirgapImportComponent {
];
readonly currentStep = signal<ImportStep>('upload');
readonly sourceMode = signal<ImportSourceMode>('path');
readonly serverPath = signal('');
readonly sourceUrl = signal('');
readonly selectedFile = signal<File | null>(null);
readonly isDragOver = signal(false);
readonly validationProgress = signal(0);
@@ -841,6 +1002,14 @@ export class AirgapImportComponent {
readonly validation = signal<AirGapImportValidation | null>(null);
readonly importProgress = signal<AirGapImportProgress | null>(null);
readonly canProceed = computed(() => {
switch (this.sourceMode()) {
case 'path': return this.serverPath().trim().length > 0;
case 'url': return this.sourceUrl().trim().length > 0;
case 'file': return this.selectedFile() !== null;
}
});
isStepCompleted(stepId: ImportStep): boolean {
const stepOrder = this.steps.map((s) => s.id);
const currentIndex = stepOrder.indexOf(this.currentStep());
@@ -848,6 +1017,12 @@ export class AirgapImportComponent {
return stepIndex < currentIndex;
}
onOverlayClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('workflow-overlay')) {
this.closed.emit();
}
}
onDragOver(event: DragEvent): void {
event.preventDefault();
this.isDragOver.set(true);
@@ -879,13 +1054,18 @@ export class AirgapImportComponent {
}
startValidation(): void {
const file = this.selectedFile();
if (!file) return;
if (!this.canProceed()) return;
const source: AirGapImportRequest = {
mode: this.sourceMode(),
...(this.sourceMode() === 'file' && this.selectedFile() ? { file: this.selectedFile()! } : {}),
...(this.sourceMode() === 'path' ? { serverPath: this.serverPath().trim() } : {}),
...(this.sourceMode() === 'url' ? { sourceUrl: this.sourceUrl().trim() } : {}),
};
this.currentStep.set('validate');
this.validationProgress.set(0);
// Simulate validation progress
const progressInterval = setInterval(() => {
this.validationProgress.update((p) => Math.min(p + 10, 90));
if (this.validationProgress() >= 30 && this.validationProgress() < 60) {
@@ -895,7 +1075,7 @@ export class AirgapImportComponent {
}
}, 200);
this.feedMirrorApi.validateImport(file).subscribe({
this.feedMirrorApi.validateImport(source).subscribe({
next: (result) => {
clearInterval(progressInterval);
this.validationProgress.set(100);
@@ -950,6 +1130,9 @@ export class AirgapImportComponent {
reset(): void {
this.currentStep.set('upload');
this.sourceMode.set('path');
this.serverPath.set('');
this.sourceUrl.set('');
this.selectedFile.set(null);
this.validation.set(null);
this.importProgress.set(null);

View File

@@ -327,12 +327,12 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
&--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);
}
}
@@ -581,12 +581,13 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
backdrop-filter: blur(2px);
}
.modal-content {
@@ -595,6 +596,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
border-radius: var(--radius-lg);
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.modal-header {
@@ -658,10 +660,13 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.875rem;
font-family: inherit;
transition: border-color 150ms ease;
&:focus {
outline: none;
border-color: var(--color-status-info);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px rgba(var(--color-brand-primary-rgb, 99, 102, 241), 0.15);
}
&:disabled {

View File

@@ -17,10 +17,11 @@ import {
MirrorSyncRequest,
} from '../../core/api/feed-mirror.models';
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
@Component({
selector: 'app-mirror-list',
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, StellaFilterChipComponent],
template: `
<div class="mirror-list">
<!-- Filters -->
@@ -34,35 +35,18 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
class="search-input"
/>
</div>
<div class="filter-group">
<select
[ngModel]="statusFilter()"
(ngModelChange)="updateStatusFilter($event)"
class="filter-select"
>
<option value="">All Statuses</option>
<option value="synced">Synced</option>
<option value="syncing">Syncing</option>
<option value="stale">Stale</option>
<option value="error">Error</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div class="filter-group">
<select
[ngModel]="feedTypeFilter()"
(ngModelChange)="updateFeedTypeFilter($event)"
class="filter-select"
>
<option value="">All Feed Types</option>
<option value="nvd">NVD</option>
<option value="ghsa">GHSA</option>
<option value="oval">OVAL</option>
<option value="osv">OSV</option>
<option value="epss">EPSS</option>
<option value="kev">KEV</option>
</select>
</div>
<stella-filter-chip
label="Status"
[value]="statusFilter() || 'All'"
[options]="statusOptions"
(valueChange)="onStatusChipChange($event)"
/>
<stella-filter-chip
label="Feed"
[value]="feedTypeFilter() || 'All'"
[options]="feedTypeOptions"
(valueChange)="onFeedTypeChipChange($event)"
/>
<button type="button" class="refresh-btn" (click)="refresh.emit()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"/>
@@ -195,6 +179,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.875rem;
transition: border-color 150ms ease;
&::placeholder {
color: var(--color-text-secondary);
@@ -202,7 +187,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
&:focus {
outline: none;
border-color: var(--color-status-info);
border-color: var(--color-brand-primary);
}
}
@@ -213,10 +198,6 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.875rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5A2E' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
cursor: pointer;
&:focus {
@@ -556,11 +537,40 @@ export class MirrorListComponent {
readonly feedTypeFilter = signal<FeedType | ''>('');
readonly syncing = signal<Record<string, boolean>>({});
readonly statusOptions: FilterChipOption[] = [
{ id: '', label: 'All' },
{ id: 'synced', label: 'Synced' },
{ id: 'syncing', label: 'Syncing' },
{ id: 'stale', label: 'Stale' },
{ id: 'error', label: 'Error' },
{ id: 'disabled', label: 'Disabled' },
];
readonly feedTypeOptions: FilterChipOption[] = [
{ id: '', label: 'All' },
{ id: 'nvd', label: 'NVD' },
{ id: 'ghsa', label: 'GHSA' },
{ id: 'oval', label: 'OVAL' },
{ id: 'osv', label: 'OSV' },
{ id: 'epss', label: 'EPSS' },
{ id: 'kev', label: 'KEV' },
];
updateSearch(term: string): void {
this.searchTerm.set(term);
this.emitFilter();
}
onStatusChipChange(id: string): void {
this.statusFilter.set(id as MirrorSyncStatus | '');
this.emitFilter();
}
onFeedTypeChipChange(id: string): void {
this.feedTypeFilter.set(id as FeedType | '');
this.emitFilter();
}
updateStatusFilter(status: MirrorSyncStatus | ''): void {
this.statusFilter.set(status);
this.emitFilter();

View File

@@ -23,7 +23,6 @@ const PAGE_TABS: readonly StellaPageTab[] = [
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
urlParam="tab"
ariaLabel="Feeds & Offline sections"
(tabChange)="onTabChange($event)"
>

View File

@@ -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<PlatformFeedsAirgapPageComponent>;
let component: PlatformFeedsAirgapPageComponent;
let queryParamMap$: BehaviorSubject<ParamMap>;
let currentQueryParamMap: ParamMap;
let router: Router;
let mockApi: jasmine.SpyObj<any>;
const mockMirrors: Partial<FeedMirror>[] = [
{
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<OfflineSyncStatus> = {
state: 'partial',
lastOnlineAt: new Date().toISOString(),
mirrorStats: { total: 3, synced: 1, stale: 1, error: 1 },
totalStorageBytes: 710 * 1024 * 1024,
recommendations: [],
};
const mockBundles: Partial<AirGapBundle>[] = [
{
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<void> {
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.');