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:
@@ -10,6 +10,7 @@ import {
|
||||
AirGapBundleRequest,
|
||||
AirGapImportValidation,
|
||||
AirGapImportProgress,
|
||||
AirGapImportRequest,
|
||||
FeedVersionLock,
|
||||
FeedVersionLockRequest,
|
||||
OfflineSyncStatus,
|
||||
@@ -54,7 +55,7 @@ export interface FeedMirrorApi {
|
||||
downloadBundle(bundleId: string): Observable<SnapshotDownloadProgress>;
|
||||
|
||||
// AirGap import operations
|
||||
validateImport(file: File): Observable<AirGapImportValidation>;
|
||||
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation>;
|
||||
startImport(bundleId: string): Observable<AirGapImportProgress>;
|
||||
getImportProgress(importId: string): Observable<AirGapImportProgress>;
|
||||
|
||||
@@ -467,12 +468,18 @@ export class FeedMirrorHttpClient implements FeedMirrorApi {
|
||||
|
||||
// ---- AirGap import operations ----
|
||||
|
||||
validateImport(file: File): Observable<AirGapImportValidation> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
validateImport(source: AirGapImportRequest): Observable<AirGapImportValidation> {
|
||||
if (source.mode === 'file' && source.file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', source.file, source.file.name);
|
||||
return this.http.post<AirGapImportValidation>(
|
||||
`${this.baseUrl}/imports/validate`,
|
||||
formData,
|
||||
);
|
||||
}
|
||||
return this.http.post<AirGapImportValidation>(
|
||||
`${this.baseUrl}/imports/validate`,
|
||||
formData
|
||||
{ mode: source.mode, serverPath: source.serverPath, sourceUrl: source.sourceUrl },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -712,7 +719,7 @@ export class MockFeedMirrorApi implements FeedMirrorApi {
|
||||
}
|
||||
|
||||
// AirGap import operations
|
||||
validateImport(_file: File): Observable<AirGapImportValidation> {
|
||||
validateImport(_source: AirGapImportRequest): Observable<AirGapImportValidation> {
|
||||
return of({
|
||||
bundleId: 'import-validation-temp',
|
||||
status: 'valid' as const,
|
||||
|
||||
@@ -184,6 +184,27 @@ export interface AirGapImportValidation {
|
||||
readonly canImport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import source mode: server path, URL download, or browser file upload.
|
||||
*/
|
||||
export type ImportSourceMode = 'file' | 'path' | 'url';
|
||||
|
||||
/**
|
||||
* AirGap import source descriptor.
|
||||
* - `path`: Server-side absolute path (USB, NFS mount, volume).
|
||||
* - `url`: Internal URL for the backend to download directly.
|
||||
* - `file`: Browser-uploaded file (FormData) — for small bundles only.
|
||||
*/
|
||||
export interface AirGapImportRequest {
|
||||
readonly mode: ImportSourceMode;
|
||||
/** Browser-uploaded file (mode === 'file') */
|
||||
readonly file?: File;
|
||||
/** Server-side absolute path (mode === 'path') */
|
||||
readonly serverPath?: string;
|
||||
/** URL to download from (mode === 'url') */
|
||||
readonly sourceUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import validation error.
|
||||
*/
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
EventEmitter,
|
||||
inject,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
FeedMirror,
|
||||
FeedSnapshot,
|
||||
@@ -23,337 +24,366 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
@Component({
|
||||
selector: 'app-airgap-export',
|
||||
imports: [CommonModule, FormsModule, RouterModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="airgap-export">
|
||||
<header class="page-header">
|
||||
<a routerLink="/ops/feeds" class="back-link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5"/>
|
||||
<path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Feed Mirror
|
||||
</a>
|
||||
<h1>Export AirGap Bundle</h1>
|
||||
<p class="subtitle">Create a vulnerability feed bundle for air-gapped deployment</p>
|
||||
</header>
|
||||
<div class="workflow-overlay" (click)="onBackdropClick($event)">
|
||||
<div class="workflow-container">
|
||||
<header class="workflow-header">
|
||||
<h2 class="workflow-title">Export AirGap Bundle</h2>
|
||||
<button type="button" class="close-btn" (click)="closed.emit()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<nav class="steps-nav">
|
||||
@for (step of steps; track step.id) {
|
||||
<div
|
||||
class="step"
|
||||
[class.step--active]="currentStep() === step.id"
|
||||
[class.step--completed]="isStepCompleted(step.id)"
|
||||
>
|
||||
<span class="step-number">{{ step.number }}</span>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="content-area">
|
||||
<!-- Select Feeds Step -->
|
||||
@if (currentStep() === 'select') {
|
||||
<section class="step-content">
|
||||
<h2>Select Feeds to Include</h2>
|
||||
<p>Choose which vulnerability feeds to include in the bundle.</p>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading available feeds...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="feed-selection">
|
||||
@for (mirror of mirrors(); track mirror.mirrorId) {
|
||||
<label
|
||||
class="feed-option"
|
||||
[class.feed-option--selected]="isSelected(mirror.feedType)"
|
||||
[class.feed-option--disabled]="mirror.syncStatus === 'error'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(mirror.feedType)"
|
||||
[disabled]="mirror.syncStatus === 'error'"
|
||||
(change)="toggleFeed(mirror.feedType)"
|
||||
/>
|
||||
<div class="feed-info">
|
||||
<span class="feed-badge" [ngClass]="'feed-badge--' + mirror.feedType">
|
||||
{{ mirror.feedType | uppercase }}
|
||||
</span>
|
||||
<span class="feed-name">{{ mirror.name }}</span>
|
||||
</div>
|
||||
<div class="feed-meta">
|
||||
<span class="feed-status" [ngClass]="'status--' + mirror.syncStatus">
|
||||
{{ mirror.syncStatus | titlecase }}
|
||||
</span>
|
||||
<span class="feed-size">{{ formatBytes(mirror.totalSizeBytes) }}</span>
|
||||
@if (mirror.lastSyncAt) {
|
||||
<span class="feed-updated">{{ formatDate(mirror.lastSyncAt) }}</span>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="selection-summary">
|
||||
<span>{{ selectedFeeds().length }} feeds selected</span>
|
||||
<span class="estimated-size">Estimated size: {{ estimatedSize() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="selectedFeeds().length === 0"
|
||||
(click)="proceedToConfigure()"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<div class="workflow-body">
|
||||
<!-- Step Progress -->
|
||||
<nav class="steps-nav">
|
||||
@for (step of steps; track step.id) {
|
||||
<div
|
||||
class="step"
|
||||
[class.step--active]="currentStep() === step.id"
|
||||
[class.step--completed]="isStepCompleted(step.id)"
|
||||
>
|
||||
<span class="step-number">{{ step.number }}</span>
|
||||
<span class="step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Configure Step -->
|
||||
@if (currentStep() === 'configure') {
|
||||
<section class="step-content">
|
||||
<h2>Configure Bundle</h2>
|
||||
<p>Set bundle options and metadata.</p>
|
||||
<div class="content-area">
|
||||
<!-- Select Feeds Step -->
|
||||
@if (currentStep() === 'select') {
|
||||
<section class="step-content">
|
||||
<h2>Select Feeds to Include</h2>
|
||||
<p>Choose which vulnerability feeds to include in the bundle.</p>
|
||||
|
||||
<div class="config-form">
|
||||
<div class="form-group">
|
||||
<label for="bundleName">Bundle Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bundleName"
|
||||
[ngModel]="bundleName()"
|
||||
(ngModelChange)="bundleName.set($event)"
|
||||
placeholder="e.g., Full Feed Bundle - December 2025"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading available feeds...</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="feed-selection">
|
||||
@for (mirror of mirrors(); track mirror.mirrorId) {
|
||||
<label
|
||||
class="feed-option"
|
||||
[class.feed-option--selected]="isSelected(mirror.feedType)"
|
||||
[class.feed-option--disabled]="mirror.syncStatus === 'error'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(mirror.feedType)"
|
||||
[disabled]="mirror.syncStatus === 'error'"
|
||||
(change)="toggleFeed(mirror.feedType)"
|
||||
/>
|
||||
<div class="feed-info">
|
||||
<span class="feed-badge" [ngClass]="'feed-badge--' + mirror.feedType">
|
||||
{{ mirror.feedType | uppercase }}
|
||||
</span>
|
||||
<span class="feed-name">{{ mirror.name }}</span>
|
||||
</div>
|
||||
<div class="feed-meta">
|
||||
<span class="feed-status" [ngClass]="'status--' + mirror.syncStatus">
|
||||
{{ mirror.syncStatus | titlecase }}
|
||||
</span>
|
||||
<span class="feed-size">{{ formatBytes(mirror.totalSizeBytes) }}</span>
|
||||
@if (mirror.lastSyncAt) {
|
||||
<span class="feed-updated">{{ formatDate(mirror.lastSyncAt) }}</span>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="bundleDescription">Description</label>
|
||||
<textarea
|
||||
id="bundleDescription"
|
||||
[ngModel]="bundleDescription()"
|
||||
(ngModelChange)="bundleDescription.set($event)"
|
||||
placeholder="Optional description for this bundle..."
|
||||
rows="3"
|
||||
class="form-input"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="selection-summary">
|
||||
<span>{{ selectedFeeds().length }} feeds selected</span>
|
||||
<span class="estimated-size">Estimated size: {{ estimatedSize() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expirationDays">Expiration (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="expirationDays"
|
||||
[ngModel]="expirationDays()"
|
||||
(ngModelChange)="expirationDays.set($event)"
|
||||
min="7"
|
||||
max="365"
|
||||
class="form-input form-input--short"
|
||||
/>
|
||||
<span class="form-hint">Bundle will expire after this many days</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="includeSignature()"
|
||||
(change)="includeSignature.set(!includeSignature())"
|
||||
/>
|
||||
<span>Include cryptographic signature</span>
|
||||
</label>
|
||||
<span class="form-hint">Sign the bundle for integrity verification</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="useLatestSnapshots()"
|
||||
(change)="useLatestSnapshots.set(!useLatestSnapshots())"
|
||||
/>
|
||||
<span>Use latest snapshots</span>
|
||||
</label>
|
||||
<span class="form-hint">Automatically include the most recent snapshot for each feed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-feeds-summary">
|
||||
<h3>Selected Feeds</h3>
|
||||
<div class="feeds-list">
|
||||
@for (feedType of selectedFeeds(); track feedType) {
|
||||
<span class="feed-chip" [ngClass]="'feed-chip--' + feedType">
|
||||
{{ feedType | uppercase }}
|
||||
<button type="button" class="chip-remove" (click)="toggleFeed(feedType)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="selectedFeeds().length === 0"
|
||||
(click)="proceedToConfigure()"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="currentStep.set('select')">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!bundleName()"
|
||||
(click)="startBuild()"
|
||||
>
|
||||
Create Bundle
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<!-- Configure Step -->
|
||||
@if (currentStep() === 'configure') {
|
||||
<section class="step-content">
|
||||
<h2>Configure Bundle</h2>
|
||||
<p>Set bundle options and metadata.</p>
|
||||
|
||||
<!-- Building Step -->
|
||||
@if (currentStep() === 'building') {
|
||||
<section class="step-content">
|
||||
<h2>Building Bundle</h2>
|
||||
<p>Creating your air-gapped bundle...</p>
|
||||
<div class="config-form">
|
||||
<div class="form-group">
|
||||
<label for="bundleName">Bundle Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bundleName"
|
||||
[ngModel]="bundleName()"
|
||||
(ngModelChange)="bundleName.set($event)"
|
||||
placeholder="e.g., Full Feed Bundle - December 2025"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="build-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" [style.width.%]="buildProgress()"></div>
|
||||
</div>
|
||||
<div class="progress-details">
|
||||
<span class="progress-percent">{{ buildProgress() }}%</span>
|
||||
<span class="progress-status">{{ buildStatus() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<div class="form-group">
|
||||
<label for="bundleDescription">Description</label>
|
||||
<textarea
|
||||
id="bundleDescription"
|
||||
[ngModel]="bundleDescription()"
|
||||
(ngModelChange)="bundleDescription.set($event)"
|
||||
placeholder="Optional description for this bundle..."
|
||||
rows="3"
|
||||
class="form-input"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Ready Step -->
|
||||
@if (currentStep() === 'ready' && createdBundle()) {
|
||||
<section class="step-content step-content--ready">
|
||||
<div class="ready-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Bundle Ready</h2>
|
||||
<p>Your air-gapped bundle has been created and is ready for download.</p>
|
||||
<div class="form-group">
|
||||
<label for="expirationDays">Expiration (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="expirationDays"
|
||||
[ngModel]="expirationDays()"
|
||||
(ngModelChange)="expirationDays.set($event)"
|
||||
min="7"
|
||||
max="365"
|
||||
class="form-input form-input--short"
|
||||
/>
|
||||
<span class="form-hint">Bundle will expire after this many days</span>
|
||||
</div>
|
||||
|
||||
<div class="bundle-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Bundle Name</span>
|
||||
<span class="detail-value">{{ createdBundle()!.name }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Size</span>
|
||||
<span class="detail-value">{{ formatBytes(createdBundle()!.sizeBytes) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Included Feeds</span>
|
||||
<div class="detail-value feeds-row">
|
||||
@for (feed of createdBundle()!.includedFeeds; track feed) {
|
||||
<span class="feed-badge-small" [ngClass]="'feed-badge--' + feed">{{ feed | uppercase }}</span>
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="includeSignature()"
|
||||
(change)="includeSignature.set(!includeSignature())"
|
||||
/>
|
||||
<span>Include cryptographic signature</span>
|
||||
</label>
|
||||
<span class="form-hint">Sign the bundle for integrity verification</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="useLatestSnapshots()"
|
||||
(change)="useLatestSnapshots.set(!useLatestSnapshots())"
|
||||
/>
|
||||
<span>Use latest snapshots</span>
|
||||
</label>
|
||||
<span class="form-hint">Automatically include the most recent snapshot for each feed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-feeds-summary">
|
||||
<h3>Selected Feeds</h3>
|
||||
<div class="feeds-list">
|
||||
@for (feedType of selectedFeeds(); track feedType) {
|
||||
<span class="feed-chip" [ngClass]="'feed-chip--' + feedType">
|
||||
{{ feedType | uppercase }}
|
||||
<button type="button" class="chip-remove" (click)="toggleFeed(feedType)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="currentStep.set('select')">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!bundleName()"
|
||||
(click)="startBuild()"
|
||||
>
|
||||
Create Bundle
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Building Step -->
|
||||
@if (currentStep() === 'building') {
|
||||
<section class="step-content">
|
||||
<h2>Building Bundle</h2>
|
||||
<p>Creating your air-gapped bundle...</p>
|
||||
|
||||
<div class="build-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" [style.width.%]="buildProgress()"></div>
|
||||
</div>
|
||||
<div class="progress-details">
|
||||
<span class="progress-percent">{{ buildProgress() }}%</span>
|
||||
<span class="progress-status">{{ buildStatus() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Ready Step -->
|
||||
@if (currentStep() === 'ready' && createdBundle()) {
|
||||
<section class="step-content step-content--ready">
|
||||
<div class="ready-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Bundle Ready</h2>
|
||||
<p>Your air-gapped bundle has been created and is ready for download.</p>
|
||||
|
||||
<div class="bundle-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Bundle Name</span>
|
||||
<span class="detail-value">{{ createdBundle()!.name }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Size</span>
|
||||
<span class="detail-value">{{ formatBytes(createdBundle()!.sizeBytes) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Included Feeds</span>
|
||||
<div class="detail-value feeds-row">
|
||||
@for (feed of createdBundle()!.includedFeeds; track feed) {
|
||||
<span class="feed-badge-small" [ngClass]="'feed-badge--' + feed">{{ feed | uppercase }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (createdBundle()!.expiresAt) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Expires</span>
|
||||
<span class="detail-value">{{ formatDate(createdBundle()!.expiresAt!) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (createdBundle()!.expiresAt) {
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Expires</span>
|
||||
<span class="detail-value">{{ formatDate(createdBundle()!.expiresAt!) }}</span>
|
||||
|
||||
<div class="checksum-section">
|
||||
<h3>Checksums</h3>
|
||||
<div class="checksum-row">
|
||||
<span class="checksum-label">SHA-256</span>
|
||||
<code class="checksum-value">{{ createdBundle()!.checksumSha256 || 'Generating...' }}</code>
|
||||
<button type="button" class="copy-btn" (click)="copyChecksum('sha256')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="checksum-section">
|
||||
<h3>Checksums</h3>
|
||||
<div class="checksum-row">
|
||||
<span class="checksum-label">SHA-256</span>
|
||||
<code class="checksum-value">{{ createdBundle()!.checksumSha256 || 'Generating...' }}</code>
|
||||
<button type="button" class="copy-btn" (click)="copyChecksum('sha256')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-section">
|
||||
<a
|
||||
[href]="createdBundle()!.downloadUrl"
|
||||
class="btn btn--primary btn--large"
|
||||
[class.btn--disabled]="!createdBundle()!.downloadUrl"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download Bundle
|
||||
</a>
|
||||
@if (createdBundle()!.signatureUrl) {
|
||||
<a [href]="createdBundle()!.signatureUrl" class="btn btn--secondary">
|
||||
Download Signature
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="download-section">
|
||||
<a
|
||||
[href]="createdBundle()!.downloadUrl"
|
||||
class="btn btn--primary btn--large"
|
||||
[class.btn--disabled]="!createdBundle()!.downloadUrl"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download Bundle
|
||||
</a>
|
||||
@if (createdBundle()!.signatureUrl) {
|
||||
<a [href]="createdBundle()!.signatureUrl" class="btn btn--secondary">
|
||||
Download Signature
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<a routerLink="/ops/feeds" class="btn btn--secondary">
|
||||
Back to Feed Mirror
|
||||
</a>
|
||||
<button type="button" class="btn btn--secondary" (click)="reset()">
|
||||
Create Another Bundle
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="completed.emit()">
|
||||
Done
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="reset()">
|
||||
Create Another Bundle
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.airgap-export {
|
||||
padding: 1.5rem;
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-text-heading);
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
.workflow-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.workflow-container {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.workflow-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.workflow-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.15s;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.steps-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -366,18 +396,18 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-surface-inverse);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s;
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-status-info);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
background: var(--color-status-info-bg);
|
||||
|
||||
.step-number {
|
||||
background: var(--color-status-info);
|
||||
color: white;
|
||||
color: var(--color-surface-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +416,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.step-number {
|
||||
background: var(--color-status-success);
|
||||
color: white;
|
||||
color: var(--color-surface-inverse);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,7 +427,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--color-text-primary);
|
||||
background: var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -414,8 +444,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
}
|
||||
|
||||
.step-content {
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-surface-inverse);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
|
||||
@@ -442,7 +472,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-text-primary);
|
||||
border: 3px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-status-info);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 1s linear infinite;
|
||||
@@ -465,8 +495,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-surface-inverse);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
@@ -483,7 +513,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
&--selected {
|
||||
border-color: var(--color-status-info);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
background: var(--color-status-info-bg);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
@@ -506,12 +536,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
font-weight: var(--font-weight-bold);
|
||||
width: fit-content;
|
||||
|
||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
||||
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); }
|
||||
&--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||
&--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||
&--osv { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
&--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); }
|
||||
&--kev { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.feed-name {
|
||||
@@ -536,16 +566,16 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status--synced { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
||||
.status--syncing { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
||||
.status--stale { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); }
|
||||
.status--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
||||
.status--synced { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
.status--syncing { background: var(--color-status-info-bg); color: var(--color-status-info); }
|
||||
.status--stale { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||
.status--error { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--color-text-heading);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -590,10 +620,10 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.form-input {
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-text-primary);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:focus {
|
||||
@@ -618,7 +648,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.selected-feeds-summary {
|
||||
padding: 1rem;
|
||||
background: var(--color-text-heading);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
@@ -647,12 +677,12 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
||||
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info); }
|
||||
&--ghsa { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||
&--oval { background: var(--color-severity-info-bg); color: var(--color-status-excepted); }
|
||||
&--osv { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
&--epss { background: var(--color-status-warning-bg); color: var(--color-severity-high); }
|
||||
&--kev { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
@@ -675,7 +705,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background: var(--color-text-primary);
|
||||
background: var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
@@ -713,7 +743,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.bundle-details {
|
||||
text-align: left;
|
||||
background: var(--color-text-heading);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
@@ -724,7 +754,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-surface-inverse);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -754,7 +784,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.checksum-section {
|
||||
text-align: left;
|
||||
background: var(--color-text-heading);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -789,8 +819,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: var(--color-surface-inverse);
|
||||
border: 1px solid var(--color-text-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
@@ -798,8 +828,8 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-primary);
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,7 +846,7 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-surface-inverse);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -838,23 +868,23 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-btn-primary-bg);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-surface-inverse);
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,9 +899,11 @@ type ExportStep = 'select' | 'configure' | 'building' | 'ready';
|
||||
export class AirgapExportComponent implements OnInit {
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
||||
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() completed = new EventEmitter<void>();
|
||||
|
||||
readonly steps = [
|
||||
{ id: 'select' as ExportStep, number: 1, label: 'Select Feeds' },
|
||||
{ id: 'configure' as ExportStep, number: 2, label: 'Configure' },
|
||||
@@ -913,6 +945,12 @@ export class AirgapExportComponent implements OnInit {
|
||||
this.loadMirrors();
|
||||
}
|
||||
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('workflow-overlay')) {
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private loadMirrors(): void {
|
||||
this.loading.set(true);
|
||||
this.feedMirrorApi.listMirrors({ enabled: true }).subscribe({
|
||||
|
||||
@@ -3,14 +3,17 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AirGapImportValidation,
|
||||
AirGapImportProgress,
|
||||
FeedType,
|
||||
AirGapImportRequest,
|
||||
ImportSourceMode,
|
||||
} from '../../core/api/feed-mirror.models';
|
||||
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
|
||||
@@ -18,21 +21,21 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
|
||||
@Component({
|
||||
selector: 'app-airgap-import',
|
||||
imports: [CommonModule, RouterModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="airgap-import">
|
||||
<header class="page-header">
|
||||
<a routerLink="/ops/feeds" class="back-link">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5"/>
|
||||
<path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Feed Mirror
|
||||
</a>
|
||||
<h1>Import AirGap Bundle</h1>
|
||||
<p class="subtitle">Import a vulnerability feed bundle from external media for offline use</p>
|
||||
</header>
|
||||
<div class="workflow-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="workflow-container">
|
||||
<header class="workflow-header">
|
||||
<h2>Import AirGap Bundle</h2>
|
||||
<button type="button" class="close-btn" (click)="closed.emit()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="airgap-import">
|
||||
<!-- Progress Steps -->
|
||||
<nav class="steps-nav">
|
||||
@for (step of steps; track step.id) {
|
||||
@@ -51,58 +54,108 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
<!-- Upload Step -->
|
||||
@if (currentStep() === 'upload') {
|
||||
<section class="step-content">
|
||||
<h2>Select Bundle File</h2>
|
||||
<p>Choose an AirGap bundle file (.tar.gz or .zip) from your file system or external media.</p>
|
||||
<h2>Select Bundle Source</h2>
|
||||
<p>Choose how to provide the airgap bundle for import.</p>
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
[class.drop-zone--dragover]="isDragOver()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<div class="source-modes">
|
||||
<button type="button" [class.active]="sourceMode() === 'path'" (click)="sourceMode.set('path')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Server Path
|
||||
</button>
|
||||
<button type="button" [class.active]="sourceMode() === 'url'" (click)="sourceMode.set('url')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
URL
|
||||
</button>
|
||||
<button type="button" [class.active]="sourceMode() === 'file'" (click)="sourceMode.set('file')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<p class="drop-zone-text">
|
||||
Drag and drop your bundle file here, or
|
||||
<label class="file-input-label">
|
||||
browse
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.zip,.tgz"
|
||||
(change)="onFileSelect($event)"
|
||||
class="file-input"
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p class="drop-zone-hint">Supported formats: .tar.gz, .tgz, .zip</p>
|
||||
</div>
|
||||
File Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectedFile()) {
|
||||
<div class="selected-file">
|
||||
<div class="file-info">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
@if (sourceMode() === 'path') {
|
||||
<div class="source-input">
|
||||
<label for="serverPath">Server-side file or directory path</label>
|
||||
<input id="serverPath" type="text"
|
||||
placeholder="/var/lib/concelier/import/bundle-2026-04.tar.gz"
|
||||
[ngModel]="serverPath()" (ngModelChange)="serverPath.set($event)" />
|
||||
<p class="hint">Path inside the Concelier container. Stage bundles to the import volume
|
||||
(<code>/var/lib/concelier/import/</code>) — map host directories via
|
||||
<code>STELLAOPS_AIRGAP_IMPORT_DIR</code> in compose. Supports .tar.gz, .tgz, .zip or directories.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sourceMode() === 'url') {
|
||||
<div class="source-input">
|
||||
<label for="sourceUrl">Bundle URL</label>
|
||||
<input id="sourceUrl" type="url"
|
||||
placeholder="https://mirror.internal/bundles/latest.tar.gz"
|
||||
[ngModel]="sourceUrl()" (ngModelChange)="sourceUrl.set($event)" />
|
||||
<p class="hint">Internal URL to download the bundle from. The server fetches it directly — no browser transfer.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sourceMode() === 'file') {
|
||||
<div
|
||||
class="drop-zone"
|
||||
[class.drop-zone--dragover]="isDragOver()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<div class="file-details">
|
||||
<span class="file-name">{{ selectedFile()!.name }}</span>
|
||||
<span class="file-size">{{ formatBytes(selectedFile()!.size) }}</span>
|
||||
</div>
|
||||
<p class="drop-zone-text">
|
||||
Drag and drop your bundle file here, or
|
||||
<label class="file-input-label">
|
||||
browse
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.zip,.tgz"
|
||||
(change)="onFileSelect($event)"
|
||||
class="file-input"
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p class="drop-zone-hint">Supported formats: .tar.gz, .tgz, .zip — for small bundles only</p>
|
||||
</div>
|
||||
<button type="button" class="remove-file" (click)="removeFile()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (selectedFile()) {
|
||||
<div class="selected-file">
|
||||
<div class="file-info">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<div class="file-details">
|
||||
<span class="file-name">{{ selectedFile()!.name }}</span>
|
||||
<span class="file-size">{{ formatBytes(selectedFile()!.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-file" (click)="removeFile()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (canProceed()) {
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn btn--primary" (click)="startValidation()">
|
||||
Continue to Validation
|
||||
@@ -304,9 +357,9 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
}
|
||||
|
||||
<div class="step-actions">
|
||||
<a routerLink="/ops/feeds" class="btn btn--primary">
|
||||
<button type="button" class="btn btn--primary" (click)="completed.emit()">
|
||||
View Feed Mirrors
|
||||
</a>
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="reset()">
|
||||
Import Another Bundle
|
||||
</button>
|
||||
@@ -315,43 +368,72 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.airgap-import {
|
||||
padding: 1.5rem;
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-text-heading);
|
||||
min-height: calc(100vh - 120px);
|
||||
.workflow-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
.workflow-container {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
.workflow-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface-primary);
|
||||
z-index: 1;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
}
|
||||
.airgap-import {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
// Steps Navigation
|
||||
@@ -368,18 +450,18 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-surface-inverse);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s;
|
||||
|
||||
&--active {
|
||||
border-color: var(--color-status-info);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
background: var(--color-status-info-bg);
|
||||
|
||||
.step-number {
|
||||
background: var(--color-status-info);
|
||||
color: white;
|
||||
color: var(--color-surface-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +470,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
|
||||
.step-number {
|
||||
background: var(--color-status-success);
|
||||
color: white;
|
||||
color: var(--color-surface-inverse);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +481,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--color-text-primary);
|
||||
background: var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
@@ -417,8 +499,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
}
|
||||
|
||||
.step-content {
|
||||
background: var(--color-text-heading);
|
||||
border: 1px solid var(--color-surface-inverse);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
|
||||
@@ -435,8 +517,82 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
}
|
||||
|
||||
// Drop Zone
|
||||
/* Source mode selector */
|
||||
.source-modes {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.source-modes button {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: none;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.source-modes button:last-child { border-right: none; }
|
||||
|
||||
.source-modes button:hover:not(.active) {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.source-modes button.active {
|
||||
background: var(--color-btn-primary-bg);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.source-input {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.source-input label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.source-input input {
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.source-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.source-input .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--color-text-primary);
|
||||
border: 2px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
@@ -486,8 +642,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
background: var(--color-status-info-bg);
|
||||
border: 1px solid var(--color-status-info-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -526,8 +682,8 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +707,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
}
|
||||
|
||||
.progress-ring-bg {
|
||||
stroke: var(--color-text-primary);
|
||||
stroke: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.progress-ring-fill {
|
||||
@@ -577,13 +733,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
// Validation Result
|
||||
.validation-result {
|
||||
padding: 1.5rem;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
background: var(--color-status-error-bg);
|
||||
border: 1px solid var(--color-status-error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
&--valid {
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
background: var(--color-status-success-bg);
|
||||
border-color: var(--color-status-success-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +784,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
|
||||
.bundle-contents {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
@@ -653,12 +809,12 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
|
||||
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
|
||||
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
|
||||
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
|
||||
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
|
||||
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
|
||||
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info-text); border-color: var(--color-severity-info-border); }
|
||||
&--ghsa { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
|
||||
&--oval { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
|
||||
&--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-color: var(--color-severity-low-border); }
|
||||
&--epss { background: var(--color-severity-high-bg); color: var(--color-status-warning-text); border-color: var(--color-severity-high-border); }
|
||||
&--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); border-color: var(--color-severity-critical-border); }
|
||||
}
|
||||
|
||||
.content-stats {
|
||||
@@ -686,7 +842,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
.validation-warnings {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
@@ -699,7 +855,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
background: var(--color-status-error-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
@@ -712,11 +868,11 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
|
||||
.warning-item {
|
||||
padding: 0.5rem;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
background: var(--color-status-warning-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-status-warning-border);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
// Import Progress
|
||||
@@ -726,7 +882,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background: var(--color-text-primary);
|
||||
background: var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
@@ -783,7 +939,7 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-surface-inverse);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -798,23 +954,23 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
transition: all 0.15s;
|
||||
|
||||
&--primary {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-btn-primary-bg);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-text-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-inverse);
|
||||
color: rgba(212, 201, 168, 0.3);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,11 +978,13 @@ type ImportStep = 'upload' | 'validate' | 'review' | 'import' | 'complete';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AirgapImportComponent {
|
||||
private readonly router = inject(Router);
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() completed = new EventEmitter<void>();
|
||||
|
||||
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
||||
|
||||
readonly steps = [
|
||||
{ id: 'upload' as ImportStep, number: 1, label: 'Select File' },
|
||||
{ id: 'upload' as ImportStep, number: 1, label: 'Source' },
|
||||
{ id: 'validate' as ImportStep, number: 2, label: 'Validate' },
|
||||
{ id: 'review' as ImportStep, number: 3, label: 'Review' },
|
||||
{ id: 'import' as ImportStep, number: 4, label: 'Import' },
|
||||
@@ -834,6 +992,9 @@ export class AirgapImportComponent {
|
||||
];
|
||||
|
||||
readonly currentStep = signal<ImportStep>('upload');
|
||||
readonly sourceMode = signal<ImportSourceMode>('path');
|
||||
readonly serverPath = signal('');
|
||||
readonly sourceUrl = signal('');
|
||||
readonly selectedFile = signal<File | null>(null);
|
||||
readonly isDragOver = signal(false);
|
||||
readonly validationProgress = signal(0);
|
||||
@@ -841,6 +1002,14 @@ export class AirgapImportComponent {
|
||||
readonly validation = signal<AirGapImportValidation | null>(null);
|
||||
readonly importProgress = signal<AirGapImportProgress | null>(null);
|
||||
|
||||
readonly canProceed = computed(() => {
|
||||
switch (this.sourceMode()) {
|
||||
case 'path': return this.serverPath().trim().length > 0;
|
||||
case 'url': return this.sourceUrl().trim().length > 0;
|
||||
case 'file': return this.selectedFile() !== null;
|
||||
}
|
||||
});
|
||||
|
||||
isStepCompleted(stepId: ImportStep): boolean {
|
||||
const stepOrder = this.steps.map((s) => s.id);
|
||||
const currentIndex = stepOrder.indexOf(this.currentStep());
|
||||
@@ -848,6 +1017,12 @@ export class AirgapImportComponent {
|
||||
return stepIndex < currentIndex;
|
||||
}
|
||||
|
||||
onOverlayClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('workflow-overlay')) {
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.isDragOver.set(true);
|
||||
@@ -879,13 +1054,18 @@ export class AirgapImportComponent {
|
||||
}
|
||||
|
||||
startValidation(): void {
|
||||
const file = this.selectedFile();
|
||||
if (!file) return;
|
||||
if (!this.canProceed()) return;
|
||||
|
||||
const source: AirGapImportRequest = {
|
||||
mode: this.sourceMode(),
|
||||
...(this.sourceMode() === 'file' && this.selectedFile() ? { file: this.selectedFile()! } : {}),
|
||||
...(this.sourceMode() === 'path' ? { serverPath: this.serverPath().trim() } : {}),
|
||||
...(this.sourceMode() === 'url' ? { sourceUrl: this.sourceUrl().trim() } : {}),
|
||||
};
|
||||
|
||||
this.currentStep.set('validate');
|
||||
this.validationProgress.set(0);
|
||||
|
||||
// Simulate validation progress
|
||||
const progressInterval = setInterval(() => {
|
||||
this.validationProgress.update((p) => Math.min(p + 10, 90));
|
||||
if (this.validationProgress() >= 30 && this.validationProgress() < 60) {
|
||||
@@ -895,7 +1075,7 @@ export class AirgapImportComponent {
|
||||
}
|
||||
}, 200);
|
||||
|
||||
this.feedMirrorApi.validateImport(file).subscribe({
|
||||
this.feedMirrorApi.validateImport(source).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.validationProgress.set(100);
|
||||
@@ -950,6 +1130,9 @@ export class AirgapImportComponent {
|
||||
|
||||
reset(): void {
|
||||
this.currentStep.set('upload');
|
||||
this.sourceMode.set('path');
|
||||
this.serverPath.set('');
|
||||
this.sourceUrl.set('');
|
||||
this.selectedFile.set(null);
|
||||
this.validation.set(null);
|
||||
this.importProgress.set(null);
|
||||
|
||||
@@ -327,12 +327,12 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-btn-primary-bg);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-status-info-text);
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,12 +581,13 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@@ -595,6 +596,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -658,10 +660,13 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 150ms ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-status-info);
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-brand-primary-rgb, 99, 102, 241), 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
MirrorSyncRequest,
|
||||
} from '../../core/api/feed-mirror.models';
|
||||
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-mirror-list',
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, StellaFilterChipComponent],
|
||||
template: `
|
||||
<div class="mirror-list">
|
||||
<!-- Filters -->
|
||||
@@ -34,35 +35,18 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select
|
||||
[ngModel]="statusFilter()"
|
||||
(ngModelChange)="updateStatusFilter($event)"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="synced">Synced</option>
|
||||
<option value="syncing">Syncing</option>
|
||||
<option value="stale">Stale</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<select
|
||||
[ngModel]="feedTypeFilter()"
|
||||
(ngModelChange)="updateFeedTypeFilter($event)"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">All Feed Types</option>
|
||||
<option value="nvd">NVD</option>
|
||||
<option value="ghsa">GHSA</option>
|
||||
<option value="oval">OVAL</option>
|
||||
<option value="osv">OSV</option>
|
||||
<option value="epss">EPSS</option>
|
||||
<option value="kev">KEV</option>
|
||||
</select>
|
||||
</div>
|
||||
<stella-filter-chip
|
||||
label="Status"
|
||||
[value]="statusFilter() || 'All'"
|
||||
[options]="statusOptions"
|
||||
(valueChange)="onStatusChipChange($event)"
|
||||
/>
|
||||
<stella-filter-chip
|
||||
label="Feed"
|
||||
[value]="feedTypeFilter() || 'All'"
|
||||
[options]="feedTypeOptions"
|
||||
(valueChange)="onFeedTypeChipChange($event)"
|
||||
/>
|
||||
<button type="button" class="refresh-btn" (click)="refresh.emit()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6"/>
|
||||
@@ -195,6 +179,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 150ms ease;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
@@ -202,7 +187,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-status-info);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,10 +198,6 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5A2E' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
@@ -556,11 +537,40 @@ export class MirrorListComponent {
|
||||
readonly feedTypeFilter = signal<FeedType | ''>('');
|
||||
readonly syncing = signal<Record<string, boolean>>({});
|
||||
|
||||
readonly statusOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All' },
|
||||
{ id: 'synced', label: 'Synced' },
|
||||
{ id: 'syncing', label: 'Syncing' },
|
||||
{ id: 'stale', label: 'Stale' },
|
||||
{ id: 'error', label: 'Error' },
|
||||
{ id: 'disabled', label: 'Disabled' },
|
||||
];
|
||||
|
||||
readonly feedTypeOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All' },
|
||||
{ id: 'nvd', label: 'NVD' },
|
||||
{ id: 'ghsa', label: 'GHSA' },
|
||||
{ id: 'oval', label: 'OVAL' },
|
||||
{ id: 'osv', label: 'OSV' },
|
||||
{ id: 'epss', label: 'EPSS' },
|
||||
{ id: 'kev', label: 'KEV' },
|
||||
];
|
||||
|
||||
updateSearch(term: string): void {
|
||||
this.searchTerm.set(term);
|
||||
this.emitFilter();
|
||||
}
|
||||
|
||||
onStatusChipChange(id: string): void {
|
||||
this.statusFilter.set(id as MirrorSyncStatus | '');
|
||||
this.emitFilter();
|
||||
}
|
||||
|
||||
onFeedTypeChipChange(id: string): void {
|
||||
this.feedTypeFilter.set(id as FeedType | '');
|
||||
this.emitFilter();
|
||||
}
|
||||
|
||||
updateStatusFilter(status: MirrorSyncStatus | ''): void {
|
||||
this.statusFilter.set(status);
|
||||
this.emitFilter();
|
||||
|
||||
@@ -23,7 +23,6 @@ const PAGE_TABS: readonly StellaPageTab[] = [
|
||||
<stella-page-tabs
|
||||
[tabs]="pageTabs"
|
||||
[activeTab]="activeTab()"
|
||||
urlParam="tab"
|
||||
ariaLabel="Feeds & Offline sections"
|
||||
(tabChange)="onTabChange($event)"
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +1,152 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, ParamMap, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PlatformFeedsAirgapPageComponent } from '../../app/features/platform/ops/platform-feeds-airgap-page.component';
|
||||
import { FEED_MIRROR_API } from '../../app/core/api/feed-mirror.client';
|
||||
import type { FeedMirror, OfflineSyncStatus, AirGapBundle } from '../../app/core/api/feed-mirror.models';
|
||||
|
||||
describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
|
||||
let fixture: ComponentFixture<PlatformFeedsAirgapPageComponent>;
|
||||
let component: PlatformFeedsAirgapPageComponent;
|
||||
let queryParamMap$: BehaviorSubject<ParamMap>;
|
||||
let currentQueryParamMap: ParamMap;
|
||||
let router: Router;
|
||||
let mockApi: jasmine.SpyObj<any>;
|
||||
|
||||
const mockMirrors: Partial<FeedMirror>[] = [
|
||||
{
|
||||
mirrorId: 'mirror-1',
|
||||
name: 'NVD Mirror',
|
||||
feedType: 'nvd',
|
||||
enabled: true,
|
||||
syncStatus: 'synced',
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
syncIntervalMinutes: 120,
|
||||
totalSizeBytes: 500 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
mirrorId: 'mirror-2',
|
||||
name: 'OSV Mirror',
|
||||
feedType: 'osv',
|
||||
enabled: true,
|
||||
syncStatus: 'stale',
|
||||
lastSyncAt: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
|
||||
syncIntervalMinutes: 120,
|
||||
totalSizeBytes: 200 * 1024 * 1024,
|
||||
},
|
||||
{
|
||||
mirrorId: 'mirror-3',
|
||||
name: 'KEV Mirror',
|
||||
feedType: 'kev',
|
||||
enabled: true,
|
||||
syncStatus: 'error',
|
||||
lastSyncAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
|
||||
syncIntervalMinutes: 360,
|
||||
totalSizeBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
];
|
||||
|
||||
const mockOfflineStatus: Partial<OfflineSyncStatus> = {
|
||||
state: 'partial',
|
||||
lastOnlineAt: new Date().toISOString(),
|
||||
mirrorStats: { total: 3, synced: 1, stale: 1, error: 1 },
|
||||
totalStorageBytes: 710 * 1024 * 1024,
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const mockBundles: Partial<AirGapBundle>[] = [
|
||||
{
|
||||
bundleId: 'bundle-1',
|
||||
name: 'Full Export',
|
||||
status: 'ready',
|
||||
sizeBytes: 1024 * 1024 * 500,
|
||||
includedFeeds: ['nvd', 'osv'],
|
||||
createdAt: new Date().toISOString(),
|
||||
downloadUrl: '/api/bundles/bundle-1/download',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
currentQueryParamMap = convertToParamMap({ tab: 'version-locks' });
|
||||
queryParamMap$ = new BehaviorSubject(currentQueryParamMap);
|
||||
mockApi = jasmine.createSpyObj('FeedMirrorApi', [
|
||||
'listMirrors',
|
||||
'getOfflineSyncStatus',
|
||||
'listBundles',
|
||||
'listVersionLocks',
|
||||
]);
|
||||
mockApi.listMirrors.and.returnValue(of(mockMirrors));
|
||||
mockApi.getOfflineSyncStatus.and.returnValue(of(mockOfflineStatus));
|
||||
mockApi.listBundles.and.returnValue(of(mockBundles));
|
||||
mockApi.listVersionLocks.and.returnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlatformFeedsAirgapPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
get snapshot() {
|
||||
return { queryParamMap: currentQueryParamMap };
|
||||
},
|
||||
},
|
||||
},
|
||||
provideRouter([
|
||||
{ path: '**', component: PlatformFeedsAirgapPageComponent },
|
||||
]),
|
||||
{ provide: FEED_MIRROR_API, useValue: mockApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
async function initWithUrl(url: string): Promise<void> {
|
||||
await router.navigateByUrl(url);
|
||||
fixture = TestBed.createComponent(PlatformFeedsAirgapPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
it('loads mirrors and offline status on init', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap');
|
||||
expect(mockApi.listMirrors).toHaveBeenCalled();
|
||||
expect(mockApi.getOfflineSyncStatus).toHaveBeenCalled();
|
||||
expect(component.mirrors().length).toBe(3);
|
||||
expect(component.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('selects tab from query param when value is valid', async () => {
|
||||
await fixture.whenStable();
|
||||
it('loads bundles on init', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap');
|
||||
expect(mockApi.listBundles).toHaveBeenCalled();
|
||||
expect(component.bundles().length).toBe(1);
|
||||
expect(component.loadingBundles()).toBe(false);
|
||||
});
|
||||
|
||||
it('computes synced, stale, and error counts', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap');
|
||||
expect(component.syncedCount()).toBe(1);
|
||||
expect(component.staleCount()).toBe(1);
|
||||
expect(component.errorCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('selects tab from query param', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap?tab=version-locks');
|
||||
expect(component.tab()).toBe('version-locks');
|
||||
});
|
||||
|
||||
it('ignores unknown tab query values and keeps current tab', async () => {
|
||||
currentQueryParamMap = convertToParamMap({ tab: 'unknown-tab' });
|
||||
queryParamMap$.next(currentQueryParamMap);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.tab()).toBe('version-locks');
|
||||
it('ignores unknown tab query values and keeps default', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap?tab=unknown-tab');
|
||||
expect(component.tab()).toBe('feed-mirrors');
|
||||
});
|
||||
|
||||
it('tracks the airgap action from query params for canonical action links', async () => {
|
||||
currentQueryParamMap = convertToParamMap({ tab: 'airgap-bundles', action: 'import' });
|
||||
queryParamMap$.next(currentQueryParamMap);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
it('selects freshness-lens tab from query param', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap?tab=freshness-lens');
|
||||
expect(component.tab()).toBe('freshness-lens');
|
||||
});
|
||||
|
||||
it('computes freshness rows from mirror data', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap?tab=freshness-lens');
|
||||
const rows = component.freshnessRows();
|
||||
expect(rows.length).toBe(3);
|
||||
expect(rows[0].source).toBe('NVD Mirror');
|
||||
expect(rows[0].status).toBe('OK');
|
||||
expect(rows[1].status).toBe('WARN'); // stale syncStatus
|
||||
expect(rows[2].status).toBe('FAIL'); // error syncStatus
|
||||
});
|
||||
|
||||
it('tracks airgap action from query params for backward compat', async () => {
|
||||
await initWithUrl('/ops/operations/feeds-airgap?tab=airgap-bundles&action=import');
|
||||
expect(component.tab()).toBe('airgap-bundles');
|
||||
expect(component.airgapAction()).toBe('import');
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
|
||||
|
||||
Reference in New Issue
Block a user