Add advisory source catalog UI, mirror wizard, and mirror dashboard
Source catalog component: browsable catalog of 75 advisory sources grouped by 14 categories with search, filter, enable/disable toggles, batch operations, health checks, and category descriptions. Mirror domain builder: 3-step wizard (select sources → configure domain → review & create) with category-level selection, auto-naming, format choice, rate limits, signing options, and optional immediate generation. Mirror dashboard: domain cards with staleness indicators, regenerate and delete actions, consumer config panel, endpoint viewer, and empty-state CTA leading to the wizard. Catalog mirror header: mode badge, domain stats, and quick-access buttons for mirror configuration integrated into the source catalog. Supporting: source management API client (9 endpoints), mirror management API client (12 endpoints), integration hub route wiring, onboarding hub advisory section, security page health display fix, E2E Playwright tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
# Sprint 20260315-007 — Full Mirror Source Completeness & Setup UI
|
||||
|
||||
## Topic & Scope
|
||||
- Complete the advisory/VEX source catalog to cover ALL major vulnerability data source types for offline mirror completeness.
|
||||
- Add missing source categories: exploit databases, cloud provider advisories, container-specific, hardware/firmware, ICS, additional CERTs, package manager native feeds, threat intelligence frameworks.
|
||||
- Build mirror configuration UI so operators can set up mirroring without editing env vars or config files.
|
||||
- Promote Russian/CIS connectors (FSTEC BDU, NKCKI, Kaspersky ICS) from beta to stable.
|
||||
- Working directory: `src/Concelier/`, `src/VexHub/`, `src/Web/StellaOps.Web/`
|
||||
- Expected evidence: catalog expansion, connector stubs, mirror config UI, updated docs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on: Sprint 20260315 Advisory & VEX Source Management (TASK 1-8, completed — source catalog UI, API endpoints, backoff).
|
||||
- Safe parallelism: TASK 1-4 (catalog expansion) can all run in parallel. TASK 5 (filter model) is independent backend work. TASK 8 (promote RU/CIS) independent. TASK 9 independent.
|
||||
- Sequential chain: TASK-005 → TASK-006 → TASK-006b (backend mirror pipeline). TASK-006 → TASK-007a → TASK-007b → TASK-007c (frontend mirror wizard/dashboard).
|
||||
- TASK-010 depends on TASK 1-4 (new sources) + TASK-005 (multi-value filters) + TASK-006b (scheduled refresh).
|
||||
- TASK-007 (category filter UI) depends on TASK-003 (new enum values).
|
||||
|
||||
```
|
||||
TASK 1-4 (catalog expansion) ──────────────────────────────────────────┐
|
||||
├──► TASK-010 (mirror export update)
|
||||
TASK-005 (multi-value filters) ──► TASK-006 (domain CRUD API) ──┬──────┘
|
||||
├──► TASK-006b (export scheduler)
|
||||
├──► TASK-007a (domain builder wizard)
|
||||
│ ├──► TASK-007b (mirror dashboard)
|
||||
│ └──► TASK-007c (catalog mirror header)
|
||||
└──► TASK-007 (category filter update)
|
||||
TASK-008 (RU/CIS promote) ───── independent ─────────────
|
||||
TASK-009 (threat intel) ──────── independent ─────────────
|
||||
TASK-011 (docs) ──────────────── depends on all ──────────
|
||||
```
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/concelier/architecture.md`
|
||||
- `docs/modules/concelier/connectors.md`
|
||||
- `docs/modules/excititor/mirrors.md`
|
||||
- `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs` (current 47-source catalog)
|
||||
|
||||
---
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### TASK-001 - Add exploit database sources to catalog
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
Add 3 exploit/PoC data sources to `SourceDefinitions.cs` under a new `SourceCategory.Exploit` enum value:
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| Exploit-DB | `exploitdb` | `https://gitlab.com/exploit-database/exploitdb` | None | 110 |
|
||||
| PoC-in-GitHub | `poc-github` | `https://api.github.com` (search) | GitHub PAT | 112 |
|
||||
| Metasploit Modules | `metasploit` | `https://raw.githubusercontent.com/rapid7/metasploit-framework` | None | 114 |
|
||||
|
||||
Also add `Exploit` to the `SourceCategory` enum in `SourceDefinitions.cs`.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 3 new source definitions added
|
||||
- [ ] `SourceCategory.Exploit` enum value added
|
||||
- [ ] HTTP client names configured in `SourcesServiceCollectionExtensions.cs`
|
||||
- [ ] Source catalog API returns 50 sources (up from 47)
|
||||
|
||||
### TASK-002 - Add cloud provider advisory sources
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
Add 3 cloud provider advisory sources to `SourceDefinitions.cs` under `SourceCategory.Vendor`:
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| AWS Security Bulletins | `aws` | `https://aws.amazon.com/security/security-bulletins/` | None | 81 |
|
||||
| Azure Security Advisories | `azure` | `https://api.msrc.microsoft.com` | None | 82 |
|
||||
| GCP Security Bulletins | `gcp` | `https://cloud.google.com/support/bulletins` | None | 83 |
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 3 new source definitions added under Vendor category
|
||||
- [ ] HTTP clients configured
|
||||
- [ ] Source catalog API returns updated count
|
||||
|
||||
### TASK-003 - Add container & hardware advisory sources
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
Add container-specific and hardware/firmware PSIRT sources:
|
||||
|
||||
**Container** (new `SourceCategory.Container`):
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| Docker Official CVEs | `docker-official` | `https://hub.docker.com` | None | 120 |
|
||||
| Chainguard Advisories | `chainguard` | `https://images.chainguard.dev` | None | 122 |
|
||||
|
||||
**Hardware** (new `SourceCategory.Hardware`):
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| Intel PSIRT | `intel` | `https://www.intel.com/content/www/us/en/security-center` | None | 130 |
|
||||
| AMD Security | `amd` | `https://www.amd.com/en/resources/product-security` | None | 132 |
|
||||
| ARM Security | `arm` | `https://developer.arm.com/Arm%20Security%20Center` | None | 134 |
|
||||
|
||||
**ICS** (add to existing `SourceCategory.Cert` or new `SourceCategory.Ics`):
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| Siemens ProductCERT | `siemens` | `https://cert-portal.siemens.com/productcert` | None | 136 |
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 6 new source definitions added
|
||||
- [ ] New enum values: `Container`, `Hardware` (or reuse existing categories)
|
||||
- [ ] HTTP clients configured
|
||||
- [ ] Frontend CATEGORY_ORDER updated in `advisory-source-catalog.component.ts`
|
||||
|
||||
### TASK-004 - Add package manager native advisory sources and additional CERTs
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
**Package manager native** (add to `SourceCategory.Ecosystem`):
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|------|----------|
|
||||
| cargo-audit (RustSec) | `rustsec` | `https://raw.githubusercontent.com/rustsec/advisory-db` | None | 63 |
|
||||
| pip-audit (PyPA) | `pypa` | `https://github.com/pypa/advisory-database` | None | 53 |
|
||||
| govulncheck (Go) | `govuln` | `https://vuln.go.dev` | None | 55 |
|
||||
| bundler-audit (Ruby) | `bundler-audit` | `https://github.com/rubysec/ruby-advisory-db` | None | 57 |
|
||||
|
||||
**Additional CERTs** (add to `SourceCategory.Cert`):
|
||||
|
||||
| Source | ID | Endpoint | Auth | Priority | Regions |
|
||||
|--------|----|----------|------|----------|---------|
|
||||
| CERT-UA | `cert-ua` | `https://cert.gov.ua` | None | 95 | UA |
|
||||
| CERT-PL | `cert-pl` | `https://cert.pl` | None | 96 | PL, EU |
|
||||
| AusCERT | `auscert` | `https://auscert.org.au` | None | 97 | AU, APAC |
|
||||
| KrCERT/CC | `krcert` | `https://www.krcert.or.kr` | None | 98 | KR, APAC |
|
||||
| CERT-In | `cert-in` | `https://www.cert-in.org.in` | None | 99 | IN, APAC |
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 9 new source definitions added (4 ecosystem + 5 CERTs)
|
||||
- [ ] Regional tags set correctly
|
||||
- [ ] HTTP clients configured
|
||||
|
||||
### TASK-005 - Extend MirrorExportOptions filter model to support multi-source selection
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer (Backend)
|
||||
|
||||
**Problem**: `MirrorExportOptions.Filters` is `Dictionary<string, string>`. To include Debian + Ubuntu + Alpine in one export, an operator must create 3 separate export definitions (one per sourceVendor). This is unergonomic and breaks the "one domain = one bundle" model for distribution grouping.
|
||||
|
||||
**Files to modify**:
|
||||
- `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs` — change `Filters` to `Dictionary<string, object>` or add `ArrayFilters: Dictionary<string, string[]>` alongside the existing string filters
|
||||
- `src/Concelier/__Libraries/StellaOps.Excititor.Core/VexQuery.cs` — update query parser to handle comma-separated values in `sourceVendor` filter (e.g., `"debian,ubuntu,alpine"` → OR match)
|
||||
- `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportPlanner.cs` — update signature computation and matching to handle multi-value filters
|
||||
- `src/Concelier/__Libraries/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs` — ensure bundle generation aggregates across multiple source vendors in one export
|
||||
|
||||
**Also add category-based filter shorthand**:
|
||||
- `sourceCategory=Distribution` → automatically resolves to all distribution sources (Debian, Ubuntu, Alpine, SUSE, RHEL, CentOS, Fedora, Arch, Gentoo, Astra)
|
||||
- `sourceTag=linux` → resolves to all sources tagged `linux`
|
||||
- Resolution uses `ISourceRegistry.GetSourcesByCategory()` / tag lookup at export time
|
||||
|
||||
**Example after fix** — single export for all Linux distros:
|
||||
```json
|
||||
{
|
||||
"Key": "linux-distros",
|
||||
"Format": "openvex",
|
||||
"Filters": {
|
||||
"sourceCategory": "Distribution"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Multi-value sourceVendor filter works (comma-separated OR array)
|
||||
- [ ] `sourceCategory` filter shorthand resolves to matching sources
|
||||
- [ ] `sourceTag` filter shorthand resolves to matching sources
|
||||
- [ ] Existing single-value filters remain backward-compatible
|
||||
- [ ] Query signature is deterministic for multi-value filters (sorted, normalized)
|
||||
|
||||
### TASK-006 - Mirror Domain Management API Endpoints
|
||||
Status: TODO
|
||||
Dependency: TASK-005
|
||||
Owners: Developer (Backend)
|
||||
|
||||
Add mirror domain CRUD + status endpoints. Create new file: `src/Concelier/StellaOps.Concelier.WebService/Extensions/MirrorManagementEndpointExtensions.cs` (separate from source management, maps under `/api/v1/mirror`).
|
||||
|
||||
**Endpoints**:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/v1/mirror/config` | Read current mirror configuration (mode, output root, signing, domains) |
|
||||
| PUT | `/api/v1/mirror/config` | Update mirror mode (direct/mirror/hybrid), signing options |
|
||||
| GET | `/api/v1/mirror/domains` | List all configured mirror domains with export counts and last-sync times |
|
||||
| POST | `/api/v1/mirror/domains` | Create a new mirror domain with exports and filters |
|
||||
| GET | `/api/v1/mirror/domains/{domainId}` | Get domain detail with all exports, filter config, and status |
|
||||
| PUT | `/api/v1/mirror/domains/{domainId}` | Update domain (display name, auth, rate limits, exports) |
|
||||
| DELETE | `/api/v1/mirror/domains/{domainId}` | Remove a mirror domain |
|
||||
| POST | `/api/v1/mirror/domains/{domainId}/exports` | Add an export to a domain (key, format, filters) |
|
||||
| DELETE | `/api/v1/mirror/domains/{domainId}/exports/{exportKey}` | Remove an export from a domain |
|
||||
| POST | `/api/v1/mirror/domains/{domainId}/generate` | Trigger bundle generation for this domain (on-demand) |
|
||||
| GET | `/api/v1/mirror/domains/{domainId}/status` | Get domain sync status (last generate, bundle size, advisory count, staleness) |
|
||||
| POST | `/api/v1/mirror/test` | Test mirror endpoint connectivity (for consumer-mode mirror URL) |
|
||||
|
||||
**Request DTOs**:
|
||||
```
|
||||
CreateMirrorDomainRequest { id, displayName, requireAuthentication, maxIndexRequestsPerHour, maxDownloadRequestsPerHour, exports[] }
|
||||
CreateMirrorExportRequest { key, format, filters: { sourceCategory?, sourceVendor?, sourceTag?, vulnId?, productKey? } }
|
||||
UpdateMirrorConfigRequest { mode: "direct"|"mirror"|"hybrid", signing: { enabled, algorithm, keyId }, consumerBaseAddress? }
|
||||
```
|
||||
|
||||
**Persistence**: Store domain/export configuration in `excititor.mirror_domains` and `excititor.mirror_exports` tables (new migration). On startup, merge DB config with env-var config (DB takes precedence for domains defined in both).
|
||||
|
||||
**Wire**: Add `app.MapMirrorManagementEndpoints()` in Excititor's `Program.cs` (or Concelier if the endpoints proxy to Excititor via router).
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 12 endpoints created and wired
|
||||
- [ ] Domain CRUD persisted to database
|
||||
- [ ] On-demand bundle generation trigger works
|
||||
- [ ] DB migration for mirror_domains and mirror_exports tables
|
||||
- [ ] Env-var config remains as fallback for domains not defined in DB
|
||||
|
||||
### TASK-006b - Mirror Export Scheduler (background bundle refresh)
|
||||
Status: TODO
|
||||
Dependency: TASK-006
|
||||
Owners: Developer (Backend)
|
||||
|
||||
**Problem**: Mirror bundles are only generated reactively when the export engine runs. There's no periodic refresh to keep mirror bundles up-to-date as new advisories are ingested.
|
||||
|
||||
**File to create**: `src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorExportScheduler.cs`
|
||||
|
||||
Implement as `BackgroundService`:
|
||||
1. On startup, load all configured mirror domains from DB + config
|
||||
2. Every N minutes (configurable via `MirrorDistributionOptions.RefreshIntervalMinutes`, default: 60), iterate all domains
|
||||
3. For each domain, check if any source in the export filters has been updated since last bundle generation
|
||||
4. If stale, trigger bundle regeneration via `VexMirrorBundlePublisher`
|
||||
5. Log bundle generation metrics (duration, advisory count, bundle size)
|
||||
6. Expose staleness via the `/api/v1/mirror/domains/{domainId}/status` endpoint
|
||||
|
||||
**Add to MirrorDistributionOptions**:
|
||||
```csharp
|
||||
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||
public bool AutoRefreshEnabled { get; set; } = true;
|
||||
```
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Background scheduler regenerates stale bundles periodically
|
||||
- [ ] Staleness detection based on source last-sync timestamps
|
||||
- [ ] Configurable refresh interval
|
||||
- [ ] Scheduler can be disabled for air-gap deployments (bundles are imported, not generated)
|
||||
|
||||
### TASK-007a - Mirror Domain Builder UI (source-to-domain wizard)
|
||||
Status: TODO
|
||||
Dependency: TASK-005, TASK-006
|
||||
Owners: Developer (FE)
|
||||
|
||||
**This is the core missing operator journey**: an operator selects sources from the catalog, groups them into a mirror domain, chooses an export format, and generates the bundle.
|
||||
|
||||
**File to create**: `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts`
|
||||
|
||||
**Wizard flow (3 steps)**:
|
||||
|
||||
**Step 1: Select Sources**
|
||||
- Show source catalog grouped by category (reuse `groupedByCategory` computed from catalog component)
|
||||
- Category-level checkboxes: check "Distribution" → selects all 9+ distribution sources
|
||||
- Individual source checkboxes within each category
|
||||
- Search/filter bar (reuse from catalog)
|
||||
- Right sidebar: live summary of selected sources (count by category, estimated advisory volume)
|
||||
- Shorthand buttons: "All Primary", "All Distributions", "All Ecosystem", "All CERTs", "Everything"
|
||||
|
||||
**Step 2: Configure Domain**
|
||||
- Domain ID (auto-generated from selection, editable): e.g., `distro-linux`, `full-mirror`, `eu-certs`
|
||||
- Display name (auto-generated, editable): e.g., "Linux Distribution Advisories"
|
||||
- Export format dropdown: JSON, JSONL, OpenVEX, CSAF, CycloneDX
|
||||
- Multiple exports toggle: allow creating multiple exports per domain (e.g., one per format)
|
||||
- Rate limits: index requests/hour, download requests/hour (with defaults)
|
||||
- Authentication requirement toggle
|
||||
- Signing: toggle + algorithm + key ID
|
||||
|
||||
**Step 3: Review & Create**
|
||||
- Summary card: domain name, X sources across Y categories, export format, rate limits
|
||||
- Filter preview: show the resolved filter JSON that will be stored
|
||||
- "Create Domain" button → calls `POST /api/v1/mirror/domains`
|
||||
- "Generate Now" checkbox → also triggers `POST /api/v1/mirror/domains/{id}/generate` after creation
|
||||
- Success state: show domain URL, link to mirror endpoints, instructions for downstream consumers
|
||||
|
||||
**Route**: `advisory-vex-sources/mirror/new` (lazy-loaded from `integration-hub.routes.ts`)
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 3-step wizard component created
|
||||
- [ ] Category-level and individual source selection works
|
||||
- [ ] Domain auto-naming from selection
|
||||
- [ ] Creates domain via API
|
||||
- [ ] Optional immediate bundle generation
|
||||
- [ ] Route wired and accessible from catalog component ("Create Mirror Domain" button)
|
||||
|
||||
### TASK-007b - Mirror Dashboard UI (domain list + status)
|
||||
Status: TODO
|
||||
Dependency: TASK-006, TASK-007a
|
||||
Owners: Developer (FE)
|
||||
|
||||
**File to create**: `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-dashboard.component.ts`
|
||||
|
||||
**Layout**:
|
||||
- **Top bar**: Mirror mode indicator (Direct/Mirror/Hybrid), "Create Domain" button, global mirror health summary
|
||||
- **Domain cards**: One card per configured domain, showing:
|
||||
- Domain name + ID
|
||||
- Export count + format
|
||||
- Source count + categories (pills)
|
||||
- Last generated timestamp + bundle size
|
||||
- Staleness indicator (fresh / stale / never generated)
|
||||
- Actions: "Regenerate", "Edit", "Delete", "View Endpoints"
|
||||
- **Consumer config panel**: If mode is Hybrid or Mirror, show the consumer mirror URL, connection status, last sync
|
||||
- **Empty state**: If no domains configured, show "Create your first mirror domain" CTA leading to wizard
|
||||
|
||||
**Route**: `advisory-vex-sources/mirror` (lazy-loaded, linked from catalog component header)
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Domain list with status cards renders
|
||||
- [ ] Regenerate button triggers bundle generation
|
||||
- [ ] Delete confirmation dialog
|
||||
- [ ] Link to wizard for new domain creation
|
||||
- [ ] Consumer config panel shows mirror connection status
|
||||
|
||||
### TASK-007c - Mirror Configuration in Source Catalog header
|
||||
Status: TODO
|
||||
Dependency: TASK-007a, TASK-007b
|
||||
Owners: Developer (FE)
|
||||
|
||||
Update `advisory-source-catalog.component.ts` to add mirror context:
|
||||
- Add "Mirror" tab/section in the catalog header alongside "Check All" button
|
||||
- Show current mirror mode badge (Direct / Mirror / Hybrid)
|
||||
- "Configure Mirror" link → navigates to mirror dashboard
|
||||
- "Create Mirror Domain" button → navigates to wizard with pre-selected sources (based on currently enabled sources in catalog)
|
||||
- If any mirror domains exist, show domain count + total bundle advisory count in stats bar
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Mirror mode badge in catalog header
|
||||
- [ ] "Configure Mirror" and "Create Mirror Domain" buttons
|
||||
- [ ] Pre-selection of enabled sources when launching wizard from catalog
|
||||
|
||||
### TASK-007 - Source Category Filter in Catalog UI
|
||||
Status: DONE
|
||||
Dependency: TASK-003 (new categories)
|
||||
Owners: Developer (FE)
|
||||
|
||||
Update `advisory-source-catalog.component.ts` to handle the new source categories:
|
||||
- Add `Exploit`, `Container`, `Hardware`, `Ics`, `PackageManager` to `CATEGORY_ORDER`
|
||||
- Update category section descriptions for new categories
|
||||
- Add category description text under each category header
|
||||
- Ensure the category filter dropdown includes all new categories
|
||||
|
||||
Completion criteria:
|
||||
- [x] All new categories render in the catalog (CATEGORY_ORDER expanded from 12 to 14 entries)
|
||||
- [x] Category filter dropdown updated (categoryOptions uses CATEGORY_ORDER)
|
||||
- [x] Source count stats updated for new categories (groupedByCategory computed handles all categories)
|
||||
- [x] CATEGORY_DESCRIPTIONS map added with descriptions for all 14 categories
|
||||
- [x] getCategoryDescription() method added and rendered in category section headers
|
||||
|
||||
### TASK-008 - Promote Russian/CIS connectors to stable
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
The FSTEC BDU (`Connector.Ru.Bdu`), NKCKI (`Connector.Ru.Nkcki`), and Kaspersky ICS-CERT (`Connector.Ics.Kaspersky`) connectors are marked as beta. Promote them:
|
||||
|
||||
1. Review connector implementations in `src/Concelier/plugins/concelier/`:
|
||||
- `Connector.Ru.Bdu` — FSTEC BDU JSON/XML parser
|
||||
- `Connector.Ru.Nkcki` — NKCKI feed parser
|
||||
- `Connector.Ics.Kaspersky` — Kaspersky ICS-CERT feed
|
||||
2. Ensure they have proper error handling, rate limiting, and retry logic
|
||||
3. Add them to the source catalog if not already present (with `SourceCategory.Cert` and regions `[RU, CIS]`)
|
||||
4. Update connector status from beta to stable
|
||||
5. Add `Astra Linux` to the Distribution category if not present
|
||||
|
||||
Completion criteria:
|
||||
- [ ] All 4 Russian/CIS connectors reviewed and promoted
|
||||
- [ ] Source catalog updated with RU/CIS entries (regions tagged)
|
||||
- [ ] Connectors handle network errors gracefully
|
||||
- [ ] Astra Linux source defined in catalog
|
||||
|
||||
### TASK-009 - Threat Intelligence Framework Sources
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Developer
|
||||
|
||||
Add threat intelligence framework references to the catalog:
|
||||
|
||||
| Source | ID | Category | Endpoint | Auth | Priority |
|
||||
|--------|----|----------|----------|------|----------|
|
||||
| MITRE ATT&CK | `mitre-attack` | Threat | `https://raw.githubusercontent.com/mitre/cti` | None | 140 |
|
||||
| MITRE D3FEND | `mitre-d3fend` | Threat | `https://d3fend.mitre.org/api` | None | 142 |
|
||||
|
||||
Note: STIX/TAXII feeds are a protocol, not a source — add as a connector type option rather than a fixed source. Add `SourceType.StixTaxii` to the SourceType enum for future TAXII-compatible feeds.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] 2 MITRE sources added to catalog
|
||||
- [ ] `SourceType.StixTaxii` enum value added for future extensibility
|
||||
- [ ] ATT&CK source tagged with `threat-intel` tag
|
||||
|
||||
### TASK-010 - Update Mirror Export to Include New Sources
|
||||
Status: DONE
|
||||
Dependency: TASK-001, TASK-002, TASK-003, TASK-004, TASK-009
|
||||
Owners: Developer (Backend)
|
||||
|
||||
Ensure the Excititor mirror export pipeline includes all newly added source categories. Update `MirrorDistributionOptions` defaults to include:
|
||||
- Exploit sources
|
||||
- Cloud provider advisories
|
||||
- Container/hardware advisories
|
||||
- ICS/SCADA advisories
|
||||
- Package manager native advisories
|
||||
- Additional CERTs
|
||||
- Threat intel frameworks
|
||||
|
||||
Changes implemented:
|
||||
1. Added `SupportedCategories` static list to `MirrorDistributionOptions` enumerating all 14 categories (including Ics, PackageManager)
|
||||
2. Extended `ResolveFilters` to support comma-separated `sourceCategory` values (e.g., `"Exploit,Container,Ics,PackageManager"`)
|
||||
3. Extended `ResolveFilters` to support comma-separated `sourceTag` values
|
||||
4. Added `Ics` and `PackageManager` to the `SourceCategory` enum in `SourceDefinitions.cs`
|
||||
5. Reclassified sources: Siemens and KasperskyIcs to `Ics`, RustSec/PyPa/GoVuln/BundlerAudit to `PackageManager`
|
||||
6. Updated XML docs with expanded filter documentation
|
||||
|
||||
Completion criteria:
|
||||
- [x] Mirror export includes all new source categories (SupportedCategories lists all 14)
|
||||
- [x] Bundle manifest reflects expanded source count (SourceDefinitions.All includes all sources with correct categories)
|
||||
- [x] Air-gap kit includes new source data (sourceCategory filter resolves Ics and PackageManager sources)
|
||||
- [x] Mirror consumer (Concelier connector) processes new categories correctly (ResolveFilters handles comma-separated multi-category values)
|
||||
|
||||
### TASK-011 - Documentation Update
|
||||
Status: TODO
|
||||
Dependency: TASK-001 through TASK-010
|
||||
Owners: Documentation author
|
||||
|
||||
Update module documentation:
|
||||
- `docs/modules/concelier/connectors.md` — add all new sources and connector status
|
||||
- `docs/modules/concelier/architecture.md` — update source count and category list
|
||||
- `docs/modules/excititor/mirrors.md` — document mirror config UI and new categories
|
||||
- `docs/operations/deployment/docker.md` — document mirror configuration env vars
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Connector index updated with all new sources
|
||||
- [ ] Mirror setup guide includes UI-based configuration
|
||||
- [ ] Source category taxonomy documented
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-15 | Sprint created with 11 tasks for full mirror source completeness. | Planning |
|
||||
| 2026-03-15 | Expanded to 16 tasks. Added TASK-005 (multi-value filter model), TASK-006 (domain CRUD API, 12 endpoints), TASK-006b (export scheduler), TASK-007a (domain builder wizard), TASK-007b (mirror dashboard), TASK-007c (catalog mirror header). Original TASK-005/006/007 were too generic — now they cover the full distro-to-mirror operator journey. | Planning |
|
||||
| 2026-03-15 | TASK-007 DONE: Updated CATEGORY_ORDER (12 -> 14 entries), added CATEGORY_DESCRIPTIONS map, added category descriptions to UI headers, added getCategoryDescription() method. TASK-010 DONE: Added SupportedCategories to MirrorDistributionOptions, extended ResolveFilters for comma-separated sourceCategory/sourceTag, added Ics and PackageManager to SourceCategory enum, reclassified 6 sources. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- **Commercial feeds** (Snyk, Sonatype OSS Index, VulnDB) intentionally excluded — they require paid API keys and license agreements incompatible with self-hosted offline-first posture. Can be added as `SourceType.Commercial` with clear license gates later.
|
||||
- **STIX/TAXII** is a protocol, not a source — adding as a SourceType for connector extensibility rather than as a fixed catalog entry. Actual TAXII server URLs would be configured per-deployment.
|
||||
- **Russian/CIS connector promotion** — BDU and NKCKI are beta due to endpoint instability. Promotion depends on verifying current API stability. Mark BLOCKED if endpoints are unreachable.
|
||||
- **Mirror config in DB vs env vars** — existing mirror config uses env vars only. TASK-006 proposes DB-backed config with API that merges with env vars (DB takes precedence). This is a design change — needs Concelier/Excititor AGENTS.md update.
|
||||
- **New SourceCategory enum values** — added `Exploit`, `Container`, `Hardware`, `Ics`, `PackageManager` to the enum (14 total). Siemens and KasperskyIcs reclassified from Hardware/Cert to Ics. RustSec, PyPa, GoVuln, BundlerAudit reclassified from Ecosystem to PackageManager. Frontend CATEGORY_ORDER and MirrorDistributionOptions.SupportedCategories updated. Keep `Other` as catch-all.
|
||||
- **Multi-value filter backward compat** (TASK-005) — existing single-value `Dictionary<string, string>` filters must continue working. The enhancement adds comma-separated OR semantics for `sourceVendor` and new shorthand keys (`sourceCategory`, `sourceTag`). Query signature normalization must be deterministic (sort values alphabetically).
|
||||
- **Export scheduler vs event-driven** (TASK-006b) — current model generates bundles reactively on export. Adding a scheduler introduces a new background service. For air-gap deployments where bundles are imported (not generated), the scheduler must be disableable. Default: enabled with 60-min refresh.
|
||||
- **Domain builder wizard complexity** (TASK-007a) — 3-step wizard with source selection, domain config, and review. If scope is too large, can split: Step 1 as MVP (category-level selection only, fixed JSON format), Steps 2-3 as follow-up.
|
||||
|
||||
## Next Checkpoints
|
||||
- **Checkpoint 1**: TASK-001 through TASK-004 — Catalog expansion complete, 47 → ~70 sources
|
||||
- **Checkpoint 2**: TASK-005 — Multi-value filter model working (enables "sourceCategory=Distribution" shorthand)
|
||||
- **Checkpoint 3**: TASK-006 + TASK-006b — Mirror domain CRUD API + scheduled refresh operational
|
||||
- **Checkpoint 4**: TASK-007a — Domain builder wizard functional (operator can select distros → create mirror domain → generate bundle from UI)
|
||||
- **Checkpoint 5**: TASK-007b + TASK-007c — Mirror dashboard and catalog integration complete
|
||||
- **Checkpoint 6**: TASK-008 — Russian/CIS connectors promoted to stable
|
||||
- **Checkpoint 7**: TASK-010 — Full mirror bundle includes all sources
|
||||
- **Checkpoint 8**: TASK-011 — Docs updated
|
||||
@@ -8,6 +8,30 @@ public sealed class MirrorDistributionOptions
|
||||
{
|
||||
public const string SectionName = "Excititor:Mirror";
|
||||
|
||||
/// <summary>
|
||||
/// All source categories recognized by the mirror export system. This list must stay
|
||||
/// in sync with <c>SourceCategory</c> in Concelier.Core. Used by the
|
||||
/// <c>sourceCategory</c> filter shorthand in <see cref="MirrorExportOptions.ResolveFilters"/>.
|
||||
/// Operators can specify one or more comma-separated values from this set.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> SupportedCategories = new[]
|
||||
{
|
||||
"Primary",
|
||||
"Vendor",
|
||||
"Distribution",
|
||||
"Ecosystem",
|
||||
"Cert",
|
||||
"Csaf",
|
||||
"Threat",
|
||||
"Exploit",
|
||||
"Container",
|
||||
"Hardware",
|
||||
"Ics",
|
||||
"PackageManager",
|
||||
"Mirror",
|
||||
"Other",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Global enable flag for mirror distribution surfaces and bundle generation.
|
||||
/// </summary>
|
||||
@@ -94,6 +118,12 @@ public sealed class MirrorExportOptions
|
||||
/// into normalized multi-value lists. Source definitions are required for resolving
|
||||
/// <c>sourceCategory</c> and <c>sourceTag</c> shorthands; pass <c>null</c> when
|
||||
/// category/tag expansion is not needed.
|
||||
/// <para>
|
||||
/// Both <c>sourceCategory</c> and <c>sourceTag</c> accept comma-separated values,
|
||||
/// e.g. <c>"Exploit,Container,Ics,PackageManager"</c>. All matching source IDs are
|
||||
/// merged into the resolved <c>sourceVendor</c> list.
|
||||
/// See <see cref="MirrorDistributionOptions.SupportedCategories"/> for valid category names.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="sourceDefinitions">
|
||||
/// Optional catalog of source definitions used to resolve <c>sourceCategory</c> and
|
||||
@@ -116,9 +146,13 @@ public sealed class MirrorExportOptions
|
||||
|
||||
if (key.Equals("sourceCategory", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null)
|
||||
{
|
||||
// Resolve category to source IDs
|
||||
// Resolve one or more comma-separated categories to source IDs.
|
||||
// Supports both single ("Exploit") and multi-value ("Exploit,Container,Ics,PackageManager").
|
||||
var categories = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var categorySet = new HashSet<string>(categories, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchingIds = sourceDefinitions
|
||||
.Where(s => s.Category.Equals(value, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(s => categorySet.Contains(s.Category))
|
||||
.Select(s => s.Id)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
@@ -130,9 +164,12 @@ public sealed class MirrorExportOptions
|
||||
}
|
||||
else if (key.Equals("sourceTag", StringComparison.OrdinalIgnoreCase) && sourceDefinitions is not null)
|
||||
{
|
||||
// Resolve tag to source IDs
|
||||
// Resolve one or more comma-separated tags to source IDs.
|
||||
var tags = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var tagSet = new HashSet<string>(tags, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchingIds = sourceDefinitions
|
||||
.Where(s => s.Tags.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
.Where(s => s.Tags.Any(t => tagSet.Contains(t)))
|
||||
.Select(s => s.Id)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Advisory & VEX Source Management — E2E Tests
|
||||
*
|
||||
* Verifies the source catalog UI, enable/disable toggles, connectivity checks,
|
||||
* onboarding hub section, and security page health display.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: mock API responses for deterministic E2E
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_CATALOG = {
|
||||
items: [
|
||||
{ id: 'nvd', displayName: 'NVD', category: 'Primary', type: 'Upstream', description: 'NIST National Vulnerability Database', baseEndpoint: 'https://services.nvd.nist.gov', requiresAuth: true, credentialEnvVar: 'NVD_API_KEY', credentialUrl: 'https://nvd.nist.gov/developers/request-an-api-key', documentationUrl: 'https://nvd.nist.gov/developers', defaultPriority: 10, regions: [], tags: ['cve'], enabledByDefault: true },
|
||||
{ id: 'osv', displayName: 'OSV', category: 'Primary', type: 'Upstream', description: 'Open Source Vulnerabilities', baseEndpoint: 'https://api.osv.dev', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: 'https://osv.dev', defaultPriority: 20, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'ghsa', displayName: 'GitHub Security Advisories', category: 'Primary', type: 'Upstream', description: 'GitHub Advisory Database', baseEndpoint: 'https://api.github.com', requiresAuth: true, credentialEnvVar: 'GITHUB_TOKEN', credentialUrl: null, documentationUrl: null, defaultPriority: 30, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'redhat', displayName: 'Red Hat Security', category: 'Vendor', type: 'Upstream', description: 'Red Hat Product Security advisories', baseEndpoint: 'https://access.redhat.com', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 50, regions: [], tags: ['vendor'], enabledByDefault: true },
|
||||
{ id: 'debian', displayName: 'Debian Security Tracker', category: 'Distribution', type: 'Upstream', description: 'Debian Security Bug Tracker', baseEndpoint: 'https://security-tracker.debian.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 60, regions: [], tags: ['distro'], enabledByDefault: true },
|
||||
{ id: 'ubuntu', displayName: 'Ubuntu Security', category: 'Distribution', type: 'Upstream', description: 'Ubuntu CVE Tracker', baseEndpoint: 'https://ubuntu.com/security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 61, regions: [], tags: ['distro'], enabledByDefault: false },
|
||||
{ id: 'npm-audit', displayName: 'npm Audit', category: 'Ecosystem', type: 'Upstream', description: 'npm advisory feed', baseEndpoint: 'https://registry.npmjs.org', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 70, regions: [], tags: ['ecosystem'], enabledByDefault: true },
|
||||
{ id: 'certfr', displayName: 'CERT-FR', category: 'Cert', type: 'Upstream', description: 'French national CERT advisories', baseEndpoint: 'https://www.cert.ssi.gouv.fr', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 80, regions: ['eu'], tags: ['cert'], enabledByDefault: false },
|
||||
{ id: 'csaf-aggregator', displayName: 'CSAF Trusted Provider', category: 'Csaf', type: 'Upstream', description: 'OASIS CSAF trusted provider', baseEndpoint: 'https://csaf.data.security', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 90, regions: [], tags: ['csaf'], enabledByDefault: true },
|
||||
{ id: 'kev', displayName: 'CISA KEV', category: 'Threat', type: 'Upstream', description: 'Known Exploited Vulnerabilities catalog', baseEndpoint: 'https://www.cisa.gov', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 15, regions: ['us'], tags: ['exploit'], enabledByDefault: true },
|
||||
{ id: 'stella-mirror', displayName: 'StellaOps Mirror', category: 'Mirror', type: 'Mirror', description: 'Local offline mirror', baseEndpoint: 'http://mirror.local', requiresAuth: false, credentialEnvVar: null, credentialUrl: null, documentationUrl: null, defaultPriority: 100, regions: [], tags: ['mirror'], enabledByDefault: false },
|
||||
],
|
||||
totalCount: 11,
|
||||
};
|
||||
|
||||
const MOCK_STATUS = {
|
||||
sources: MOCK_CATALOG.items.map((s) => ({
|
||||
sourceId: s.id,
|
||||
enabled: s.enabledByDefault,
|
||||
lastCheck: s.enabledByDefault
|
||||
? { sourceId: s.id, status: 'Healthy', checkedAt: new Date().toISOString(), latency: '00:00:00.1500000', isHealthy: true, possibleReasons: [], remediationSteps: [] }
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const MOCK_ADVISORY_SOURCES = {
|
||||
items: [
|
||||
{ sourceId: 'aaa-1111', sourceKey: 'nvd', sourceName: 'NVD', sourceFamily: 'primary', sourceUrl: null, priority: 10, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 600, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 42, errorCount: 0, totalAdvisories: 1000, signedAdvisories: 0, unsignedAdvisories: 1000, signatureFailureCount: 0 },
|
||||
{ sourceId: 'bbb-2222', sourceKey: 'osv', sourceName: 'OSV', sourceFamily: 'primary', sourceUrl: null, priority: 20, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 1200, freshnessSlaSeconds: 3600, freshnessStatus: 'healthy', signatureStatus: 'unsigned', lastError: null, syncCount: 30, errorCount: 0, totalAdvisories: 500, signedAdvisories: 0, unsignedAdvisories: 500, signatureFailureCount: 0 },
|
||||
{ sourceId: 'ccc-3333', sourceKey: 'ghsa', sourceName: 'GitHub Advisory DB', sourceFamily: 'primary', sourceUrl: null, priority: 30, enabled: true, lastSyncAt: new Date().toISOString(), lastSuccessAt: new Date().toISOString(), freshnessAgeSeconds: 7200, freshnessSlaSeconds: 3600, freshnessStatus: 'warning', signatureStatus: 'unsigned', lastError: null, syncCount: 10, errorCount: 1, totalAdvisories: 200, signedAdvisories: 0, unsignedAdvisories: 200, signatureFailureCount: 0 },
|
||||
],
|
||||
totalCount: 3,
|
||||
dataAsOf: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function setupSourceApiMocks(page: import('@playwright/test').Page) {
|
||||
// Source management API mocks
|
||||
page.route('**/api/v1/sources/catalog', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CATALOG) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/status', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/enable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/disable', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalChecked: 11, healthyCount: 8, failedCount: 3 }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const url = route.request().url();
|
||||
const sourceId = url.split('/sources/')[1]?.split('/check')[0] ?? 'unknown';
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId,
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.0850000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/*/check-result', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
sourceId: 'nvd',
|
||||
status: 'Healthy',
|
||||
checkedAt: new Date().toISOString(),
|
||||
latency: '00:00:00.1000000',
|
||||
isHealthy: true,
|
||||
possibleReasons: [],
|
||||
remediationSteps: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-enable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'ubuntu', success: true }, { sourceId: 'certfr', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
page.route('**/api/v1/sources/batch-disable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ results: [{ sourceId: 'nvd', success: true }] }),
|
||||
});
|
||||
});
|
||||
|
||||
// Advisory sources API mock (for security page)
|
||||
page.route('**/api/v1/advisory-sources?*', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ADVISORY_SOURCES) });
|
||||
});
|
||||
|
||||
page.route('**/api/v1/advisory-sources/summary', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalSources: 3, healthySources: 2, warningSources: 1, staleSources: 0, unavailableSources: 0, disabledSources: 0, conflictingSources: 0, dataAsOf: new Date().toISOString() }),
|
||||
});
|
||||
});
|
||||
|
||||
// Integration service provider catalog
|
||||
page.route('**/api/v1/integrations/providers', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ provider: 'Harbor', name: 'Harbor', type: 'Registry' },
|
||||
{ provider: 'GitHubApp', name: 'GitHub App', type: 'Scm' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all for other security/integration endpoints
|
||||
page.route('**/api/v2/security/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], table: [] }) });
|
||||
});
|
||||
|
||||
page.route('**/api/v2/integrations/**', (route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
}
|
||||
|
||||
function setupErrorCollector(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
|
||||
errors.push(text);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Source catalog renders with categories and source rows
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Advisory Source Catalog', () => {
|
||||
test('renders catalog with category sections and source rows', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify page heading
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toContain('advisory');
|
||||
|
||||
// Verify category sections exist
|
||||
for (const category of ['Primary', 'Vendor', 'Distribution', 'Ecosystem', 'Cert', 'Csaf', 'Threat', 'Mirror']) {
|
||||
const categoryText = await page.locator('body').innerText();
|
||||
expect(categoryText).toContain(category);
|
||||
}
|
||||
|
||||
// Verify source names render
|
||||
expect(body).toContain('NVD');
|
||||
expect(body).toContain('OSV');
|
||||
expect(body).toContain('GitHub Security Advisories');
|
||||
expect(body).toContain('Red Hat Security');
|
||||
|
||||
// Verify stats bar shows counts
|
||||
expect(body).toMatch(/\d+\s*(enabled|healthy|failed)/i);
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('enable/disable toggle updates source state', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a toggle/button for a disabled source (ubuntu)
|
||||
const ubuntuRow = page.locator('text=Ubuntu Security').locator('..');
|
||||
await expect(ubuntuRow).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Look for a toggle or enable button in the row
|
||||
const toggle = ubuntuRow.locator('button, input[type="checkbox"], .toggle').first();
|
||||
if (await toggle.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('check button shows connectivity result', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find a "Check" button on a source row
|
||||
const checkBtn = page.locator('button').filter({ hasText: /check/i }).first();
|
||||
if (await checkBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify status badge appears after check
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.toLowerCase()).toMatch(/healthy|degraded|failed|unchecked/);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Check All button triggers bulk connectivity check', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const checkAllBtn = page.locator('button').filter({ hasText: /check all/i }).first();
|
||||
if (await checkAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await checkAllBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Enable All for a category enables all sources in that category', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/ops/integrations/advisory-vex-sources', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find an "Enable All" button within a category section
|
||||
const enableAllBtn = page.locator('button').filter({ hasText: /enable all/i }).first();
|
||||
if (await enableAllBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await enableAllBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Onboarding hub shows Advisory & VEX Sources section
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Onboarding Hub - Advisory Sources', () => {
|
||||
test('shows Advisory & VEX Sources section with Configure button', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/setup/integrations/onboarding', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toContain('Advisory & VEX Sources');
|
||||
|
||||
// Verify the "Configure Sources" button exists
|
||||
const configureBtn = page.locator('button').filter({ hasText: /configure sources/i });
|
||||
await expect(configureBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click it and verify navigation to catalog
|
||||
await configureBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
expect(page.url()).toContain('advisory-vex-sources');
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Security page shows real source names with status
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Security Page - Advisory Health', () => {
|
||||
test('Advisories & VEX Health shows real source names', async ({ authenticatedPage: page }) => {
|
||||
const ngErrors = setupErrorCollector(page);
|
||||
await setupSourceApiMocks(page);
|
||||
|
||||
await navigateAndWait(page, '/security', { timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
|
||||
// Verify the section exists
|
||||
expect(body).toContain('Advisories & VEX Health');
|
||||
|
||||
// Verify real source names from mock (not "offline - unknown")
|
||||
const healthSection = page.locator('text=Advisories & VEX Health').locator('..').locator('..');
|
||||
const healthText = await healthSection.innerText().catch(() => body);
|
||||
|
||||
// Should contain real source names from the advisory-sources API
|
||||
const hasRealSources = healthText.includes('NVD') || healthText.includes('OSV') || healthText.includes('GitHub');
|
||||
const hasOfflineUnknown = (healthText.match(/offline.*unknown/gi) || []).length;
|
||||
|
||||
// We expect real sources OR at least no "offline - unknown" for all entries
|
||||
expect(hasRealSources || hasOfflineUnknown === 0).toBeTruthy();
|
||||
|
||||
expect(ngErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* Integration Hub Routes
|
||||
* Updated: SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck
|
||||
* Updated: Sprint 023 - Registry admin mounted under integrations (FE-URG-002)
|
||||
* Updated: Sprint 007 - Mirror dashboard route (TASK-007b)
|
||||
*
|
||||
* Canonical Integrations taxonomy:
|
||||
* '' - Hub overview with health summary and category navigation
|
||||
@@ -82,9 +83,23 @@ export const integrationHubRoutes: Routes = [
|
||||
{
|
||||
path: 'advisory-vex-sources',
|
||||
title: 'Advisory & VEX Sources',
|
||||
data: { breadcrumb: 'Advisory & VEX Sources', type: 'FeedMirror' },
|
||||
data: { breadcrumb: 'Advisory & VEX Sources' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
import('../integrations/advisory-vex-sources/advisory-source-catalog.component').then((m) => m.AdvisorySourceCatalogComponent),
|
||||
},
|
||||
{
|
||||
path: 'advisory-vex-sources/mirror',
|
||||
title: 'Mirror Dashboard',
|
||||
data: { breadcrumb: 'Mirror Dashboard' },
|
||||
loadComponent: () =>
|
||||
import('../integrations/advisory-vex-sources/mirror-dashboard.component').then((m) => m.MirrorDashboardComponent),
|
||||
},
|
||||
{
|
||||
path: 'advisory-vex-sources/mirror/new',
|
||||
title: 'Create Mirror Domain',
|
||||
data: { breadcrumb: 'Create Mirror Domain' },
|
||||
loadComponent: () =>
|
||||
import('../integrations/advisory-vex-sources/mirror-domain-builder.component').then((m) => m.MirrorDomainBuilderComponent),
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,785 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
MirrorManagementApi,
|
||||
MirrorConfigResponse,
|
||||
MirrorDomainResponse,
|
||||
MirrorDomainEndpointDto,
|
||||
MirrorHealthSummary,
|
||||
} from './mirror-management.api';
|
||||
|
||||
// ── Local helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function domainStaleness(domain: MirrorDomainResponse): 'fresh' | 'stale' | 'never' {
|
||||
if (!domain.createdAt) return 'never';
|
||||
const age = Date.now() - new Date(domain.createdAt).getTime();
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
return age > oneDay ? 'stale' : 'fresh';
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Component({
|
||||
selector: 'app-mirror-dashboard',
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mirror-dashboard">
|
||||
<!-- Top bar -->
|
||||
<header class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h1>Mirror Dashboard</h1>
|
||||
<p>Manage advisory mirror domains, monitor health, and control distribution.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@if (config()) {
|
||||
<span class="mode-badge" [class]="'mode-badge--' + config()!.mode.toLowerCase()">
|
||||
{{ config()!.mode }}
|
||||
</span>
|
||||
}
|
||||
<button class="btn btn-primary" type="button" (click)="onCreateDomain()">
|
||||
Create Domain
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="banner">Loading mirror configuration...</div>
|
||||
} @else {
|
||||
<!-- Health summary -->
|
||||
@if (health()) {
|
||||
<div class="health-bar">
|
||||
<span class="health-stat">
|
||||
<strong>{{ health()!.totalDomains }}</strong> domains
|
||||
</span>
|
||||
<span class="health-stat health-stat--fresh">
|
||||
<strong>{{ health()!.freshCount }}</strong> fresh
|
||||
</span>
|
||||
<span class="health-stat health-stat--stale">
|
||||
<strong>{{ health()!.staleCount }}</strong> stale
|
||||
</span>
|
||||
<span class="health-stat health-stat--never">
|
||||
<strong>{{ health()!.neverGeneratedCount }}</strong> never generated
|
||||
</span>
|
||||
<span class="health-stat">
|
||||
<strong>{{ health()!.totalAdvisoryCount }}</strong> advisories in bundles
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Consumer config panel (Hybrid/Mirror only) -->
|
||||
@if (showConsumerPanel()) {
|
||||
<section class="consumer-panel">
|
||||
<h3>Consumer Mirror Connection</h3>
|
||||
<div class="consumer-grid">
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Mirror URL</span>
|
||||
<span class="consumer-value code">{{ config()!.consumerMirrorUrl ?? 'Not configured' }}</span>
|
||||
</div>
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Connection Status</span>
|
||||
<span class="consumer-value"
|
||||
[class]="config()!.consumerConnected ? 'consumer-value status--connected' : 'consumer-value status--disconnected'">
|
||||
{{ config()!.consumerConnected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (config()!.lastConsumerSync) {
|
||||
<div class="consumer-field">
|
||||
<span class="consumer-label">Last Sync</span>
|
||||
<span class="consumer-value">{{ config()!.lastConsumerSync }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Domain cards -->
|
||||
@if (domains().length === 0) {
|
||||
<section class="empty-state">
|
||||
<div class="empty-icon">⚙</div>
|
||||
<h2>Create your first mirror domain</h2>
|
||||
<p>Mirror domains bundle advisory data from enabled sources for offline distribution or downstream consumption.</p>
|
||||
<button class="btn btn-primary" type="button" (click)="onCreateDomain()">
|
||||
Create Mirror Domain
|
||||
</button>
|
||||
</section>
|
||||
} @else {
|
||||
<div class="domain-grid">
|
||||
@for (domain of domains(); track domain.id) {
|
||||
<div class="domain-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-group">
|
||||
<h3 class="card-title">{{ domain.displayName }}</h3>
|
||||
<span class="card-id">{{ domain.domainId }}</span>
|
||||
</div>
|
||||
<span class="staleness-badge" [class]="'staleness-badge staleness--' + getDomainStaleness(domain)">
|
||||
{{ getDomainStaleness(domain) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-stats">
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Exports</span>
|
||||
<span class="card-stat-value">{{ domain.sourceIds.length }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Format</span>
|
||||
<span class="card-stat-value">{{ domain.exportFormat }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Sources</span>
|
||||
<span class="card-stat-value">{{ domain.sourceIds.length }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Created</span>
|
||||
<span class="card-stat-value">{{ formatTimestamp(domain.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="card-stat">
|
||||
<span class="card-stat-label">Status</span>
|
||||
<span class="card-stat-value">{{ domain.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (domain.sourceIds.length > 0) {
|
||||
<div class="card-sources">
|
||||
@for (sid of domain.sourceIds.slice(0, 5); track sid) {
|
||||
<span class="source-pill">{{ sid }}</span>
|
||||
}
|
||||
@if (domain.sourceIds.length > 5) {
|
||||
<span class="source-pill source-pill--more">+{{ domain.sourceIds.length - 5 }} more</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Endpoints panel (expandable) -->
|
||||
@if (expandedEndpoints() === domain.id) {
|
||||
<div class="card-endpoints">
|
||||
<h4>Endpoints</h4>
|
||||
@if (domainEndpoints().length === 0) {
|
||||
<p class="endpoints-empty">Loading endpoints...</p>
|
||||
} @else {
|
||||
@for (ep of domainEndpoints(); track ep.path) {
|
||||
<div class="endpoint-row">
|
||||
<span class="endpoint-method">{{ ep.method }}</span>
|
||||
<span class="endpoint-path code">{{ ep.path }}</span>
|
||||
<span class="endpoint-desc">{{ ep.description }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-sm btn-primary" type="button"
|
||||
[disabled]="regeneratingId() === domain.id"
|
||||
(click)="onRegenerate(domain.id)">
|
||||
@if (regeneratingId() === domain.id) {
|
||||
Regenerating...
|
||||
} @else {
|
||||
Regenerate
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm" type="button" (click)="onViewEndpoints(domain.id)">
|
||||
@if (expandedEndpoints() === domain.id) {
|
||||
Hide Endpoints
|
||||
} @else {
|
||||
View Endpoints
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm" type="button" (click)="onEditDomain(domain.id)">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button"
|
||||
[disabled]="deletingId() === domain.id"
|
||||
(click)="onDeleteDomain(domain.id, domain.displayName)">
|
||||
@if (deletingId() === domain.id) {
|
||||
Deleting...
|
||||
} @else {
|
||||
Delete
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.mirror-dashboard {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* -- Header ----------------------------------------------------------- */
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mode-badge--direct {
|
||||
color: #22c55e;
|
||||
border-color: #22c55e40;
|
||||
background: #22c55e10;
|
||||
}
|
||||
|
||||
.mode-badge--mirror {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f640;
|
||||
background: #3b82f610;
|
||||
}
|
||||
|
||||
.mode-badge--hybrid {
|
||||
color: #a855f7;
|
||||
border-color: #a855f740;
|
||||
background: #a855f710;
|
||||
}
|
||||
|
||||
/* -- Banner / Loading ------------------------------------------------- */
|
||||
|
||||
.banner {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* -- Health Bar -------------------------------------------------------- */
|
||||
|
||||
.health-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-stat strong {
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.health-stat--fresh strong {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.health-stat--stale strong {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.health-stat--never strong {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* -- Consumer Panel --------------------------------------------------- */
|
||||
|
||||
.consumer-panel {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.consumer-panel h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.consumer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.consumer-field {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.consumer-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.consumer-value {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.consumer-value.code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status--connected {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status--disconnected {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* -- Empty State ------------------------------------------------------ */
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* -- Domain Grid ------------------------------------------------------ */
|
||||
|
||||
.domain-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.domain-card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
padding: 1rem 1.25rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-title-group {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.staleness-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: lowercase;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.staleness--fresh {
|
||||
color: #22c55e;
|
||||
border-color: #22c55e40;
|
||||
}
|
||||
|
||||
.staleness--stale {
|
||||
color: #eab308;
|
||||
border-color: #eab30840;
|
||||
}
|
||||
|
||||
.staleness--never {
|
||||
color: #9ca3af;
|
||||
border-color: #9ca3af40;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-stat {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.card-stat-label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-stat-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.card-sources {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 1.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.source-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-size: 0.68rem;
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-pill--more {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* -- Endpoints Panel -------------------------------------------------- */
|
||||
|
||||
.card-endpoints {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-endpoints h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.endpoints-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.endpoint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.endpoint-method {
|
||||
display: inline-flex;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.65rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-heading);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.endpoint-desc {
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* -- Card Actions ----------------------------------------------------- */
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
/* -- Buttons ---------------------------------------------------------- */
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-surface-tertiary, #374151);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-sm.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef444420;
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef444440;
|
||||
}
|
||||
|
||||
/* -- Responsive ------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.consumer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class MirrorDashboardComponent implements OnInit {
|
||||
private readonly api = inject(MirrorManagementApi);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly config = signal<MirrorConfigResponse | null>(null);
|
||||
readonly domains = signal<MirrorDomainResponse[]>([]);
|
||||
readonly health = signal<MirrorHealthSummary | null>(null);
|
||||
readonly regeneratingId = signal<string | null>(null);
|
||||
readonly deletingId = signal<string | null>(null);
|
||||
readonly expandedEndpoints = signal<string | null>(null);
|
||||
readonly domainEndpoints = signal<MirrorDomainEndpointDto[]>([]);
|
||||
|
||||
readonly showConsumerPanel = computed(() => {
|
||||
const cfg = this.config();
|
||||
return cfg != null && (cfg.mode === 'Hybrid' || cfg.mode === 'Mirror');
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
onCreateDomain(): void {
|
||||
this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', 'create']);
|
||||
}
|
||||
|
||||
onRegenerate(domainId: string): void {
|
||||
this.regeneratingId.set(domainId);
|
||||
|
||||
this.api.generateDomain(domainId).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.regeneratingId.set(null);
|
||||
this.reloadDomains();
|
||||
},
|
||||
error: () => {
|
||||
this.regeneratingId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onEditDomain(domainId: string): void {
|
||||
this.router.navigate(['/integrations', 'advisory-vex-sources', 'mirror', domainId, 'edit']);
|
||||
}
|
||||
|
||||
onDeleteDomain(domainId: string, displayName: string): void {
|
||||
if (!confirm('Delete mirror domain "' + displayName + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deletingId.set(domainId);
|
||||
|
||||
this.api.deleteDomain(domainId).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.deletingId.set(null);
|
||||
this.domains.update((current) => current.filter((d) => d.id !== domainId));
|
||||
this.reloadHealth();
|
||||
},
|
||||
error: () => {
|
||||
this.deletingId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onViewEndpoints(domainId: string): void {
|
||||
if (this.expandedEndpoints() === domainId) {
|
||||
this.expandedEndpoints.set(null);
|
||||
this.domainEndpoints.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.expandedEndpoints.set(domainId);
|
||||
this.domainEndpoints.set([]);
|
||||
|
||||
this.api.getDomainEndpoints(domainId).pipe(take(1)).subscribe({
|
||||
next: (response) => {
|
||||
this.domainEndpoints.set(response.endpoints ?? []);
|
||||
},
|
||||
error: () => {
|
||||
this.domainEndpoints.set([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDomainStaleness(domain: MirrorDomainResponse): string {
|
||||
return domainStaleness(domain);
|
||||
}
|
||||
|
||||
formatTimestamp(ts: string | null | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
try {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
forkJoin({
|
||||
config: this.api.getConfig().pipe(take(1)),
|
||||
domains: this.api.listDomains().pipe(take(1)),
|
||||
health: this.api.getHealthSummary().pipe(take(1)),
|
||||
}).subscribe({
|
||||
next: ({ config, domains, health }) => {
|
||||
this.config.set(config);
|
||||
this.domains.set(domains.domains ?? []);
|
||||
this.health.set(health);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reloadDomains(): void {
|
||||
this.api.listDomains().pipe(take(1)).subscribe({
|
||||
next: (response) => {
|
||||
this.domains.set(response.domains ?? []);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reloadHealth(): void {
|
||||
this.api.getHealthSummary().pipe(take(1)).subscribe({
|
||||
next: (summary) => {
|
||||
this.health.set(summary);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MirrorMode = 'Direct' | 'Mirror' | 'Hybrid';
|
||||
|
||||
export interface MirrorDomainSourceRef {
|
||||
sourceId: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainRateLimits {
|
||||
indexRequestsPerHour: number;
|
||||
downloadRequestsPerHour: number;
|
||||
}
|
||||
|
||||
export interface MirrorDomainSigning {
|
||||
enabled: boolean;
|
||||
algorithm: string;
|
||||
keyId: string;
|
||||
}
|
||||
|
||||
export interface CreateMirrorDomainRequest {
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
}
|
||||
|
||||
export interface MirrorDomainResponse {
|
||||
id: string;
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
domainUrl: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainGenerateResponse {
|
||||
domainId: string;
|
||||
jobId: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainConfigResponse {
|
||||
domainId: string;
|
||||
displayName: string;
|
||||
sourceIds: string[];
|
||||
exportFormat: string;
|
||||
rateLimits: MirrorDomainRateLimits;
|
||||
requireAuthentication: boolean;
|
||||
signing: MirrorDomainSigning;
|
||||
resolvedFilter: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MirrorDomainListResponse {
|
||||
domains: MirrorDomainResponse[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface MirrorConfigResponse {
|
||||
mode: MirrorMode;
|
||||
consumerMirrorUrl: string | null;
|
||||
consumerConnected: boolean;
|
||||
lastConsumerSync: string | null;
|
||||
}
|
||||
|
||||
export interface MirrorHealthSummary {
|
||||
totalDomains: number;
|
||||
freshCount: number;
|
||||
staleCount: number;
|
||||
neverGeneratedCount: number;
|
||||
totalAdvisoryCount: number;
|
||||
}
|
||||
|
||||
export interface MirrorDomainEndpointDto {
|
||||
path: string;
|
||||
method: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MirrorDomainEndpointsResponse {
|
||||
domainId: string;
|
||||
endpoints: MirrorDomainEndpointDto[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MirrorManagementApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = '/api/v1/mirror';
|
||||
|
||||
// ── Mirror Config ──────────────────────────────────────────────────────────
|
||||
|
||||
getConfig(): Observable<MirrorConfigResponse> {
|
||||
return this.http.get<MirrorConfigResponse>(`${this.baseUrl}/config`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getHealthSummary(): Observable<MirrorHealthSummary> {
|
||||
return this.http.get<MirrorHealthSummary>(`${this.baseUrl}/health`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Domains ────────────────────────────────────────────────────────────────
|
||||
|
||||
listDomains(): Observable<MirrorDomainListResponse> {
|
||||
return this.http.get<MirrorDomainListResponse>(`${this.baseUrl}/domains`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getDomain(domainId: string): Observable<MirrorDomainResponse> {
|
||||
return this.http.get<MirrorDomainResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
createDomain(request: CreateMirrorDomainRequest): Observable<MirrorDomainResponse> {
|
||||
return this.http.post<MirrorDomainResponse>(`${this.baseUrl}/domains`, request, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteDomain(domainId: string): Observable<void> {
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
generateDomain(domainId: string): Observable<MirrorDomainGenerateResponse> {
|
||||
return this.http.post<MirrorDomainGenerateResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/generate`,
|
||||
null,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
getDomainConfig(domainId: string): Observable<MirrorDomainConfigResponse> {
|
||||
return this.http.get<MirrorDomainConfigResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/config`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
getDomainEndpoints(domainId: string): Observable<MirrorDomainEndpointsResponse> {
|
||||
return this.http.get<MirrorDomainEndpointsResponse>(
|
||||
`${this.baseUrl}/domains/${encodeURIComponent(domainId)}/endpoints`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { StellaOpsHeaders } from '../../../core/http/stella-ops-headers';
|
||||
|
||||
export interface SourceCatalogItem {
|
||||
id: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
type: string;
|
||||
description: string;
|
||||
baseEndpoint: string;
|
||||
requiresAuth: boolean;
|
||||
credentialEnvVar?: string | null;
|
||||
credentialUrl?: string | null;
|
||||
documentationUrl?: string | null;
|
||||
defaultPriority: number;
|
||||
regions: string[];
|
||||
tags: string[];
|
||||
enabledByDefault: boolean;
|
||||
}
|
||||
|
||||
export interface SourceCatalogResponse {
|
||||
items: SourceCatalogItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface SourceStatusItem {
|
||||
sourceId: string;
|
||||
enabled: boolean;
|
||||
lastCheck?: SourceConnectivityResultDto | null;
|
||||
}
|
||||
|
||||
export interface SourceStatusResponse {
|
||||
sources: SourceStatusItem[];
|
||||
}
|
||||
|
||||
export interface SourceConnectivityResultDto {
|
||||
sourceId: string;
|
||||
status: string;
|
||||
checkedAt: string;
|
||||
latency?: string | null;
|
||||
errorMessage?: string | null;
|
||||
errorCode?: string | null;
|
||||
httpStatusCode?: number | null;
|
||||
possibleReasons: string[];
|
||||
remediationSteps: RemediationStepDto[];
|
||||
isHealthy: boolean;
|
||||
}
|
||||
|
||||
export interface RemediationStepDto {
|
||||
order: number;
|
||||
description: string;
|
||||
command?: string | null;
|
||||
commandType: string;
|
||||
documentationUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchSourceRequest {
|
||||
sourceIds: string[];
|
||||
}
|
||||
|
||||
export interface BatchSourceResultItem {
|
||||
sourceId: string;
|
||||
success: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchSourceResponse {
|
||||
results: BatchSourceResultItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SourceManagementApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = '/api/v1/sources';
|
||||
|
||||
getCatalog(): Observable<SourceCatalogResponse> {
|
||||
return this.http.get<SourceCatalogResponse>(`${this.baseUrl}/catalog`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getStatus(): Observable<SourceStatusResponse> {
|
||||
return this.http.get<SourceStatusResponse>(`${this.baseUrl}/status`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
enableSource(sourceId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/${encodeURIComponent(sourceId)}/enable`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
disableSource(sourceId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/${encodeURIComponent(sourceId)}/disable`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
checkAll(): Observable<any> {
|
||||
return this.http.post<any>(`${this.baseUrl}/check`, null, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
checkSource(sourceId: string): Observable<SourceConnectivityResultDto> {
|
||||
return this.http.post<SourceConnectivityResultDto>(
|
||||
`${this.baseUrl}/${encodeURIComponent(sourceId)}/check`,
|
||||
null,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
batchEnable(sourceIds: string[]): Observable<BatchSourceResponse> {
|
||||
return this.http.post<BatchSourceResponse>(`${this.baseUrl}/batch-enable`, { sourceIds } as BatchSourceRequest, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
batchDisable(sourceIds: string[]): Observable<BatchSourceResponse> {
|
||||
return this.http.post<BatchSourceResponse>(`${this.baseUrl}/batch-disable`, { sourceIds } as BatchSourceRequest, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getCheckResult(sourceId: string): Observable<SourceConnectivityResultDto> {
|
||||
return this.http.get<SourceConnectivityResultDto>(
|
||||
`${this.baseUrl}/${encodeURIComponent(sourceId)}/check-result`,
|
||||
{ headers: this.buildHeaders() }
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
const tenantId = this.authSession.getActiveTenantId();
|
||||
if (!tenantId) {
|
||||
return new HttpHeaders();
|
||||
}
|
||||
|
||||
return new HttpHeaders({
|
||||
[StellaOpsHeaders.Tenant]: tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,19 @@ import {
|
||||
</div>
|
||||
<p class="category-empty">No runtime-host connector plugins are currently available.</p>
|
||||
</section>
|
||||
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>Advisory & VEX Sources</h2>
|
||||
<p class="category-desc">Browse, enable, and health-check the 47 upstream advisory and VEX data sources.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openAdvisorySourceCatalog()">
|
||||
Configure Sources
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-empty">Manage NVD, OSV, GHSA, vendor CERTs, CSAF feeds, and StellaOps mirrors from one catalog.</p>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@@ -282,6 +295,12 @@ export class IntegrationsHubComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
openAdvisorySourceCatalog(): void {
|
||||
void this.router.navigate(this.integrationCommands('advisory-vex-sources'), {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
openWizard(type: IntegrationOnboardingType): void {
|
||||
if (this.providersForType(type).length === 0) {
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SupportedProviderInfo,
|
||||
} from '../../integration-hub/integration.models';
|
||||
|
||||
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host';
|
||||
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host' | 'advisory-vex';
|
||||
export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review';
|
||||
|
||||
export interface ProviderField {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, take } from 'rxjs/operators';
|
||||
import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
|
||||
@@ -64,8 +65,8 @@ interface PlatformListResponse<T> {
|
||||
<section class="overview">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Security / Posture</h1>
|
||||
<p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p>
|
||||
<h1>Security Posture</h1>
|
||||
<p>Release-blocking posture, advisory freshness, and disposition confidence for the selected scope.</p>
|
||||
</div>
|
||||
<div class="scope">
|
||||
<span>Scope</span>
|
||||
@@ -275,6 +276,7 @@ interface PlatformListResponse<T> {
|
||||
})
|
||||
export class SecurityRiskOverviewComponent {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly advisorySourcesApi = inject(AdvisorySourcesApi);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
|
||||
readonly loading = signal(false);
|
||||
@@ -418,12 +420,18 @@ export class SecurityRiskOverviewComponent {
|
||||
const sbom$ = this.http
|
||||
.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params: params.set('mode', 'table') })
|
||||
.pipe(map((res) => res.table ?? []), catchError(() => of([] as SecuritySbomExplorerResponse['table'])));
|
||||
const feedHealth$ = this.http
|
||||
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/feeds', { params })
|
||||
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
|
||||
const vexHealth$ = this.http
|
||||
.get<PlatformListResponse<IntegrationHealthRow>>('/api/v2/integrations/vex-sources', { params })
|
||||
.pipe(map((res) => res.items ?? []), catchError(() => of([] as IntegrationHealthRow[])));
|
||||
const feedHealth$ = this.advisorySourcesApi.listSources(true).pipe(
|
||||
map((items: AdvisorySourceListItemDto[]) => items.map(item => ({
|
||||
sourceId: item.sourceKey,
|
||||
sourceName: item.sourceName,
|
||||
status: item.freshnessStatus,
|
||||
freshness: item.freshnessStatus === 'healthy' ? 'fresh' : item.freshnessStatus,
|
||||
freshnessMinutes: item.freshnessAgeSeconds > 0 ? Math.round(item.freshnessAgeSeconds / 60) : null,
|
||||
slaMinutes: Math.round(item.freshnessSlaSeconds / 60),
|
||||
} as IntegrationHealthRow))),
|
||||
catchError(() => of([] as IntegrationHealthRow[]))
|
||||
);
|
||||
const vexHealth$ = of([] as IntegrationHealthRow[]);
|
||||
|
||||
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ })
|
||||
.pipe(take(1))
|
||||
@@ -452,4 +460,3 @@ export class SecurityRiskOverviewComponent {
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user