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:
@@ -50,6 +50,24 @@ Rollback resistance is enforced via:
|
|||||||
- A monotonicity checker (`IVersionMonotonicityChecker`) that compares incoming bundle versions to the active version.
|
- 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.
|
- 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
|
## Storage model
|
||||||
|
|
||||||
The importer writes deterministic metadata that other components can query:
|
The importer writes deterministic metadata that other components can query:
|
||||||
|
|||||||
@@ -13,9 +13,38 @@ Scope: deploy sealed-mode Concelier evidence bundles using deterministic NDJSON
|
|||||||
- Concelier WebService running with `concelier:features:airgap` enabled.
|
- Concelier WebService running with `concelier:features:airgap` enabled.
|
||||||
- No external egress; only local file system allowed for bundle path.
|
- No external egress; only local file system allowed for bundle path.
|
||||||
- PostgreSQL indexes applied (`advisory_observations`, `advisory_linksets` tables).
|
- 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
|
## 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:
|
2) Verify hashes:
|
||||||
```bash
|
```bash
|
||||||
sha256sum concelier-airgap.ndjson | diff - <(jq -r .bundleSha256 bundle.manifest.json)
|
sha256sum concelier-airgap.ndjson | diff - <(jq -r .bundleSha256 bundle.manifest.json)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
AirGapBundleRequest,
|
AirGapBundleRequest,
|
||||||
AirGapImportValidation,
|
AirGapImportValidation,
|
||||||
AirGapImportProgress,
|
AirGapImportProgress,
|
||||||
|
AirGapImportRequest,
|
||||||
FeedVersionLock,
|
FeedVersionLock,
|
||||||
FeedVersionLockRequest,
|
FeedVersionLockRequest,
|
||||||
OfflineSyncStatus,
|
OfflineSyncStatus,
|
||||||
@@ -54,7 +55,7 @@ export interface FeedMirrorApi {
|
|||||||
downloadBundle(bundleId: string): Observable<SnapshotDownloadProgress>;
|
downloadBundle(bundleId: string): Observable<SnapshotDownloadProgress>;
|
||||||
|
|
||||||
// AirGap import operations
|
// AirGap import operations
|
||||||
validateImport(file: File): Observable<AirGapImportValidation>;
|
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation>;
|
||||||
startImport(bundleId: string): Observable<AirGapImportProgress>;
|
startImport(bundleId: string): Observable<AirGapImportProgress>;
|
||||||
getImportProgress(importId: string): Observable<AirGapImportProgress>;
|
getImportProgress(importId: string): Observable<AirGapImportProgress>;
|
||||||
|
|
||||||
@@ -467,12 +468,18 @@ export class FeedMirrorHttpClient implements FeedMirrorApi {
|
|||||||
|
|
||||||
// ---- AirGap import operations ----
|
// ---- AirGap import operations ----
|
||||||
|
|
||||||
validateImport(file: File): Observable<AirGapImportValidation> {
|
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation> {
|
||||||
const formData = new FormData();
|
if (source.mode === 'file' && source.file) {
|
||||||
formData.append('file', file, file.name);
|
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>(
|
return this.http.post<AirGapImportValidation>(
|
||||||
`${this.baseUrl}/imports/validate`,
|
`${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
|
// AirGap import operations
|
||||||
validateImport(_file: File): Observable<AirGapImportValidation> {
|
validateImport(_source: AirGapImportRequest): Observable<AirGapImportValidation> {
|
||||||
return of({
|
return of({
|
||||||
bundleId: 'import-validation-temp',
|
bundleId: 'import-validation-temp',
|
||||||
status: 'valid' as const,
|
status: 'valid' as const,
|
||||||
|
|||||||
@@ -184,6 +184,27 @@ export interface AirGapImportValidation {
|
|||||||
readonly canImport: boolean;
|
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.
|
* Import validation error.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
Output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
|
||||||
import {
|
import {
|
||||||
FeedMirror,
|
FeedMirror,
|
||||||
FeedSnapshot,
|
FeedSnapshot,
|
||||||
@@ -23,337 +24,366 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-airgap-export',
|
selector: 'app-airgap-export',
|
||||||
imports: [CommonModule, FormsModule, RouterModule],
|
imports: [CommonModule, FormsModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="airgap-export">
|
<div class="workflow-overlay" (click)="onBackdropClick($event)">
|
||||||
<header class="page-header">
|
<div class="workflow-container">
|
||||||
<a routerLink="/ops/feeds" class="back-link">
|
<header class="workflow-header">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<h2 class="workflow-title">Export AirGap Bundle</h2>
|
||||||
<path d="M19 12H5"/>
|
<button type="button" class="close-btn" (click)="closed.emit()">
|
||||||
<path d="M12 19l-7-7 7-7"/>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</svg>
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
Back to Feed Mirror
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</a>
|
</svg>
|
||||||
<h1>Export AirGap Bundle</h1>
|
</button>
|
||||||
<p class="subtitle">Create a vulnerability feed bundle for air-gapped deployment</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Step Progress -->
|
<div class="workflow-body">
|
||||||
<nav class="steps-nav">
|
<!-- Step Progress -->
|
||||||
@for (step of steps; track step.id) {
|
<nav class="steps-nav">
|
||||||
<div
|
@for (step of steps; track step.id) {
|
||||||
class="step"
|
<div
|
||||||
[class.step--active]="currentStep() === step.id"
|
class="step"
|
||||||
[class.step--completed]="isStepCompleted(step.id)"
|
[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>
|
<span class="step-number">{{ step.number }}</span>
|
||||||
</div>
|
<span class="step-label">{{ step.label }}</span>
|
||||||
}
|
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</nav>
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Configure Step -->
|
<div class="content-area">
|
||||||
@if (currentStep() === 'configure') {
|
<!-- Select Feeds Step -->
|
||||||
<section class="step-content">
|
@if (currentStep() === 'select') {
|
||||||
<h2>Configure Bundle</h2>
|
<section class="step-content">
|
||||||
<p>Set bundle options and metadata.</p>
|
<h2>Select Feeds to Include</h2>
|
||||||
|
<p>Choose which vulnerability feeds to include in the bundle.</p>
|
||||||
|
|
||||||
<div class="config-form">
|
@if (loading()) {
|
||||||
<div class="form-group">
|
<div class="loading-container">
|
||||||
<label for="bundleName">Bundle Name *</label>
|
<div class="loading-spinner"></div>
|
||||||
<input
|
<p>Loading available feeds...</p>
|
||||||
type="text"
|
</div>
|
||||||
id="bundleName"
|
} @else {
|
||||||
[ngModel]="bundleName()"
|
<div class="feed-selection">
|
||||||
(ngModelChange)="bundleName.set($event)"
|
@for (mirror of mirrors(); track mirror.mirrorId) {
|
||||||
placeholder="e.g., Full Feed Bundle - December 2025"
|
<label
|
||||||
class="form-input"
|
class="feed-option"
|
||||||
/>
|
[class.feed-option--selected]="isSelected(mirror.feedType)"
|
||||||
</div>
|
[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">
|
<div class="selection-summary">
|
||||||
<label for="bundleDescription">Description</label>
|
<span>{{ selectedFeeds().length }} feeds selected</span>
|
||||||
<textarea
|
<span class="estimated-size">Estimated size: {{ estimatedSize() }}</span>
|
||||||
id="bundleDescription"
|
</div>
|
||||||
[ngModel]="bundleDescription()"
|
|
||||||
(ngModelChange)="bundleDescription.set($event)"
|
|
||||||
placeholder="Optional description for this bundle..."
|
|
||||||
rows="3"
|
|
||||||
class="form-input"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="step-actions">
|
||||||
<label for="expirationDays">Expiration (days)</label>
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="number"
|
class="btn btn--primary"
|
||||||
id="expirationDays"
|
[disabled]="selectedFeeds().length === 0"
|
||||||
[ngModel]="expirationDays()"
|
(click)="proceedToConfigure()"
|
||||||
(ngModelChange)="expirationDays.set($event)"
|
>
|
||||||
min="7"
|
Continue
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
<div class="step-actions">
|
<!-- Configure Step -->
|
||||||
<button type="button" class="btn btn--secondary" (click)="currentStep.set('select')">
|
@if (currentStep() === 'configure') {
|
||||||
Back
|
<section class="step-content">
|
||||||
</button>
|
<h2>Configure Bundle</h2>
|
||||||
<button
|
<p>Set bundle options and metadata.</p>
|
||||||
type="button"
|
|
||||||
class="btn btn--primary"
|
|
||||||
[disabled]="!bundleName()"
|
|
||||||
(click)="startBuild()"
|
|
||||||
>
|
|
||||||
Create Bundle
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Building Step -->
|
<div class="config-form">
|
||||||
@if (currentStep() === 'building') {
|
<div class="form-group">
|
||||||
<section class="step-content">
|
<label for="bundleName">Bundle Name *</label>
|
||||||
<h2>Building Bundle</h2>
|
<input
|
||||||
<p>Creating your air-gapped bundle...</p>
|
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="form-group">
|
||||||
<div class="progress-bar-container">
|
<label for="bundleDescription">Description</label>
|
||||||
<div class="progress-bar" [style.width.%]="buildProgress()"></div>
|
<textarea
|
||||||
</div>
|
id="bundleDescription"
|
||||||
<div class="progress-details">
|
[ngModel]="bundleDescription()"
|
||||||
<span class="progress-percent">{{ buildProgress() }}%</span>
|
(ngModelChange)="bundleDescription.set($event)"
|
||||||
<span class="progress-status">{{ buildStatus() }}</span>
|
placeholder="Optional description for this bundle..."
|
||||||
</div>
|
rows="3"
|
||||||
</div>
|
class="form-input"
|
||||||
</section>
|
></textarea>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<!-- Ready Step -->
|
<div class="form-group">
|
||||||
@if (currentStep() === 'ready' && createdBundle()) {
|
<label for="expirationDays">Expiration (days)</label>
|
||||||
<section class="step-content step-content--ready">
|
<input
|
||||||
<div class="ready-icon">
|
type="number"
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
id="expirationDays"
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
[ngModel]="expirationDays()"
|
||||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
(ngModelChange)="expirationDays.set($event)"
|
||||||
</svg>
|
min="7"
|
||||||
</div>
|
max="365"
|
||||||
<h2>Bundle Ready</h2>
|
class="form-input form-input--short"
|
||||||
<p>Your air-gapped bundle has been created and is ready for download.</p>
|
/>
|
||||||
|
<span class="form-hint">Bundle will expire after this many days</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bundle-details">
|
<div class="form-group form-group--checkbox">
|
||||||
<div class="detail-row">
|
<label>
|
||||||
<span class="detail-label">Bundle Name</span>
|
<input
|
||||||
<span class="detail-value">{{ createdBundle()!.name }}</span>
|
type="checkbox"
|
||||||
</div>
|
[checked]="includeSignature()"
|
||||||
<div class="detail-row">
|
(change)="includeSignature.set(!includeSignature())"
|
||||||
<span class="detail-label">Size</span>
|
/>
|
||||||
<span class="detail-value">{{ formatBytes(createdBundle()!.sizeBytes) }}</span>
|
<span>Include cryptographic signature</span>
|
||||||
</div>
|
</label>
|
||||||
<div class="detail-row">
|
<span class="form-hint">Sign the bundle for integrity verification</span>
|
||||||
<span class="detail-label">Included Feeds</span>
|
</div>
|
||||||
<div class="detail-value feeds-row">
|
|
||||||
@for (feed of createdBundle()!.includedFeeds; track feed) {
|
<div class="form-group form-group--checkbox">
|
||||||
<span class="feed-badge-small" [ngClass]="'feed-badge--' + feed">{{ feed | uppercase }}</span>
|
<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>
|
||||||
</div>
|
|
||||||
@if (createdBundle()!.expiresAt) {
|
<div class="checksum-section">
|
||||||
<div class="detail-row">
|
<h3>Checksums</h3>
|
||||||
<span class="detail-label">Expires</span>
|
<div class="checksum-row">
|
||||||
<span class="detail-value">{{ formatDate(createdBundle()!.expiresAt!) }}</span>
|
<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>
|
|
||||||
|
|
||||||
<div class="checksum-section">
|
<div class="download-section">
|
||||||
<h3>Checksums</h3>
|
<a
|
||||||
<div class="checksum-row">
|
[href]="createdBundle()!.downloadUrl"
|
||||||
<span class="checksum-label">SHA-256</span>
|
class="btn btn--primary btn--large"
|
||||||
<code class="checksum-value">{{ createdBundle()!.checksumSha256 || 'Generating...' }}</code>
|
[class.btn--disabled]="!createdBundle()!.downloadUrl"
|
||||||
<button type="button" class="copy-btn" (click)="copyChecksum('sha256')">Copy</button>
|
>
|
||||||
</div>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</div>
|
<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">
|
<div class="step-actions">
|
||||||
<a
|
<button type="button" class="btn btn--secondary" (click)="completed.emit()">
|
||||||
[href]="createdBundle()!.downloadUrl"
|
Done
|
||||||
class="btn btn--primary btn--large"
|
</button>
|
||||||
[class.btn--disabled]="!createdBundle()!.downloadUrl"
|
<button type="button" class="btn btn--secondary" (click)="reset()">
|
||||||
>
|
Create Another Bundle
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
</button>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
</div>
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
</section>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
}
|
||||||
</svg>
|
</div>
|
||||||
Download Bundle
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.airgap-export {
|
.workflow-overlay {
|
||||||
padding: 1.5rem;
|
position: fixed;
|
||||||
color: rgba(212, 201, 168, 0.3);
|
inset: 0;
|
||||||
background: var(--color-text-heading);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
min-height: calc(100vh - 120px);
|
display: flex;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
align-items: center;
|
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);
|
color: var(--color-text-muted);
|
||||||
text-decoration: none;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
transition: all 0.15s;
|
||||||
transition: color 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
&: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 {
|
.steps-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -366,18 +396,18 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-surface-inverse);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
border-color: var(--color-status-info);
|
border-color: var(--color-status-info);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: var(--color-status-info-bg);
|
||||||
|
|
||||||
.step-number {
|
.step-number {
|
||||||
background: var(--color-status-info);
|
background: var(--color-status-info);
|
||||||
color: white;
|
color: var(--color-surface-inverse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +416,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.step-number {
|
.step-number {
|
||||||
background: var(--color-status-success);
|
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;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: var(--color-text-primary);
|
background: var(--color-border-primary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -414,8 +444,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-content {
|
.step-content {
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-surface-inverse);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
@@ -442,7 +472,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 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-top-color: var(--color-status-info);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
@@ -465,8 +495,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-surface-inverse);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -483,7 +513,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
&--selected {
|
&--selected {
|
||||||
border-color: var(--color-status-info);
|
border-color: var(--color-status-info);
|
||||||
background: rgba(59, 130, 246, 0.05);
|
background: var(--color-status-info-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--disabled {
|
&--disabled {
|
||||||
@@ -506,12 +536,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
||||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); }
|
||||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
&--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
&--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
&--osv { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
&--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); }
|
||||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
&--kev { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-name {
|
.feed-name {
|
||||||
@@ -536,16 +566,16 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status--synced { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
.status--synced { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||||
.status--syncing { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
.status--syncing { background: var(--color-status-info-bg); color: var(--color-status-info); }
|
||||||
.status--stale { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); }
|
.status--stale { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||||
.status--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
.status--error { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||||
|
|
||||||
.selection-summary {
|
.selection-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
@@ -590,10 +620,10 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-text-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: rgba(212, 201, 168, 0.3);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -618,7 +648,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.selected-feeds-summary {
|
.selected-feeds-summary {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
@@ -647,12 +677,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); }
|
||||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
&--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
&--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
&--osv { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
&--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); }
|
||||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
&--kev { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-remove {
|
.chip-remove {
|
||||||
@@ -675,7 +705,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--color-text-primary);
|
background: var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -713,7 +743,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.bundle-details {
|
.bundle-details {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
@@ -724,7 +754,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 1px solid var(--color-surface-inverse);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -754,7 +784,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.checksum-section {
|
.checksum-section {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -789,8 +819,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
|
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
padding: 0.25rem 0.625rem;
|
padding: 0.25rem 0.625rem;
|
||||||
background: var(--color-surface-inverse);
|
background: var(--color-surface-secondary);
|
||||||
border: 1px solid var(--color-text-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -798,8 +828,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-text-primary);
|
background: var(--color-surface-secondary);
|
||||||
color: rgba(212, 201, 168, 0.3);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,7 +846,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid var(--color-surface-inverse);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -838,23 +868,23 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--color-text-heading);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-brand-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--secondary {
|
&--secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-text-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-surface-inverse);
|
background: var(--color-surface-secondary);
|
||||||
color: rgba(212, 201, 168, 0.3);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,9 +899,11 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
|||||||
export class AirgapExportComponent implements OnInit {
|
export class AirgapExportComponent implements OnInit {
|
||||||
private readonly dateFmt = inject(DateFormatService);
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
||||||
|
|
||||||
|
@Output() closed = new EventEmitter<void>();
|
||||||
|
@Output() completed = new EventEmitter<void>();
|
||||||
|
|
||||||
readonly steps = [
|
readonly steps = [
|
||||||
{ id: 'select' as ExportStep, number: 1, label: 'Select Feeds' },
|
{ id: 'select' as ExportStep, number: 1, label: 'Select Feeds' },
|
||||||
{ id: 'configure' as ExportStep, number: 2, label: 'Configure' },
|
{ id: 'configure' as ExportStep, number: 2, label: 'Configure' },
|
||||||
@@ -913,6 +945,12 @@ export class AirgapExportComponent implements OnInit {
|
|||||||
this.loadMirrors();
|
this.loadMirrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBackdropClick(event: MouseEvent): void {
|
||||||
|
if ((event.target as HTMLElement).classList.contains('workflow-overlay')) {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private loadMirrors(): void {
|
private loadMirrors(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.feedMirrorApi.listMirrors({ enabled: true }).subscribe({
|
this.feedMirrorApi.listMirrors({ enabled: true }).subscribe({
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
|
Output,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
AirGapImportValidation,
|
AirGapImportValidation,
|
||||||
AirGapImportProgress,
|
AirGapImportProgress,
|
||||||
FeedType,
|
AirGapImportRequest,
|
||||||
|
ImportSourceMode,
|
||||||
} from '../../core/api/feed-mirror.models';
|
} from '../../core/api/feed-mirror.models';
|
||||||
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||||
|
|
||||||
@@ -18,21 +21,21 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-airgap-import',
|
selector: 'app-airgap-import',
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, FormsModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="airgap-import">
|
<div class="workflow-overlay" (click)="onOverlayClick($event)">
|
||||||
<header class="page-header">
|
<div class="workflow-container">
|
||||||
<a routerLink="/ops/feeds" class="back-link">
|
<header class="workflow-header">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<h2>Import AirGap Bundle</h2>
|
||||||
<path d="M19 12H5"/>
|
<button type="button" class="close-btn" (click)="closed.emit()">
|
||||||
<path d="M12 19l-7-7 7-7"/>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</svg>
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
Back to Feed Mirror
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</a>
|
</svg>
|
||||||
<h1>Import AirGap Bundle</h1>
|
</button>
|
||||||
<p class="subtitle">Import a vulnerability feed bundle from external media for offline use</p>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
|
<div class="airgap-import">
|
||||||
<!-- Progress Steps -->
|
<!-- Progress Steps -->
|
||||||
<nav class="steps-nav">
|
<nav class="steps-nav">
|
||||||
@for (step of steps; track step.id) {
|
@for (step of steps; track step.id) {
|
||||||
@@ -51,58 +54,108 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
<!-- Upload Step -->
|
<!-- Upload Step -->
|
||||||
@if (currentStep() === 'upload') {
|
@if (currentStep() === 'upload') {
|
||||||
<section class="step-content">
|
<section class="step-content">
|
||||||
<h2>Select Bundle File</h2>
|
<h2>Select Bundle Source</h2>
|
||||||
<p>Choose an AirGap bundle file (.tar.gz or .zip) from your file system or external media.</p>
|
<p>Choose how to provide the airgap bundle for import.</p>
|
||||||
|
|
||||||
<div
|
<div class="source-modes">
|
||||||
class="drop-zone"
|
<button type="button" [class.active]="sourceMode() === 'path'" (click)="sourceMode.set('path')">
|
||||||
[class.drop-zone--dragover]="isDragOver()"
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
(dragover)="onDragOver($event)"
|
<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"/>
|
||||||
(dragleave)="onDragLeave($event)"
|
</svg>
|
||||||
(drop)="onDrop($event)"
|
Server Path
|
||||||
>
|
</button>
|
||||||
<div class="drop-zone-content">
|
<button type="button" [class.active]="sourceMode() === 'url'" (click)="sourceMode.set('url')">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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"/>
|
<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"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="drop-zone-text">
|
File Upload
|
||||||
Drag and drop your bundle file here, or
|
</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedFile()) {
|
@if (sourceMode() === 'path') {
|
||||||
<div class="selected-file">
|
<div class="source-input">
|
||||||
<div class="file-info">
|
<label for="serverPath">Server-side file or directory path</label>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<input id="serverPath" type="text"
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
placeholder="/var/lib/concelier/import/bundle-2026-04.tar.gz"
|
||||||
<polyline points="14 2 14 8 20 8"/>
|
[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>
|
</svg>
|
||||||
<div class="file-details">
|
<p class="drop-zone-text">
|
||||||
<span class="file-name">{{ selectedFile()!.name }}</span>
|
Drag and drop your bundle file here, or
|
||||||
<span class="file-size">{{ formatBytes(selectedFile()!.size) }}</span>
|
<label class="file-input-label">
|
||||||
</div>
|
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>
|
</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>
|
</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">
|
<div class="step-actions">
|
||||||
<button type="button" class="btn btn--primary" (click)="startValidation()">
|
<button type="button" class="btn btn--primary" (click)="startValidation()">
|
||||||
Continue to Validation
|
Continue to Validation
|
||||||
@@ -304,9 +357,9 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="step-actions">
|
<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
|
View Feed Mirrors
|
||||||
</a>
|
</button>
|
||||||
<button type="button" class="btn btn--secondary" (click)="reset()">
|
<button type="button" class="btn btn--secondary" (click)="reset()">
|
||||||
Import Another Bundle
|
Import Another Bundle
|
||||||
</button>
|
</button>
|
||||||
@@ -315,43 +368,72 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.airgap-import {
|
.workflow-overlay {
|
||||||
padding: 1.5rem;
|
position: fixed;
|
||||||
color: rgba(212, 201, 168, 0.3);
|
inset: 0;
|
||||||
background: var(--color-text-heading);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
min-height: calc(100vh - 120px);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.workflow-container {
|
||||||
margin-bottom: 2rem;
|
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 {
|
.workflow-header {
|
||||||
margin: 1rem 0 0.25rem;
|
display: flex;
|
||||||
font-size: 1.5rem;
|
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);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.subtitle {
|
.close-btn {
|
||||||
margin: 0;
|
display: flex;
|
||||||
color: var(--color-text-muted);
|
align-items: center;
|
||||||
font-size: 0.875rem;
|
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 {
|
.airgap-import {
|
||||||
display: inline-flex;
|
padding: 1.5rem;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steps Navigation
|
// Steps Navigation
|
||||||
@@ -368,18 +450,18 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-surface-inverse);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
border-color: var(--color-status-info);
|
border-color: var(--color-status-info);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: var(--color-status-info-bg);
|
||||||
|
|
||||||
.step-number {
|
.step-number {
|
||||||
background: var(--color-status-info);
|
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 {
|
.step-number {
|
||||||
background: var(--color-status-success);
|
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;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background: var(--color-text-primary);
|
background: var(--color-border-primary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -417,8 +499,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-content {
|
.step-content {
|
||||||
background: var(--color-text-heading);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-surface-inverse);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
@@ -435,8 +517,82 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Drop Zone
|
// 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 {
|
.drop-zone {
|
||||||
border: 2px dashed var(--color-text-primary);
|
border: 2px dashed var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 3rem 2rem;
|
padding: 3rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -486,8 +642,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: var(--color-status-info-bg);
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
border: 1px solid var(--color-status-info-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,8 +682,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--color-surface-secondary);
|
||||||
color: rgba(212, 201, 168, 0.3);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +707,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-ring-bg {
|
.progress-ring-bg {
|
||||||
stroke: var(--color-text-primary);
|
stroke: var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-ring-fill {
|
.progress-ring-fill {
|
||||||
@@ -577,13 +733,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
// Validation Result
|
// Validation Result
|
||||||
.validation-result {
|
.validation-result {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: rgba(239, 68, 68, 0.05);
|
background: var(--color-status-error-bg);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid var(--color-status-error-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|
||||||
&--valid {
|
&--valid {
|
||||||
background: rgba(34, 197, 94, 0.05);
|
background: var(--color-status-success-bg);
|
||||||
border-color: rgba(34, 197, 94, 0.2);
|
border-color: var(--color-status-success-border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +784,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
|
|
||||||
.bundle-contents {
|
.bundle-contents {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
@@ -653,12 +809,12 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info-text); border-color: var(--color-severity-info-border); }
|
||||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
&--ghsa { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
|
||||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
&--oval { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
|
||||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
&--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-color: var(--color-severity-low-border); }
|
||||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
&--epss { background: var(--color-severity-high-bg); color: var(--color-status-warning-text); border-color: var(--color-severity-high-border); }
|
||||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
&--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); border-color: var(--color-severity-critical-border); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-stats {
|
.content-stats {
|
||||||
@@ -686,7 +842,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
.validation-warnings {
|
.validation-warnings {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
@@ -699,7 +855,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: var(--color-status-error-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -712,11 +868,11 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
|
|
||||||
.warning-item {
|
.warning-item {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: rgba(234, 179, 8, 0.1);
|
background: var(--color-status-warning-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--color-status-warning-border);
|
color: var(--color-status-warning-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import Progress
|
// Import Progress
|
||||||
@@ -726,7 +882,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
|
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--color-text-primary);
|
background: var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -783,7 +939,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid var(--color-surface-inverse);
|
border-top: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -798,23 +954,23 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--color-text-heading);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-brand-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--secondary {
|
&--secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-text-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-surface-inverse);
|
background: var(--color-surface-secondary);
|
||||||
color: rgba(212, 201, 168, 0.3);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,11 +978,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AirgapImportComponent {
|
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);
|
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
||||||
|
|
||||||
readonly steps = [
|
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: 'validate' as ImportStep, number: 2, label: 'Validate' },
|
||||||
{ id: 'review' as ImportStep, number: 3, label: 'Review' },
|
{ id: 'review' as ImportStep, number: 3, label: 'Review' },
|
||||||
{ id: 'import' as ImportStep, number: 4, label: 'Import' },
|
{ id: 'import' as ImportStep, number: 4, label: 'Import' },
|
||||||
@@ -834,6 +992,9 @@ export class AirgapImportComponent {
|
|||||||
];
|
];
|
||||||
|
|
||||||
readonly currentStep = signal<ImportStep>('upload');
|
readonly currentStep = signal<ImportStep>('upload');
|
||||||
|
readonly sourceMode = signal<ImportSourceMode>('path');
|
||||||
|
readonly serverPath = signal('');
|
||||||
|
readonly sourceUrl = signal('');
|
||||||
readonly selectedFile = signal<File | null>(null);
|
readonly selectedFile = signal<File | null>(null);
|
||||||
readonly isDragOver = signal(false);
|
readonly isDragOver = signal(false);
|
||||||
readonly validationProgress = signal(0);
|
readonly validationProgress = signal(0);
|
||||||
@@ -841,6 +1002,14 @@ export class AirgapImportComponent {
|
|||||||
readonly validation = signal<AirGapImportValidation | null>(null);
|
readonly validation = signal<AirGapImportValidation | null>(null);
|
||||||
readonly importProgress = signal<AirGapImportProgress | 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 {
|
isStepCompleted(stepId: ImportStep): boolean {
|
||||||
const stepOrder = this.steps.map((s) => s.id);
|
const stepOrder = this.steps.map((s) => s.id);
|
||||||
const currentIndex = stepOrder.indexOf(this.currentStep());
|
const currentIndex = stepOrder.indexOf(this.currentStep());
|
||||||
@@ -848,6 +1017,12 @@ export class AirgapImportComponent {
|
|||||||
return stepIndex < currentIndex;
|
return stepIndex < currentIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOverlayClick(event: MouseEvent): void {
|
||||||
|
if ((event.target as HTMLElement).classList.contains('workflow-overlay')) {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDragOver(event: DragEvent): void {
|
onDragOver(event: DragEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.isDragOver.set(true);
|
this.isDragOver.set(true);
|
||||||
@@ -879,13 +1054,18 @@ export class AirgapImportComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startValidation(): void {
|
startValidation(): void {
|
||||||
const file = this.selectedFile();
|
if (!this.canProceed()) return;
|
||||||
if (!file) 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.currentStep.set('validate');
|
||||||
this.validationProgress.set(0);
|
this.validationProgress.set(0);
|
||||||
|
|
||||||
// Simulate validation progress
|
|
||||||
const progressInterval = setInterval(() => {
|
const progressInterval = setInterval(() => {
|
||||||
this.validationProgress.update((p) => Math.min(p + 10, 90));
|
this.validationProgress.update((p) => Math.min(p + 10, 90));
|
||||||
if (this.validationProgress() >= 30 && this.validationProgress() < 60) {
|
if (this.validationProgress() >= 30 && this.validationProgress() < 60) {
|
||||||
@@ -895,7 +1075,7 @@ export class AirgapImportComponent {
|
|||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
|
|
||||||
this.feedMirrorApi.validateImport(file).subscribe({
|
this.feedMirrorApi.validateImport(source).subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
this.validationProgress.set(100);
|
this.validationProgress.set(100);
|
||||||
@@ -950,6 +1130,9 @@ export class AirgapImportComponent {
|
|||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.currentStep.set('upload');
|
this.currentStep.set('upload');
|
||||||
|
this.sourceMode.set('path');
|
||||||
|
this.serverPath.set('');
|
||||||
|
this.sourceUrl.set('');
|
||||||
this.selectedFile.set(null);
|
this.selectedFile.set(null);
|
||||||
this.validation.set(null);
|
this.validation.set(null);
|
||||||
this.importProgress.set(null);
|
this.importProgress.set(null);
|
||||||
|
|||||||
@@ -327,12 +327,12 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--primary {
|
&--primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: var(--color-text-heading);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&: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 {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@@ -595,6 +596,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -658,10 +660,13 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
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 {
|
&:disabled {
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
MirrorSyncRequest,
|
MirrorSyncRequest,
|
||||||
} from '../../core/api/feed-mirror.models';
|
} from '../../core/api/feed-mirror.models';
|
||||||
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||||
|
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-mirror-list',
|
selector: 'app-mirror-list',
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, StellaFilterChipComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="mirror-list">
|
<div class="mirror-list">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -34,35 +35,18 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
class="search-input"
|
class="search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<stella-filter-chip
|
||||||
<select
|
label="Status"
|
||||||
[ngModel]="statusFilter()"
|
[value]="statusFilter() || 'All'"
|
||||||
(ngModelChange)="updateStatusFilter($event)"
|
[options]="statusOptions"
|
||||||
class="filter-select"
|
(valueChange)="onStatusChipChange($event)"
|
||||||
>
|
/>
|
||||||
<option value="">All Statuses</option>
|
<stella-filter-chip
|
||||||
<option value="synced">Synced</option>
|
label="Feed"
|
||||||
<option value="syncing">Syncing</option>
|
[value]="feedTypeFilter() || 'All'"
|
||||||
<option value="stale">Stale</option>
|
[options]="feedTypeOptions"
|
||||||
<option value="error">Error</option>
|
(valueChange)="onFeedTypeChipChange($event)"
|
||||||
<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>
|
|
||||||
<button type="button" class="refresh-btn" (click)="refresh.emit()">
|
<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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M23 4v6h-6"/>
|
<path d="M23 4v6h-6"/>
|
||||||
@@ -195,6 +179,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -202,7 +187,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
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);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.875rem;
|
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;
|
cursor: pointer;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -556,11 +537,40 @@ export class MirrorListComponent {
|
|||||||
readonly feedTypeFilter = signal<FeedType | ''>('');
|
readonly feedTypeFilter = signal<FeedType | ''>('');
|
||||||
readonly syncing = signal<Record<string, boolean>>({});
|
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 {
|
updateSearch(term: string): void {
|
||||||
this.searchTerm.set(term);
|
this.searchTerm.set(term);
|
||||||
this.emitFilter();
|
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 {
|
updateStatusFilter(status: MirrorSyncStatus | ''): void {
|
||||||
this.statusFilter.set(status);
|
this.statusFilter.set(status);
|
||||||
this.emitFilter();
|
this.emitFilter();
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
|||||||
<stella-page-tabs
|
<stella-page-tabs
|
||||||
[tabs]="pageTabs"
|
[tabs]="pageTabs"
|
||||||
[activeTab]="activeTab()"
|
[activeTab]="activeTab()"
|
||||||
urlParam="tab"
|
|
||||||
ariaLabel="Feeds & Offline sections"
|
ariaLabel="Feeds & Offline sections"
|
||||||
(tabChange)="onTabChange($event)"
|
(tabChange)="onTabChange($event)"
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,152 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router';
|
import { provideRouter, Router } from '@angular/router';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
import { PlatformFeedsAirgapPageComponent } from '../../app/features/platform/ops/platform-feeds-airgap-page.component';
|
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)', () => {
|
describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
|
||||||
let fixture: ComponentFixture<PlatformFeedsAirgapPageComponent>;
|
let fixture: ComponentFixture<PlatformFeedsAirgapPageComponent>;
|
||||||
let component: PlatformFeedsAirgapPageComponent;
|
let component: PlatformFeedsAirgapPageComponent;
|
||||||
let queryParamMap$: BehaviorSubject<ParamMap>;
|
let router: Router;
|
||||||
let currentQueryParamMap: ParamMap;
|
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 () => {
|
beforeEach(async () => {
|
||||||
currentQueryParamMap = convertToParamMap({ tab: 'version-locks' });
|
mockApi = jasmine.createSpyObj('FeedMirrorApi', [
|
||||||
queryParamMap$ = new BehaviorSubject(currentQueryParamMap);
|
'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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [PlatformFeedsAirgapPageComponent],
|
imports: [PlatformFeedsAirgapPageComponent],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([
|
||||||
{
|
{ path: '**', component: PlatformFeedsAirgapPageComponent },
|
||||||
provide: ActivatedRoute,
|
]),
|
||||||
useValue: {
|
{ provide: FEED_MIRROR_API, useValue: mockApi },
|
||||||
queryParamMap: queryParamMap$.asObservable(),
|
|
||||||
get snapshot() {
|
|
||||||
return { queryParamMap: currentQueryParamMap };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initWithUrl(url: string): Promise<void> {
|
||||||
|
await router.navigateByUrl(url);
|
||||||
fixture = TestBed.createComponent(PlatformFeedsAirgapPageComponent);
|
fixture = TestBed.createComponent(PlatformFeedsAirgapPageComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
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 () => {
|
it('loads bundles on init', async () => {
|
||||||
await fixture.whenStable();
|
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');
|
expect(component.tab()).toBe('version-locks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores unknown tab query values and keeps current tab', async () => {
|
it('ignores unknown tab query values and keeps default', async () => {
|
||||||
currentQueryParamMap = convertToParamMap({ tab: 'unknown-tab' });
|
await initWithUrl('/ops/operations/feeds-airgap?tab=unknown-tab');
|
||||||
queryParamMap$.next(currentQueryParamMap);
|
expect(component.tab()).toBe('feed-mirrors');
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
expect(component.tab()).toBe('version-locks');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tracks the airgap action from query params for canonical action links', async () => {
|
it('selects freshness-lens tab from query param', async () => {
|
||||||
currentQueryParamMap = convertToParamMap({ tab: 'airgap-bundles', action: 'import' });
|
await initWithUrl('/ops/operations/feeds-airgap?tab=freshness-lens');
|
||||||
queryParamMap$.next(currentQueryParamMap);
|
expect(component.tab()).toBe('freshness-lens');
|
||||||
fixture.detectChanges();
|
});
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
|
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.tab()).toBe('airgap-bundles');
|
||||||
expect(component.airgapAction()).toBe('import');
|
expect(component.airgapAction()).toBe('import');
|
||||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
|
||||||
|
|||||||
Reference in New Issue
Block a user