Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -1,34 +1,34 @@
|
||||
# StellaOps Web Frontend
|
||||
|
||||
## Mission
|
||||
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
|
||||
|
||||
## Team Composition
|
||||
- **UX Specialist** – defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
|
||||
- **Angular Engineers** – implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
|
||||
|
||||
## Operating Principles
|
||||
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
|
||||
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
|
||||
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
|
||||
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
|
||||
|
||||
## Key Paths
|
||||
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
|
||||
- `docs/` — UX specs and mockups (to be added).
|
||||
- `ops/` — Web deployment manifests for air-gapped environments (future).
|
||||
|
||||
## Coordination
|
||||
- Sync with DevEx for project scaffolding and build pipelines.
|
||||
- Partner with Docs Guild to translate UX decisions into operator guides.
|
||||
- Collaborate with Security Guild to validate authentication flows and session handling.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
# StellaOps Web Frontend
|
||||
|
||||
## Mission
|
||||
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
|
||||
|
||||
## Team Composition
|
||||
- **UX Specialist** – defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
|
||||
- **Angular Engineers** – implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
|
||||
|
||||
## Operating Principles
|
||||
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
|
||||
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
|
||||
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
|
||||
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
|
||||
|
||||
## Key Paths
|
||||
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
|
||||
- `docs/` — UX specs and mockups (to be added).
|
||||
- `ops/` — Web deployment manifests for air-gapped environments (future).
|
||||
|
||||
## Coordination
|
||||
- Sync with DevEx for project scaffolding and build pipelines.
|
||||
- Partner with Docs Guild to translate UX decisions into operator guides.
|
||||
- Collaborate with Security Guild to validate authentication flows and session handling.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
|
||||
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
|
||||
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
|
||||
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
|
||||
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
# Deterministic Install & Headless Chromium
|
||||
|
||||
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **20.11.0** or newer (matches the `engines` constraint).
|
||||
- npm **10.2.0** or newer.
|
||||
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
|
||||
|
||||
## One-Time Cache Priming (Connected Host)
|
||||
|
||||
```bash
|
||||
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
|
||||
npm run ci:install
|
||||
```
|
||||
|
||||
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
|
||||
|
||||
If you plan to bundle a Chromium binary, download it while still connected:
|
||||
|
||||
```bash
|
||||
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
|
||||
```
|
||||
|
||||
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
|
||||
|
||||
## Offline Runner Execution
|
||||
|
||||
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
|
||||
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
|
||||
3. Run `npm run ci:install` to restore dependencies without network access.
|
||||
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
|
||||
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
|
||||
|
||||
## Chromium Options
|
||||
|
||||
- **System package** – Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
|
||||
- **Environment override** – Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
|
||||
- **Offline cache drop** – Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
|
||||
|
||||
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.
|
||||
# Deterministic Install & Headless Chromium
|
||||
|
||||
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js **20.11.0** or newer (matches the `engines` constraint).
|
||||
- npm **10.2.0** or newer.
|
||||
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
|
||||
|
||||
## One-Time Cache Priming (Connected Host)
|
||||
|
||||
```bash
|
||||
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
|
||||
npm run ci:install
|
||||
```
|
||||
|
||||
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
|
||||
|
||||
If you plan to bundle a Chromium binary, download it while still connected:
|
||||
|
||||
```bash
|
||||
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
|
||||
```
|
||||
|
||||
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
|
||||
|
||||
## Offline Runner Execution
|
||||
|
||||
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
|
||||
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
|
||||
3. Run `npm run ci:install` to restore dependencies without network access.
|
||||
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
|
||||
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
|
||||
|
||||
## Chromium Options
|
||||
|
||||
- **System package** – Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
|
||||
- **Environment override** – Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
|
||||
- **Offline cache drop** – Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
|
||||
|
||||
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
# Helm Readiness & Probes
|
||||
|
||||
This app serves static health endpoints for platform probes:
|
||||
|
||||
- `/assets/health/liveness.json`
|
||||
- `/assets/health/readiness.json`
|
||||
- `/assets/health/version.json`
|
||||
|
||||
These are packaged with the Angular build. Configure Helm/Nginx to route the probes directly to the web pod.
|
||||
|
||||
## Suggested Helm values
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /assets/health/liveness.json
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /assets/health/readiness.json
|
||||
port: http
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
- Edit the JSON under `src/assets/health/*.json` for environment-specific readiness details.
|
||||
- Run `npm run build` (or CI pipeline) to bake the files into the image.
|
||||
# Helm Readiness & Probes
|
||||
|
||||
This app serves static health endpoints for platform probes:
|
||||
|
||||
- `/assets/health/liveness.json`
|
||||
- `/assets/health/readiness.json`
|
||||
- `/assets/health/version.json`
|
||||
|
||||
These are packaged with the Angular build. Configure Helm/Nginx to route the probes directly to the web pod.
|
||||
|
||||
## Suggested Helm values
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /assets/health/liveness.json
|
||||
port: http
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /assets/health/readiness.json
|
||||
port: http
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
- Edit the JSON under `src/assets/health/*.json` for environment-specific readiness details.
|
||||
- Run `npm run build` (or CI pipeline) to bake the files into the image.
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
# WEB1.TRIVY-SETTINGS – Backend Contract & UI Wiring Notes
|
||||
|
||||
## 1. Known backend surfaces
|
||||
|
||||
- `POST /jobs/export:trivy-db`
|
||||
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
|
||||
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
|
||||
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
|
||||
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
|
||||
|
||||
## 2. Clarifications needed from Concelier backend
|
||||
|
||||
| Topic | Questions to resolve | Suggested owner |
|
||||
| --- | --- | --- |
|
||||
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
|
||||
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
|
||||
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
|
||||
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
|
||||
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
|
||||
|
||||
## 3. Proposed Angular implementation (pending contract lock)
|
||||
|
||||
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
|
||||
- **State & transport**:
|
||||
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
|
||||
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
|
||||
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
|
||||
- **UX flow**:
|
||||
- Load settings on navigation; show inline info about current publish/bundle defaults.
|
||||
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
|
||||
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
|
||||
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
|
||||
|
||||
## 4. Next steps
|
||||
|
||||
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
|
||||
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.
|
||||
# WEB1.TRIVY-SETTINGS – Backend Contract & UI Wiring Notes
|
||||
|
||||
## 1. Known backend surfaces
|
||||
|
||||
- `POST /jobs/export:trivy-db`
|
||||
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
|
||||
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
|
||||
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
|
||||
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
|
||||
|
||||
## 2. Clarifications needed from Concelier backend
|
||||
|
||||
| Topic | Questions to resolve | Suggested owner |
|
||||
| --- | --- | --- |
|
||||
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
|
||||
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
|
||||
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
|
||||
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
|
||||
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
|
||||
|
||||
## 3. Proposed Angular implementation (pending contract lock)
|
||||
|
||||
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
|
||||
- **State & transport**:
|
||||
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
|
||||
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
|
||||
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
|
||||
- **UX flow**:
|
||||
- Load settings on navigation; show inline info about current publish/bundle defaults.
|
||||
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
|
||||
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
|
||||
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
|
||||
|
||||
## 4. Next steps
|
||||
|
||||
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
|
||||
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'dashboard/sources',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/sources-dashboard.component').then(
|
||||
(m) => m.SourcesDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'console/profile',
|
||||
loadComponent: () =>
|
||||
@@ -36,62 +43,6 @@ export const routes: Routes = [
|
||||
(m) => m.NotifyPanelComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'exceptions',
|
||||
loadComponent: () =>
|
||||
import('./features/exceptions/exception-center.component').then(
|
||||
(m) => m.ExceptionCenterComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities',
|
||||
loadComponent: () =>
|
||||
import('./features/vulnerabilities/vulnerability-explorer.component').then(
|
||||
(m) => m.VulnerabilityExplorerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'graph',
|
||||
loadComponent: () =>
|
||||
import('./features/graph/graph-explorer.component').then(
|
||||
(m) => m.GraphExplorerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'evidence/:advisoryId',
|
||||
loadComponent: () =>
|
||||
import('./features/evidence/evidence-page.component').then(
|
||||
(m) => m.EvidencePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sources',
|
||||
loadComponent: () =>
|
||||
import('./features/sources/aoc-dashboard.component').then(
|
||||
(m) => m.AocDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sources/violations/:code',
|
||||
loadComponent: () =>
|
||||
import('./features/sources/violation-detail.component').then(
|
||||
(m) => m.ViolationDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
loadComponent: () =>
|
||||
import('./features/releases/release-flow.component').then(
|
||||
(m) => m.ReleaseFlowComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'releases/:releaseId',
|
||||
loadComponent: () =>
|
||||
import('./features/releases/release-flow.component').then(
|
||||
(m) => m.ReleaseFlowComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -1,364 +1,116 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
AocDashboardSummary,
|
||||
AocPassFailSummary,
|
||||
AocViolationCode,
|
||||
IngestThroughput,
|
||||
AocSource,
|
||||
AocCheckResult,
|
||||
VerificationRequest,
|
||||
ViolationDetail,
|
||||
TimeSeriesPoint,
|
||||
} from './aoc.models';
|
||||
|
||||
/**
|
||||
* Injection token for AOC API client.
|
||||
*/
|
||||
export const AOC_API = new InjectionToken<AocApi>('AOC_API');
|
||||
|
||||
/**
|
||||
* AOC API interface.
|
||||
*/
|
||||
export interface AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary>;
|
||||
getViolationDetail(violationId: string): Observable<ViolationDetail>;
|
||||
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]>;
|
||||
startVerification(): Observable<VerificationRequest>;
|
||||
getVerificationStatus(requestId: string): Observable<VerificationRequest>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
function generateHistory(days: number, baseValue: number, variance: number): TimeSeriesPoint[] {
|
||||
const points: TimeSeriesPoint[] = [];
|
||||
const now = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
points.push({
|
||||
timestamp: date.toISOString(),
|
||||
value: baseValue + Math.floor(Math.random() * variance * 2) - variance,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const mockPassFailSummary: AocPassFailSummary = {
|
||||
period: 'last_24h',
|
||||
totalChecks: 1247,
|
||||
passed: 1198,
|
||||
failed: 32,
|
||||
pending: 12,
|
||||
skipped: 5,
|
||||
passRate: 0.961,
|
||||
trend: 'improving',
|
||||
history: generateHistory(7, 96, 3),
|
||||
};
|
||||
|
||||
const mockViolationCodes: AocViolationCode[] = [
|
||||
{
|
||||
code: 'AOC-001',
|
||||
name: 'Missing Provenance',
|
||||
severity: 'critical',
|
||||
description: 'Document lacks required provenance attestation',
|
||||
count: 12,
|
||||
lastSeen: '2025-11-27T09:45:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-001',
|
||||
},
|
||||
{
|
||||
code: 'AOC-002',
|
||||
name: 'Invalid Signature',
|
||||
severity: 'critical',
|
||||
description: 'Document signature verification failed',
|
||||
count: 8,
|
||||
lastSeen: '2025-11-27T08:30:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-002',
|
||||
},
|
||||
{
|
||||
code: 'AOC-010',
|
||||
name: 'Schema Mismatch',
|
||||
severity: 'high',
|
||||
description: 'Document does not conform to expected schema version',
|
||||
count: 5,
|
||||
lastSeen: '2025-11-27T07:15:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-010',
|
||||
},
|
||||
{
|
||||
code: 'AOC-015',
|
||||
name: 'Timestamp Drift',
|
||||
severity: 'medium',
|
||||
description: 'Document timestamp exceeds allowed drift threshold',
|
||||
count: 4,
|
||||
lastSeen: '2025-11-27T06:00:00Z',
|
||||
},
|
||||
{
|
||||
code: 'AOC-020',
|
||||
name: 'Metadata Incomplete',
|
||||
severity: 'low',
|
||||
description: 'Optional metadata fields are missing',
|
||||
count: 3,
|
||||
lastSeen: '2025-11-26T22:30:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockThroughput: IngestThroughput[] = [
|
||||
{
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
documentsIngested: 15420,
|
||||
bytesIngested: 2_450_000_000,
|
||||
documentsPerMinute: 10.7,
|
||||
bytesPerMinute: 1_701_388,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-002',
|
||||
tenantName: 'TechStart Inc',
|
||||
documentsIngested: 8932,
|
||||
bytesIngested: 1_120_000_000,
|
||||
documentsPerMinute: 6.2,
|
||||
bytesPerMinute: 777_777,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-003',
|
||||
tenantName: 'DataFlow Ltd',
|
||||
documentsIngested: 5678,
|
||||
bytesIngested: 890_000_000,
|
||||
documentsPerMinute: 3.9,
|
||||
bytesPerMinute: 618_055,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-004',
|
||||
tenantName: 'SecureOps',
|
||||
documentsIngested: 3421,
|
||||
bytesIngested: 456_000_000,
|
||||
documentsPerMinute: 2.4,
|
||||
bytesPerMinute: 316_666,
|
||||
period: 'last_24h',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSources: AocSource[] = [
|
||||
{
|
||||
sourceId: 'src-001',
|
||||
name: 'Production Registry',
|
||||
type: 'registry',
|
||||
status: 'passed',
|
||||
lastCheck: '2025-11-27T10:00:00Z',
|
||||
checkCount: 523,
|
||||
passRate: 0.98,
|
||||
recentViolations: [],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-002',
|
||||
name: 'GitHub Actions Pipeline',
|
||||
type: 'pipeline',
|
||||
status: 'failed',
|
||||
lastCheck: '2025-11-27T09:45:00Z',
|
||||
checkCount: 412,
|
||||
passRate: 0.92,
|
||||
recentViolations: [mockViolationCodes[0], mockViolationCodes[1]],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-003',
|
||||
name: 'Staging Registry',
|
||||
type: 'registry',
|
||||
status: 'passed',
|
||||
lastCheck: '2025-11-27T09:30:00Z',
|
||||
checkCount: 201,
|
||||
passRate: 0.995,
|
||||
recentViolations: [],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-004',
|
||||
name: 'Manual Upload',
|
||||
type: 'manual',
|
||||
status: 'pending',
|
||||
lastCheck: '2025-11-27T08:00:00Z',
|
||||
checkCount: 111,
|
||||
passRate: 0.85,
|
||||
recentViolations: [mockViolationCodes[2]],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRecentChecks: AocCheckResult[] = [
|
||||
{
|
||||
checkId: 'chk-001',
|
||||
documentId: 'doc-abc123',
|
||||
documentType: 'sbom',
|
||||
status: 'passed',
|
||||
checkedAt: '2025-11-27T10:00:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-001',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-002',
|
||||
documentId: 'doc-def456',
|
||||
documentType: 'attestation',
|
||||
status: 'failed',
|
||||
checkedAt: '2025-11-27T09:55:00Z',
|
||||
violations: [mockViolationCodes[0]],
|
||||
sourceId: 'src-002',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-003',
|
||||
documentId: 'doc-ghi789',
|
||||
documentType: 'sbom',
|
||||
status: 'passed',
|
||||
checkedAt: '2025-11-27T09:50:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-001',
|
||||
tenantId: 'tenant-002',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-004',
|
||||
documentId: 'doc-jkl012',
|
||||
documentType: 'provenance',
|
||||
status: 'failed',
|
||||
checkedAt: '2025-11-27T09:45:00Z',
|
||||
violations: [mockViolationCodes[1]],
|
||||
sourceId: 'src-002',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-005',
|
||||
documentId: 'doc-mno345',
|
||||
documentType: 'sbom',
|
||||
status: 'pending',
|
||||
checkedAt: '2025-11-27T09:40:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-004',
|
||||
tenantId: 'tenant-003',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDashboard: AocDashboardSummary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
passFail: mockPassFailSummary,
|
||||
recentViolations: mockViolationCodes,
|
||||
throughputByTenant: mockThroughput,
|
||||
sources: mockSources,
|
||||
recentChecks: mockRecentChecks,
|
||||
};
|
||||
|
||||
const mockViolationDetails: ViolationDetail[] = [
|
||||
{
|
||||
violationId: 'viol-001',
|
||||
code: 'AOC-001',
|
||||
severity: 'critical',
|
||||
documentId: 'doc-def456',
|
||||
documentType: 'attestation',
|
||||
offendingFields: [
|
||||
{
|
||||
path: '$.predicate.buildType',
|
||||
expectedValue: 'https://slsa.dev/provenance/v1',
|
||||
actualValue: undefined,
|
||||
reason: 'Required field is missing',
|
||||
},
|
||||
{
|
||||
path: '$.predicate.builder.id',
|
||||
expectedValue: 'https://github.com/actions/runner',
|
||||
actualValue: undefined,
|
||||
reason: 'Builder ID not specified',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'pipeline',
|
||||
sourceUri: 'github.com/acme/api-service',
|
||||
ingestedAt: '2025-11-27T09:55:00Z',
|
||||
ingestedBy: 'github-actions',
|
||||
buildId: 'build-12345',
|
||||
commitSha: 'a1b2c3d4e5f6',
|
||||
pipelineUrl: 'https://github.com/acme/api-service/actions/runs/12345',
|
||||
},
|
||||
detectedAt: '2025-11-27T09:55:00Z',
|
||||
suggestion: 'Add SLSA provenance attestation to your build pipeline. See https://slsa.dev/spec/v1.0/provenance',
|
||||
},
|
||||
{
|
||||
violationId: 'viol-002',
|
||||
code: 'AOC-002',
|
||||
severity: 'critical',
|
||||
documentId: 'doc-jkl012',
|
||||
documentType: 'provenance',
|
||||
offendingFields: [
|
||||
{
|
||||
path: '$.signatures[0]',
|
||||
expectedValue: 'Valid DSSE signature',
|
||||
actualValue: 'Invalid or expired signature',
|
||||
reason: 'Signature verification failed: key not found in keyring',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'pipeline',
|
||||
sourceUri: 'github.com/acme/worker-service',
|
||||
ingestedAt: '2025-11-27T09:45:00Z',
|
||||
ingestedBy: 'github-actions',
|
||||
buildId: 'build-12346',
|
||||
commitSha: 'b2c3d4e5f6a7',
|
||||
pipelineUrl: 'https://github.com/acme/worker-service/actions/runs/12346',
|
||||
},
|
||||
detectedAt: '2025-11-27T09:45:00Z',
|
||||
suggestion: 'Ensure the signing key is registered in your tenant keyring. Run: stella keys add --public-key <key-file>',
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAocApi implements AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary> {
|
||||
return of({
|
||||
...mockDashboard,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
|
||||
getViolationDetail(violationId: string): Observable<ViolationDetail> {
|
||||
const detail = mockViolationDetails.find((v) => v.violationId === violationId);
|
||||
if (!detail) {
|
||||
throw new Error(`Violation not found: ${violationId}`);
|
||||
}
|
||||
return of(detail).pipe(delay(200));
|
||||
}
|
||||
|
||||
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]> {
|
||||
const details = mockViolationDetails.filter((v) => v.code === code);
|
||||
return of(details).pipe(delay(250));
|
||||
}
|
||||
|
||||
startVerification(): Observable<VerificationRequest> {
|
||||
return of({
|
||||
requestId: `verify-${Date.now()}`,
|
||||
status: 'queued',
|
||||
documentsToVerify: 1247,
|
||||
documentsVerified: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
cliCommand: 'stella aoc verify --since 24h --output json',
|
||||
}).pipe(delay(400));
|
||||
}
|
||||
|
||||
getVerificationStatus(requestId: string): Observable<VerificationRequest> {
|
||||
// Simulate a completed verification
|
||||
return of({
|
||||
requestId,
|
||||
status: 'completed',
|
||||
startedAt: new Date(Date.now() - 120000).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
documentsToVerify: 1247,
|
||||
documentsVerified: 1247,
|
||||
passed: 1198,
|
||||
failed: 49,
|
||||
cliCommand: 'stella aoc verify --since 24h --output json',
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
} from './aoc.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AocClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly config = inject(AppConfigService);
|
||||
|
||||
/**
|
||||
* Gets AOC metrics for the dashboard.
|
||||
*/
|
||||
getMetrics(tenantId: string, windowMinutes = 1440): Observable<AocMetrics> {
|
||||
// TODO: Replace with real API call when available
|
||||
// return this.http.get<AocMetrics>(
|
||||
// this.config.apiBaseUrl + '/aoc/metrics',
|
||||
// { params: { tenantId, windowMinutes: windowMinutes.toString() } }
|
||||
// );
|
||||
|
||||
// Mock data for development
|
||||
return of(this.getMockMetrics()).pipe(delay(300));
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers verification of documents within a time window.
|
||||
*/
|
||||
verify(request: AocVerificationRequest): Observable<AocVerificationResult> {
|
||||
// TODO: Replace with real API call when available
|
||||
// return this.http.post<AocVerificationResult>(
|
||||
// this.config.apiBaseUrl + '/aoc/verify',
|
||||
// request
|
||||
// );
|
||||
|
||||
// Mock verification result
|
||||
return of(this.getMockVerificationResult()).pipe(delay(500));
|
||||
}
|
||||
|
||||
private getMockMetrics(): AocMetrics {
|
||||
const now = new Date();
|
||||
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
passCount: 12847,
|
||||
failCount: 23,
|
||||
totalCount: 12870,
|
||||
passRate: 99.82,
|
||||
recentViolations: [
|
||||
{
|
||||
code: 'AOC-PROV-001',
|
||||
description: 'Missing provenance attestation',
|
||||
count: 12,
|
||||
severity: 'high',
|
||||
lastSeen: new Date(now.getTime() - 15 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
code: 'AOC-DIGEST-002',
|
||||
description: 'Digest mismatch in manifest',
|
||||
count: 7,
|
||||
severity: 'critical',
|
||||
lastSeen: new Date(now.getTime() - 45 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
code: 'AOC-SCHEMA-003',
|
||||
description: 'Schema validation failed',
|
||||
count: 4,
|
||||
severity: 'medium',
|
||||
lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
ingestThroughput: {
|
||||
docsPerMinute: 8.9,
|
||||
avgLatencyMs: 145,
|
||||
p95LatencyMs: 312,
|
||||
queueDepth: 3,
|
||||
errorRate: 0.18,
|
||||
},
|
||||
timeWindow: {
|
||||
start: dayAgo.toISOString(),
|
||||
end: now.toISOString(),
|
||||
durationMinutes: 1440,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getMockVerificationResult(): AocVerificationResult {
|
||||
const verifyId = 'verify-' + Date.now().toString();
|
||||
return {
|
||||
verificationId: verifyId,
|
||||
status: 'passed',
|
||||
checkedCount: 1523,
|
||||
passedCount: 1520,
|
||||
failedCount: 3,
|
||||
violations: [
|
||||
{
|
||||
documentId: 'doc-abc123',
|
||||
violationCode: 'AOC-PROV-001',
|
||||
field: 'attestation.provenance',
|
||||
expected: 'present',
|
||||
actual: 'missing',
|
||||
provenance: {
|
||||
sourceId: 'source-registry-1',
|
||||
ingestedAt: new Date().toISOString(),
|
||||
digest: 'sha256:abc123...',
|
||||
},
|
||||
},
|
||||
],
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +1,97 @@
|
||||
/**
|
||||
* Attestation of Conformance (AOC) models for UI-AOC-19-001.
|
||||
* Supports Sources dashboard tiles showing pass/fail, violation codes, and ingest throughput.
|
||||
*/
|
||||
|
||||
// AOC verification status
|
||||
export type AocVerificationStatus = 'passed' | 'failed' | 'pending' | 'skipped';
|
||||
|
||||
// Violation severity levels
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
|
||||
/**
|
||||
* AOC violation code with metadata.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly severity: ViolationSeverity;
|
||||
readonly description: string;
|
||||
readonly count: number;
|
||||
readonly lastSeen: string;
|
||||
readonly documentationUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tenant ingest throughput metrics.
|
||||
*/
|
||||
export interface IngestThroughput {
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly documentsIngested: number;
|
||||
readonly bytesIngested: number;
|
||||
readonly documentsPerMinute: number;
|
||||
readonly bytesPerMinute: number;
|
||||
readonly period: string; // e.g., "last_24h", "last_7d"
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series data point for charts.
|
||||
*/
|
||||
export interface TimeSeriesPoint {
|
||||
readonly timestamp: string;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AOC pass/fail summary for a time period.
|
||||
*/
|
||||
export interface AocPassFailSummary {
|
||||
readonly period: string;
|
||||
readonly totalChecks: number;
|
||||
readonly passed: number;
|
||||
readonly failed: number;
|
||||
readonly pending: number;
|
||||
readonly skipped: number;
|
||||
readonly passRate: number; // 0-1
|
||||
readonly trend: 'improving' | 'stable' | 'degrading';
|
||||
readonly history: readonly TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual AOC check result.
|
||||
*/
|
||||
export interface AocCheckResult {
|
||||
readonly checkId: string;
|
||||
readonly documentId: string;
|
||||
readonly documentType: string;
|
||||
readonly status: AocVerificationStatus;
|
||||
readonly checkedAt: string;
|
||||
readonly violations: readonly AocViolationCode[];
|
||||
readonly sourceId?: string;
|
||||
readonly tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source with AOC metrics.
|
||||
*/
|
||||
export interface AocSource {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly type: 'registry' | 'repository' | 'pipeline' | 'manual';
|
||||
readonly status: AocVerificationStatus;
|
||||
readonly lastCheck: string;
|
||||
readonly checkCount: number;
|
||||
readonly passRate: number;
|
||||
readonly recentViolations: readonly AocViolationCode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AOC dashboard summary combining all metrics.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
readonly generatedAt: string;
|
||||
readonly passFail: AocPassFailSummary;
|
||||
readonly recentViolations: readonly AocViolationCode[];
|
||||
readonly throughputByTenant: readonly IngestThroughput[];
|
||||
readonly sources: readonly AocSource[];
|
||||
readonly recentChecks: readonly AocCheckResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification request for "Verify last 24h" action.
|
||||
*/
|
||||
export interface VerificationRequest {
|
||||
readonly requestId: string;
|
||||
readonly status: 'queued' | 'running' | 'completed' | 'failed';
|
||||
readonly startedAt?: string;
|
||||
readonly completedAt?: string;
|
||||
readonly documentsToVerify: number;
|
||||
readonly documentsVerified: number;
|
||||
readonly passed: number;
|
||||
readonly failed: number;
|
||||
readonly cliCommand?: string; // CLI parity command
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation detail for drill-down view.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
readonly violationId: string;
|
||||
readonly code: string;
|
||||
readonly severity: ViolationSeverity;
|
||||
readonly documentId: string;
|
||||
readonly documentType: string;
|
||||
readonly offendingFields: readonly OffendingField[];
|
||||
readonly provenance: ProvenanceMetadata;
|
||||
readonly detectedAt: string;
|
||||
readonly suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offending field in a document.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
readonly path: string; // JSON path, e.g., "$.metadata.labels"
|
||||
readonly expectedValue?: string;
|
||||
readonly actualValue?: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a document.
|
||||
*/
|
||||
export interface ProvenanceMetadata {
|
||||
readonly sourceType: string;
|
||||
readonly sourceUri: string;
|
||||
readonly ingestedAt: string;
|
||||
readonly ingestedBy: string;
|
||||
readonly buildId?: string;
|
||||
readonly commitSha?: string;
|
||||
readonly pipelineUrl?: string;
|
||||
}
|
||||
/**
|
||||
* AOC (Authorization of Containers) models for dashboard metrics.
|
||||
*/
|
||||
|
||||
export interface AocMetrics {
|
||||
/** Pass/fail counts for the time window */
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
|
||||
/** Recent violations grouped by code */
|
||||
recentViolations: AocViolationSummary[];
|
||||
|
||||
/** Ingest throughput metrics */
|
||||
ingestThroughput: AocIngestThroughput;
|
||||
|
||||
/** Time window for these metrics */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
durationMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AocViolationSummary {
|
||||
code: string;
|
||||
description: string;
|
||||
count: number;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface AocIngestThroughput {
|
||||
/** Documents processed per minute */
|
||||
docsPerMinute: number;
|
||||
/** Average processing latency in milliseconds */
|
||||
avgLatencyMs: number;
|
||||
/** P95 latency in milliseconds */
|
||||
p95LatencyMs: number;
|
||||
/** Current queue depth */
|
||||
queueDepth: number;
|
||||
/** Error rate percentage */
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationRequest {
|
||||
tenantId: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AocVerificationResult {
|
||||
verificationId: string;
|
||||
status: 'passed' | 'failed' | 'partial';
|
||||
checkedCount: number;
|
||||
passedCount: number;
|
||||
failedCount: number;
|
||||
violations: AocViolationDetail[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface AocViolationDetail {
|
||||
documentId: string;
|
||||
violationCode: string;
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
77
src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts
Normal file
77
src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
95
src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts
Normal file
95
src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
@@ -1,323 +1,323 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,189 +1,189 @@
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,424 +1,424 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import {
|
||||
Exception,
|
||||
ExceptionsQueryOptions,
|
||||
ExceptionsResponse,
|
||||
ExceptionStats,
|
||||
ExceptionStatusTransition,
|
||||
} from './exception.models';
|
||||
|
||||
export interface ExceptionApi {
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
|
||||
getException(exceptionId: string): Observable<Exception>;
|
||||
createException(exception: Partial<Exception>): Observable<Exception>;
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
|
||||
deleteException(exceptionId: string): Observable<void>;
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
|
||||
getStats(): Observable<ExceptionStats>;
|
||||
}
|
||||
|
||||
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
|
||||
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let params = new HttpParams();
|
||||
if (options?.status) {
|
||||
params = params.set('status', options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
|
||||
params,
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
createException(exception: Partial<Exception>): Observable<Exception> {
|
||||
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
|
||||
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exceptionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.http.post<Exception>(
|
||||
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
|
||||
{
|
||||
newStatus: transition.newStatus,
|
||||
comment: transition.comment,
|
||||
},
|
||||
{
|
||||
headers: this.buildHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockExceptionApiService implements ExceptionApi {
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
|
||||
setTimeout(() => {
|
||||
if (exception) {
|
||||
subscriber.next(exception);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
createException(exception: Partial<Exception>): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const newException: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: 'tenant-dev',
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
|
||||
if (index === -1) {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exceptionId: string): Observable<void> {
|
||||
return new Observable((subscriber) => {
|
||||
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
|
||||
if (index !== -1) {
|
||||
this.mockExceptions.splice(index, 1);
|
||||
}
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return new Observable((subscriber) => {
|
||||
const byStatus: Record<string, number> = {
|
||||
draft: 0,
|
||||
pending_review: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import {
|
||||
Exception,
|
||||
ExceptionsQueryOptions,
|
||||
ExceptionsResponse,
|
||||
ExceptionStats,
|
||||
ExceptionStatusTransition,
|
||||
} from './exception.models';
|
||||
|
||||
export interface ExceptionApi {
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
|
||||
getException(exceptionId: string): Observable<Exception>;
|
||||
createException(exception: Partial<Exception>): Observable<Exception>;
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
|
||||
deleteException(exceptionId: string): Observable<void>;
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
|
||||
getStats(): Observable<ExceptionStats>;
|
||||
}
|
||||
|
||||
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
|
||||
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExceptionApiHttpClient implements ExceptionApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
|
||||
) {}
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let params = new HttpParams();
|
||||
if (options?.status) {
|
||||
params = params.set('status', options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
params = params.set('search', options.search);
|
||||
}
|
||||
if (options?.sortBy) {
|
||||
params = params.set('sortBy', options.sortBy);
|
||||
}
|
||||
if (options?.sortOrder) {
|
||||
params = params.set('sortOrder', options.sortOrder);
|
||||
}
|
||||
if (options?.limit) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
|
||||
params,
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
createException(exception: Partial<Exception>): Observable<Exception> {
|
||||
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
|
||||
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exceptionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.http.post<Exception>(
|
||||
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
|
||||
{
|
||||
newStatus: transition.newStatus,
|
||||
comment: transition.comment,
|
||||
},
|
||||
{
|
||||
headers: this.buildHeaders(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
return new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockExceptionApiService implements ExceptionApi {
|
||||
private readonly mockExceptions: Exception[] = [
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-001',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'log4j-temporary-exception',
|
||||
displayName: 'Log4j Temporary Exception',
|
||||
description: 'Temporary exception for legacy Log4j usage in internal tooling',
|
||||
status: 'approved',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
|
||||
vulnIds: ['CVE-2021-44228'],
|
||||
},
|
||||
justification: {
|
||||
template: 'internal-only',
|
||||
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approvedBy: 'security-lead@example.com',
|
||||
approvedAt: '2024-12-15T10:30:00Z',
|
||||
comment: 'Approved with condition: migrate before Q2',
|
||||
},
|
||||
],
|
||||
labels: { team: 'platform', priority: 'P2' },
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-10T09:00:00Z',
|
||||
updatedBy: 'security-lead@example.com',
|
||||
updatedAt: '2024-12-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-002',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'openssl-vuln-exception',
|
||||
displayName: 'OpenSSL Vulnerability Exception',
|
||||
status: 'pending_review',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-nginx-prod'],
|
||||
vulnIds: ['CVE-2024-0001'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
labels: { team: 'infrastructure' },
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-003',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'legacy-crypto-exception',
|
||||
displayName: 'Legacy Crypto Library',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-dev',
|
||||
},
|
||||
justification: {
|
||||
text: 'Migration in progress. ETA: 2 weeks.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-02-20T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-18T11:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-004',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'expired-cert-exception',
|
||||
displayName: 'Expired Certificate Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-test-env'],
|
||||
},
|
||||
justification: {
|
||||
text: 'Test environment only, not production facing.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-10-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'qa@example.com',
|
||||
createdAt: '2024-09-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-005',
|
||||
tenantId: 'tenant-dev',
|
||||
name: 'rejected-exception',
|
||||
displayName: 'Rejected Risk Exception',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Blanket exception for all critical vulns.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T16:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
|
||||
let filtered = [...this.mockExceptions];
|
||||
|
||||
if (options?.status) {
|
||||
filtered = filtered.filter((e) => e.status === options.status);
|
||||
}
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
if (options?.search) {
|
||||
const searchLower = options.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(searchLower) ||
|
||||
e.displayName?.toLowerCase().includes(searchLower) ||
|
||||
e.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? 'createdAt';
|
||||
const sortOrder = options?.sortOrder ?? 'desc';
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const items = filtered.slice(0, limit);
|
||||
|
||||
return new Observable((subscriber) => {
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
items,
|
||||
count: filtered.length,
|
||||
continuationToken: filtered.length > limit ? 'next-page-token' : null,
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
getException(exceptionId: string): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
|
||||
setTimeout(() => {
|
||||
if (exception) {
|
||||
subscriber.next(exception);
|
||||
} else {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
}
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
createException(exception: Partial<Exception>): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const newException: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
|
||||
tenantId: 'tenant-dev',
|
||||
name: exception.name ?? 'new-exception',
|
||||
status: 'draft',
|
||||
severity: exception.severity ?? 'medium',
|
||||
scope: exception.scope ?? { type: 'tenant' },
|
||||
justification: exception.justification ?? { text: '' },
|
||||
timebox: exception.timebox ?? {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
createdAt: new Date().toISOString(),
|
||||
...exception,
|
||||
} as Exception;
|
||||
|
||||
this.mockExceptions.push(newException);
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(newException);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
|
||||
return new Observable((subscriber) => {
|
||||
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
|
||||
if (index === -1) {
|
||||
subscriber.error(new Error(`Exception ${exceptionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...this.mockExceptions[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.mockExceptions[index] = updated;
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next(updated);
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exceptionId: string): Observable<void> {
|
||||
return new Observable((subscriber) => {
|
||||
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
|
||||
if (index !== -1) {
|
||||
this.mockExceptions.splice(index, 1);
|
||||
}
|
||||
setTimeout(() => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
|
||||
return this.updateException(transition.exceptionId, {
|
||||
status: transition.newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): Observable<ExceptionStats> {
|
||||
return new Observable((subscriber) => {
|
||||
const byStatus: Record<string, number> = {
|
||||
draft: 0,
|
||||
pending_review: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
expired: 0,
|
||||
revoked: 0,
|
||||
};
|
||||
const bySeverity: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
this.mockExceptions.forEach((e) => {
|
||||
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
|
||||
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
subscriber.next({
|
||||
total: this.mockExceptions.length,
|
||||
byStatus: byStatus as Record<any, number>,
|
||||
bySeverity: bySeverity as Record<any, number>,
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: byStatus['pending_review'],
|
||||
});
|
||||
subscriber.complete();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,205 @@
|
||||
/**
|
||||
* Exception Center domain models.
|
||||
* Represents policy exceptions with workflow lifecycle management.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus =
|
||||
| 'draft'
|
||||
| 'pending_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'expired'
|
||||
| 'revoked';
|
||||
|
||||
export type ExceptionSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
export type ExceptionScope = 'global' | 'tenant' | 'asset' | 'component';
|
||||
|
||||
export interface ExceptionJustification {
|
||||
readonly template?: string;
|
||||
readonly text: string;
|
||||
readonly attachments?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
readonly startDate: string;
|
||||
readonly endDate: string;
|
||||
readonly autoRenew?: boolean;
|
||||
readonly maxRenewals?: number;
|
||||
readonly renewalCount?: number;
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approvedBy: string;
|
||||
readonly approvedAt: string;
|
||||
readonly comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
readonly auditId: string;
|
||||
readonly action: string;
|
||||
readonly actor: string;
|
||||
readonly timestamp: string;
|
||||
readonly details?: Record<string, string>;
|
||||
readonly previousStatus?: ExceptionStatus;
|
||||
readonly newStatus?: ExceptionStatus;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
readonly type: ExceptionScope;
|
||||
readonly tenantId?: string;
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly vulnIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface Exception {
|
||||
readonly schemaVersion?: string;
|
||||
readonly exceptionId: string;
|
||||
readonly tenantId: string;
|
||||
readonly name: string;
|
||||
readonly displayName?: string;
|
||||
readonly description?: string;
|
||||
readonly status: ExceptionStatus;
|
||||
readonly severity: ExceptionSeverity;
|
||||
readonly scope: ExceptionScope;
|
||||
readonly justification: ExceptionJustification;
|
||||
readonly timebox: ExceptionTimebox;
|
||||
readonly policyRuleIds?: readonly string[];
|
||||
readonly approvals?: readonly ExceptionApproval[];
|
||||
readonly auditTrail?: readonly ExceptionAuditEntry[];
|
||||
readonly labels?: Record<string, string>;
|
||||
readonly metadata?: Record<string, string>;
|
||||
readonly createdBy: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedBy?: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionsQueryOptions {
|
||||
readonly status?: ExceptionStatus;
|
||||
readonly severity?: ExceptionSeverity;
|
||||
readonly scope?: ExceptionScope;
|
||||
readonly search?: string;
|
||||
readonly sortBy?: 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
|
||||
readonly sortOrder?: 'asc' | 'desc';
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionsResponse {
|
||||
readonly items: readonly Exception[];
|
||||
readonly count: number;
|
||||
readonly continuationToken?: string | null;
|
||||
}
|
||||
|
||||
export interface ExceptionStatusTransition {
|
||||
readonly exceptionId: string;
|
||||
readonly newStatus: ExceptionStatus;
|
||||
readonly comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionKanbanColumn {
|
||||
readonly status: ExceptionStatus;
|
||||
readonly label: string;
|
||||
readonly items: readonly Exception[];
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
export interface ExceptionStats {
|
||||
readonly total: number;
|
||||
readonly byStatus: Record<ExceptionStatus, number>;
|
||||
readonly bySeverity: Record<ExceptionSeverity, number>;
|
||||
readonly expiringWithin7Days: number;
|
||||
readonly pendingApproval: number;
|
||||
}
|
||||
/**
|
||||
* Exception management models for the Exception Center.
|
||||
*/
|
||||
|
||||
export type ExceptionStatus = 'draft' | 'pending' | 'approved' | 'active' | 'expired' | 'revoked';
|
||||
|
||||
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
|
||||
|
||||
export interface Exception {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
|
||||
/** Short title */
|
||||
title: string;
|
||||
|
||||
/** Detailed justification */
|
||||
justification: string;
|
||||
|
||||
/** Exception type */
|
||||
type: ExceptionType;
|
||||
|
||||
/** Current status */
|
||||
status: ExceptionStatus;
|
||||
|
||||
/** Severity being excepted */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Scope definition */
|
||||
scope: ExceptionScope;
|
||||
|
||||
/** Time constraints */
|
||||
timebox: ExceptionTimebox;
|
||||
|
||||
/** Workflow history */
|
||||
workflow: ExceptionWorkflow;
|
||||
|
||||
/** Audit trail */
|
||||
auditLog: ExceptionAuditEntry[];
|
||||
|
||||
/** Associated findings/violations */
|
||||
findings: string[];
|
||||
|
||||
/** Tags for filtering */
|
||||
tags: string[];
|
||||
|
||||
/** Created timestamp */
|
||||
createdAt: string;
|
||||
|
||||
/** Last updated timestamp */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ExceptionScope {
|
||||
/** Affected images (glob patterns allowed) */
|
||||
images?: string[];
|
||||
|
||||
/** Affected CVEs */
|
||||
cves?: string[];
|
||||
|
||||
/** Affected packages */
|
||||
packages?: string[];
|
||||
|
||||
/** Affected licenses */
|
||||
licenses?: string[];
|
||||
|
||||
/** Affected policy rules */
|
||||
policyRules?: string[];
|
||||
|
||||
/** Tenant scope */
|
||||
tenantId?: string;
|
||||
|
||||
/** Environment scope */
|
||||
environments?: string[];
|
||||
}
|
||||
|
||||
export interface ExceptionTimebox {
|
||||
/** Start date */
|
||||
startsAt: string;
|
||||
|
||||
/** Expiration date */
|
||||
expiresAt: string;
|
||||
|
||||
/** Remaining days */
|
||||
remainingDays: number;
|
||||
|
||||
/** Is expired */
|
||||
isExpired: boolean;
|
||||
|
||||
/** Warning threshold (days before expiry) */
|
||||
warnDays: number;
|
||||
|
||||
/** Is in warning period */
|
||||
isWarning: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionWorkflow {
|
||||
/** Current workflow state */
|
||||
state: ExceptionStatus;
|
||||
|
||||
/** Requested by */
|
||||
requestedBy: string;
|
||||
|
||||
/** Requested at */
|
||||
requestedAt: string;
|
||||
|
||||
/** Approved by */
|
||||
approvedBy?: string;
|
||||
|
||||
/** Approved at */
|
||||
approvedAt?: string;
|
||||
|
||||
/** Revoked by */
|
||||
revokedBy?: string;
|
||||
|
||||
/** Revoked at */
|
||||
revokedAt?: string;
|
||||
|
||||
/** Revocation reason */
|
||||
revocationReason?: string;
|
||||
|
||||
/** Required approvers */
|
||||
requiredApprovers: string[];
|
||||
|
||||
/** Current approvals */
|
||||
approvals: ExceptionApproval[];
|
||||
}
|
||||
|
||||
export interface ExceptionApproval {
|
||||
/** Approver identity */
|
||||
approver: string;
|
||||
|
||||
/** Decision */
|
||||
decision: 'approved' | 'rejected';
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Optional comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionAuditEntry {
|
||||
/** Entry ID */
|
||||
id: string;
|
||||
|
||||
/** Action performed */
|
||||
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
|
||||
|
||||
/** Actor */
|
||||
actor: string;
|
||||
|
||||
/** Timestamp */
|
||||
at: string;
|
||||
|
||||
/** Details */
|
||||
details?: string;
|
||||
|
||||
/** Previous values (for edits) */
|
||||
previousValues?: Record<string, unknown>;
|
||||
|
||||
/** New values (for edits) */
|
||||
newValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExceptionFilter {
|
||||
status?: ExceptionStatus[];
|
||||
type?: ExceptionType[];
|
||||
severity?: string[];
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
expiringSoon?: boolean;
|
||||
createdAfter?: string;
|
||||
createdBefore?: string;
|
||||
}
|
||||
|
||||
export interface ExceptionSortOption {
|
||||
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface ExceptionTransition {
|
||||
from: ExceptionStatus;
|
||||
to: ExceptionStatus;
|
||||
action: string;
|
||||
requiresApproval: boolean;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
|
||||
{ from: 'draft', to: 'pending', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
|
||||
{ from: 'pending', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'pending', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
{ from: 'approved', to: 'active', action: 'Activate', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
{ from: 'active', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
|
||||
{ from: 'pending', to: 'revoked', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
|
||||
];
|
||||
|
||||
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
|
||||
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
|
||||
{ status: 'pending', label: 'Pending Approval', color: '#f59e0b' },
|
||||
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
|
||||
{ status: 'active', label: 'Active', color: '#10b981' },
|
||||
{ status: 'expired', label: 'Expired', color: '#6b7280' },
|
||||
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
|
||||
];
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
export interface PolicyPreviewRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
policy?: PolicyPreviewPolicyDto;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewPolicyDto {
|
||||
content?: string;
|
||||
format?: string;
|
||||
actor?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewFindingDto {
|
||||
id: string;
|
||||
severity: string;
|
||||
environment?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
license?: string;
|
||||
image?: string;
|
||||
repository?: string;
|
||||
package?: string;
|
||||
purl?: string;
|
||||
cve?: string;
|
||||
path?: string;
|
||||
layerDigest?: string;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewVerdictDto {
|
||||
findingId: string;
|
||||
status: string;
|
||||
ruleName?: string | null;
|
||||
ruleAction?: string | null;
|
||||
notes?: string | null;
|
||||
score?: number | null;
|
||||
configVersion?: string | null;
|
||||
inputs?: Readonly<Record<string, number>>;
|
||||
quietedBy?: string | null;
|
||||
quiet?: boolean | null;
|
||||
unknownConfidence?: number | null;
|
||||
confidenceBand?: string | null;
|
||||
unknownAgeDays?: number | null;
|
||||
sourceTrust?: string | null;
|
||||
reachability?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewDiffDto {
|
||||
findingId: string;
|
||||
baseline: PolicyPreviewVerdictDto;
|
||||
projected: PolicyPreviewVerdictDto;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewIssueDto {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewResponseDto {
|
||||
success: boolean;
|
||||
policyDigest: string;
|
||||
revisionId?: string | null;
|
||||
changed: number;
|
||||
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyPreviewSample {
|
||||
previewRequest: PolicyPreviewRequestDto;
|
||||
previewResponse: PolicyPreviewResponseDto;
|
||||
}
|
||||
|
||||
export interface PolicyReportRequestDto {
|
||||
imageDigest: string;
|
||||
findings: ReadonlyArray<PolicyPreviewFindingDto>;
|
||||
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportResponseDto {
|
||||
report: PolicyReportDocumentDto;
|
||||
dsse?: DsseEnvelopeDto | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportDocumentDto {
|
||||
reportId: string;
|
||||
imageDigest: string;
|
||||
generatedAt: string;
|
||||
verdict: string;
|
||||
policy: PolicyReportPolicyDto;
|
||||
summary: PolicyReportSummaryDto;
|
||||
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
|
||||
issues: ReadonlyArray<PolicyPreviewIssueDto>;
|
||||
}
|
||||
|
||||
export interface PolicyReportPolicyDto {
|
||||
revisionId?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyReportSummaryDto {
|
||||
total: number;
|
||||
blocked: number;
|
||||
warned: number;
|
||||
ignored: number;
|
||||
quieted: number;
|
||||
}
|
||||
|
||||
export interface DsseEnvelopeDto {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: ReadonlyArray<DsseSignatureDto>;
|
||||
}
|
||||
|
||||
export interface DsseSignatureDto {
|
||||
keyId: string;
|
||||
algorithm: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PolicyReportSample {
|
||||
reportRequest: PolicyReportRequestDto;
|
||||
reportResponse: PolicyReportResponseDto;
|
||||
}
|
||||
|
||||
163
src/Web/StellaOps.Web/src/app/core/api/policy.models.ts
Normal file
163
src/Web/StellaOps.Web/src/app/core/api/policy.models.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
@@ -1,373 +1,373 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,161 +1,161 @@
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
|
||||
@@ -1,316 +1,316 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
} from './vulnerability.models';
|
||||
|
||||
export interface VulnerabilityApi {
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
getVulnerability(vulnId: string): Observable<Vulnerability>;
|
||||
getStats(): Observable<VulnerabilityStats>;
|
||||
}
|
||||
|
||||
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
||||
|
||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
let items = [...MOCK_VULNERABILITIES];
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(v) =>
|
||||
v.cveId.toLowerCase().includes(search) ||
|
||||
v.title.toLowerCase().includes(search) ||
|
||||
v.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
items = items.slice(offset, offset + limit);
|
||||
|
||||
return of({
|
||||
items,
|
||||
total,
|
||||
hasMore: offset + items.length < total,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
getVulnerability(vulnId: string): Observable<Vulnerability> {
|
||||
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
|
||||
if (!vuln) {
|
||||
throw new Error(`Vulnerability ${vulnId} not found`);
|
||||
}
|
||||
return of(vuln).pipe(delay(100));
|
||||
}
|
||||
|
||||
getStats(): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
};
|
||||
return of(stats).pipe(delay(150));
|
||||
}
|
||||
}
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitiesQueryOptions,
|
||||
VulnerabilitiesResponse,
|
||||
VulnerabilityStats,
|
||||
} from './vulnerability.models';
|
||||
|
||||
export interface VulnerabilityApi {
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
|
||||
getVulnerability(vulnId: string): Observable<Vulnerability>;
|
||||
getStats(): Observable<VulnerabilityStats>;
|
||||
}
|
||||
|
||||
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
|
||||
|
||||
const MOCK_VULNERABILITIES: Vulnerability[] = [
|
||||
{
|
||||
vulnId: 'vuln-001',
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
|
||||
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severity: 'critical',
|
||||
cvssScore: 10.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2021-12-10T00:00:00Z',
|
||||
modifiedAt: '2024-06-27T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-002',
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j2 Thread Context Message Pattern DoS',
|
||||
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.0,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
|
||||
status: 'excepted',
|
||||
publishedAt: '2021-12-14T00:00:00Z',
|
||||
modifiedAt: '2023-11-06T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
|
||||
name: 'log4j-core',
|
||||
version: '2.15.0',
|
||||
fixedVersion: '2.17.1',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-test-001',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-003',
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'HTTP/2 Rapid Reset Attack',
|
||||
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
|
||||
severity: 'high',
|
||||
cvssScore: 7.5,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
|
||||
status: 'in_progress',
|
||||
publishedAt: '2023-10-10T00:00:00Z',
|
||||
modifiedAt: '2024-05-01T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/golang.org/x/net@0.15.0',
|
||||
name: 'golang.org/x/net',
|
||||
version: '0.15.0',
|
||||
fixedVersion: '0.17.0',
|
||||
assetIds: ['asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
{
|
||||
purl: 'pkg:npm/nghttp2@1.55.0',
|
||||
name: 'nghttp2',
|
||||
version: '1.55.0',
|
||||
fixedVersion: '1.57.0',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-004',
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'runc container escape vulnerability',
|
||||
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
|
||||
severity: 'high',
|
||||
cvssScore: 8.6,
|
||||
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
|
||||
status: 'fixed',
|
||||
publishedAt: '2024-01-31T00:00:00Z',
|
||||
modifiedAt: '2024-09-13T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
name: 'runc',
|
||||
version: '1.1.10',
|
||||
fixedVersion: '1.1.12',
|
||||
assetIds: ['asset-builder-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-005',
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'curl SOCKS5 heap buffer overflow',
|
||||
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
cvssScore: 9.8,
|
||||
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-11T00:00:00Z',
|
||||
modifiedAt: '2024-06-10T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/curl@7.88.1-10',
|
||||
name: 'curl',
|
||||
version: '7.88.1-10',
|
||||
fixedVersion: '8.4.0',
|
||||
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-006',
|
||||
cveId: 'CVE-2022-22965',
|
||||
title: 'Spring4Shell - Spring Framework RCE',
|
||||
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
|
||||
severity: 'critical',
|
||||
cvssScore: 9.8,
|
||||
status: 'wont_fix',
|
||||
publishedAt: '2022-03-31T00:00:00Z',
|
||||
modifiedAt: '2024-08-20T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
|
||||
name: 'spring-beans',
|
||||
version: '5.3.17',
|
||||
fixedVersion: '5.3.18',
|
||||
assetIds: ['asset-legacy-001'],
|
||||
},
|
||||
],
|
||||
hasException: true,
|
||||
exceptionId: 'exc-legacy-spring',
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-007',
|
||||
cveId: 'CVE-2023-45853',
|
||||
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
|
||||
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.3,
|
||||
status: 'open',
|
||||
publishedAt: '2023-10-14T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:deb/debian/zlib@1.2.13',
|
||||
name: 'zlib',
|
||||
version: '1.2.13',
|
||||
fixedVersion: '1.3.1',
|
||||
assetIds: ['asset-web-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-008',
|
||||
cveId: 'CVE-2024-0567',
|
||||
title: 'GnuTLS certificate verification bypass',
|
||||
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
|
||||
severity: 'medium',
|
||||
cvssScore: 5.9,
|
||||
status: 'open',
|
||||
publishedAt: '2024-01-16T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
|
||||
name: 'gnutls',
|
||||
version: '3.8.2',
|
||||
fixedVersion: '3.8.3',
|
||||
assetIds: ['asset-internal-001'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-009',
|
||||
cveId: 'CVE-2023-5363',
|
||||
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
|
||||
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
|
||||
severity: 'low',
|
||||
cvssScore: 3.7,
|
||||
status: 'fixed',
|
||||
publishedAt: '2023-10-24T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
|
||||
name: 'System.Security.Cryptography.Pkcs',
|
||||
version: '7.0.2',
|
||||
fixedVersion: '8.0.0',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
{
|
||||
vulnId: 'vuln-010',
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'Go net/netip ParseAddr stack exhaustion',
|
||||
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
|
||||
severity: 'low',
|
||||
cvssScore: 4.0,
|
||||
status: 'open',
|
||||
publishedAt: '2024-06-05T00:00:00Z',
|
||||
affectedComponents: [
|
||||
{
|
||||
purl: 'pkg:golang/stdlib@1.21.10',
|
||||
name: 'go stdlib',
|
||||
version: '1.21.10',
|
||||
fixedVersion: '1.21.11',
|
||||
assetIds: ['asset-api-prod'],
|
||||
},
|
||||
],
|
||||
hasException: false,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
let items = [...MOCK_VULNERABILITIES];
|
||||
|
||||
if (options?.severity && options.severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === options.severity);
|
||||
}
|
||||
|
||||
if (options?.status && options.status !== 'all') {
|
||||
items = items.filter((v) => v.status === options.status);
|
||||
}
|
||||
|
||||
if (options?.hasException !== undefined) {
|
||||
items = items.filter((v) => v.hasException === options.hasException);
|
||||
}
|
||||
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(v) =>
|
||||
v.cveId.toLowerCase().includes(search) ||
|
||||
v.title.toLowerCase().includes(search) ||
|
||||
v.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
items = items.slice(offset, offset + limit);
|
||||
|
||||
return of({
|
||||
items,
|
||||
total,
|
||||
hasMore: offset + items.length < total,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
getVulnerability(vulnId: string): Observable<Vulnerability> {
|
||||
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
|
||||
if (!vuln) {
|
||||
throw new Error(`Vulnerability ${vulnId} not found`);
|
||||
}
|
||||
return of(vuln).pipe(delay(100));
|
||||
}
|
||||
|
||||
getStats(): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
high: vulns.filter((v) => v.severity === 'high').length,
|
||||
medium: vulns.filter((v) => v.severity === 'medium').length,
|
||||
low: vulns.filter((v) => v.severity === 'low').length,
|
||||
unknown: vulns.filter((v) => v.severity === 'unknown').length,
|
||||
},
|
||||
byStatus: {
|
||||
open: vulns.filter((v) => v.status === 'open').length,
|
||||
fixed: vulns.filter((v) => v.status === 'fixed').length,
|
||||
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
};
|
||||
return of(stats).pipe(delay(150));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
|
||||
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
|
||||
|
||||
export interface Vulnerability {
|
||||
readonly vulnId: string;
|
||||
readonly cveId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly severity: VulnerabilitySeverity;
|
||||
readonly cvssScore?: number;
|
||||
readonly cvssVector?: string;
|
||||
readonly status: VulnerabilityStatus;
|
||||
readonly publishedAt?: string;
|
||||
readonly modifiedAt?: string;
|
||||
readonly affectedComponents: readonly AffectedComponent[];
|
||||
readonly references?: readonly string[];
|
||||
readonly hasException?: boolean;
|
||||
readonly exceptionId?: string;
|
||||
}
|
||||
|
||||
export interface AffectedComponent {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly fixedVersion?: string;
|
||||
readonly assetIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityStats {
|
||||
readonly total: number;
|
||||
readonly bySeverity: Record<VulnerabilitySeverity, number>;
|
||||
readonly byStatus: Record<VulnerabilityStatus, number>;
|
||||
readonly withExceptions: number;
|
||||
readonly criticalOpen: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesQueryOptions {
|
||||
readonly severity?: VulnerabilitySeverity | 'all';
|
||||
readonly status?: VulnerabilityStatus | 'all';
|
||||
readonly search?: string;
|
||||
readonly hasException?: boolean;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
export interface VulnerabilitiesResponse {
|
||||
readonly items: readonly Vulnerability[];
|
||||
readonly total: number;
|
||||
readonly hasMore: boolean;
|
||||
}
|
||||
|
||||
@@ -1,125 +1,125 @@
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
@@ -1,166 +1,166 @@
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
|
||||
// Policy scopes
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
RELEASE_PUBLISH: 'release:publish',
|
||||
RELEASE_BYPASS: 'release:bypass',
|
||||
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
|
||||
// Policy scopes
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
RELEASE_PUBLISH: 'release:publish',
|
||||
RELEASE_BYPASS: 'release:bypass',
|
||||
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<div class="verify-action" [class]="'state-' + state()">
|
||||
<!-- Action Header -->
|
||||
<div class="action-header">
|
||||
<div class="action-info">
|
||||
<span class="status-icon">{{ statusIcon() }}</span>
|
||||
<div class="action-text">
|
||||
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
|
||||
<p class="action-desc">{{ statusLabel() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
|
||||
<button class="btn-verify" (click)="runVerification()">
|
||||
@if (state() === 'idle') {
|
||||
Run Verification
|
||||
} @else {
|
||||
Re-run
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
|
||||
CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (state() === 'running') {
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width]="progress() + '%'"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (state() === 'error' && error()) {
|
||||
<div class="error-banner">
|
||||
<span class="error-icon">[X]</span>
|
||||
<span class="error-message">{{ error() }}</span>
|
||||
<button class="btn-retry" (click)="runVerification()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results -->
|
||||
@if (state() === 'completed' && result()) {
|
||||
<div class="results-section">
|
||||
<!-- Summary Stats -->
|
||||
<div class="results-summary">
|
||||
<div class="stat-card" [class.success]="result()!.status === 'passed'">
|
||||
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
|
||||
<span class="stat-label">Documents Checked</span>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<span class="stat-value">{{ result()!.passedCount | number }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="stat-card" [class.error]="result()!.failedCount > 0">
|
||||
<span class="stat-value">{{ result()!.failedCount | number }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
|
||||
<span class="stat-label">Pass Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violations Preview -->
|
||||
@if (result()!.violations.length > 0) {
|
||||
<div class="violations-preview">
|
||||
<h5 class="preview-title">
|
||||
Violations Found
|
||||
<span class="violation-count">{{ result()!.violations.length }}</span>
|
||||
</h5>
|
||||
|
||||
<!-- Violation codes breakdown -->
|
||||
<div class="code-breakdown">
|
||||
@for (code of resultSummary()?.uniqueCodes || []; track code) {
|
||||
<span class="code-chip">
|
||||
{{ code }}
|
||||
<span class="code-count">
|
||||
{{ result()!.violations.filter(v => v.violationCode === code).length }}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sample violations -->
|
||||
<ul class="violations-list">
|
||||
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
|
||||
<li class="violation-item">
|
||||
<button class="violation-btn" (click)="onSelectViolation(v)">
|
||||
<span class="v-code">{{ v.violationCode }}</span>
|
||||
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
|
||||
@if (v.field) {
|
||||
<span class="v-field">{{ v.field }}</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@if (result()!.violations.length > 3) {
|
||||
<li class="more-violations">
|
||||
+ {{ result()!.violations.length - 3 }} more violations
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-violations">
|
||||
<span class="success-icon">[+]</span>
|
||||
<span>No violations found in the last {{ windowHours() }} hours</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Completion Info -->
|
||||
<div class="completion-info">
|
||||
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
|
||||
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CLI Guidance Panel -->
|
||||
@if (showCliGuidance()) {
|
||||
<div class="cli-guidance">
|
||||
<h5 class="cli-title">CLI Parity</h5>
|
||||
<p class="cli-desc">{{ cliGuidance.description }}</p>
|
||||
|
||||
<!-- Current Command -->
|
||||
<div class="cli-command-section">
|
||||
<label class="cli-label">Equivalent Command</label>
|
||||
<div class="cli-command">
|
||||
<code>{{ getCliCommand() }}</code>
|
||||
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
|
||||
[C]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Flags -->
|
||||
<div class="cli-flags-section">
|
||||
<label class="cli-label">Available Flags</label>
|
||||
<table class="flags-table">
|
||||
<tbody>
|
||||
@for (flag of cliGuidance.flags; track flag.flag) {
|
||||
<tr>
|
||||
<td class="flag-name"><code>{{ flag.flag }}</code></td>
|
||||
<td class="flag-desc">{{ flag.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Examples -->
|
||||
<div class="cli-examples-section">
|
||||
<label class="cli-label">Examples</label>
|
||||
<div class="examples-list">
|
||||
@for (example of cliGuidance.examples; track example) {
|
||||
<div class="example-item">
|
||||
<code>{{ example }}</code>
|
||||
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
|
||||
[C]
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install hint -->
|
||||
<div class="install-hint">
|
||||
<span class="hint-icon">[i]</span>
|
||||
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,517 @@
|
||||
.verify-action {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.state-running {
|
||||
.action-header {
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-left: 4px solid var(--color-info, #2563eb);
|
||||
}
|
||||
.status-icon { color: var(--color-info, #2563eb); }
|
||||
}
|
||||
|
||||
&.state-completed {
|
||||
.action-header {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
border-left: 4px solid var(--color-success, #059669);
|
||||
}
|
||||
.status-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.state-error {
|
||||
.action-header {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-left: 4px solid var(--color-error, #dc2626);
|
||||
}
|
||||
.status-icon { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
}
|
||||
|
||||
.action-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-left: 4px solid var(--color-border, #e5e7eb);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.action-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-verify {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cli {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
}
|
||||
}
|
||||
|
||||
// Progress
|
||||
.progress-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--color-bg-subtle, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border-radius: 4px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Error
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-top: 1px solid var(--color-error-border, #fecaca);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--color-error, #dc2626);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-error-dark, #b91c1c);
|
||||
}
|
||||
}
|
||||
|
||||
// Results
|
||||
.results-section {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
|
||||
&.success {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
.stat-value { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
.stat-value { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.violations-preview {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
border-radius: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.code-breakdown {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.code-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.code-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0 0.25rem;
|
||||
background: var(--color-error, #dc2626);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.violation-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.v-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.v-doc {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.v-field {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
border-radius: 2px;
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
}
|
||||
|
||||
.more-violations {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.no-violations {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-success, #059669);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.completion-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.verify-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
// CLI Guidance
|
||||
.cli-guidance {
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.cli-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.cli-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.cli-command-section,
|
||||
.cli-flags-section,
|
||||
.cli-examples-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cli-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.cli-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.625rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.flags-table {
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
border-collapse: collapse;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.flag-name {
|
||||
width: 140px;
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.flag-desc {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
.examples-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.example-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #d1d5db;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.install-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-info, #0284c7);
|
||||
margin-top: 1rem;
|
||||
|
||||
code {
|
||||
background: var(--color-bg-code, #e0f2fe);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<div class="violation-drilldown">
|
||||
<!-- Header with Summary -->
|
||||
<header class="drilldown-header">
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ totalViolations() }}</span>
|
||||
<span class="stat-label">Violations</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ totalDocuments() }}</span>
|
||||
<span class="stat-label">Documents</span>
|
||||
</div>
|
||||
<div class="severity-breakdown">
|
||||
@if (severityCounts().critical > 0) {
|
||||
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
|
||||
}
|
||||
@if (severityCounts().high > 0) {
|
||||
<span class="severity-chip high">{{ severityCounts().high }} high</span>
|
||||
}
|
||||
@if (severityCounts().medium > 0) {
|
||||
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
|
||||
}
|
||||
@if (severityCounts().low > 0) {
|
||||
<span class="severity-chip low">{{ severityCounts().low }} low</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'by-violation'"
|
||||
(click)="setViewMode('by-violation')"
|
||||
>
|
||||
By Violation
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'by-document'"
|
||||
(click)="setViewMode('by-document')"
|
||||
>
|
||||
By Document
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Filter violations..."
|
||||
[value]="searchFilter()"
|
||||
(input)="onSearch($event)"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- By Violation View -->
|
||||
@if (viewMode() === 'by-violation') {
|
||||
<div class="violation-list">
|
||||
@for (group of filteredGroups(); track group.code) {
|
||||
<div class="violation-group" [class]="'severity-' + group.severity">
|
||||
<button
|
||||
class="group-header"
|
||||
(click)="toggleGroup(group.code)"
|
||||
[attr.aria-expanded]="expandedCode() === group.code"
|
||||
>
|
||||
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
|
||||
<div class="group-info">
|
||||
<span class="violation-code">{{ group.code }}</span>
|
||||
<span class="violation-desc">{{ group.description }}</span>
|
||||
</div>
|
||||
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
|
||||
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedCode() === group.code) {
|
||||
<div class="group-details">
|
||||
@if (group.remediation) {
|
||||
<div class="remediation-hint">
|
||||
<strong>Remediation:</strong> {{ group.remediation }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="violations-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Field</th>
|
||||
<th>Expected</th>
|
||||
<th>Actual</th>
|
||||
<th>Provenance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of group.violations; track v.documentId + v.field) {
|
||||
<tr class="violation-row">
|
||||
<td class="doc-cell">
|
||||
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
|
||||
{{ v.documentId | slice:0:20 }}...
|
||||
</button>
|
||||
</td>
|
||||
<td class="field-cell">
|
||||
@if (v.field) {
|
||||
<code class="field-path highlighted">{{ v.field }}</code>
|
||||
} @else {
|
||||
<span class="no-field">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="expected-cell">
|
||||
@if (v.expected) {
|
||||
<code class="value expected">{{ v.expected }}</code>
|
||||
} @else {
|
||||
<span class="no-value">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actual-cell">
|
||||
@if (v.actual) {
|
||||
<code class="value actual error">{{ v.actual }}</code>
|
||||
} @else {
|
||||
<span class="no-value">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="provenance-cell">
|
||||
@if (v.provenance) {
|
||||
<div class="provenance-info">
|
||||
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
|
||||
<span class="source-id" [title]="v.provenance.sourceId">
|
||||
{{ v.provenance.sourceId | slice:0:15 }}
|
||||
</span>
|
||||
<span class="digest" [title]="v.provenance.digest">
|
||||
{{ formatDigest(v.provenance.digest) }}
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="no-provenance">No provenance</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
|
||||
{ }
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredGroups().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (searchFilter()) {
|
||||
<p>No violations match "{{ searchFilter() }}"</p>
|
||||
} @else {
|
||||
<p>No violations to display</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- By Document View -->
|
||||
@if (viewMode() === 'by-document') {
|
||||
<div class="document-list">
|
||||
@for (doc of filteredDocuments(); track doc.documentId) {
|
||||
<div class="document-card">
|
||||
<button
|
||||
class="doc-header"
|
||||
(click)="toggleDocument(doc.documentId)"
|
||||
[attr.aria-expanded]="expandedDocId() === doc.documentId"
|
||||
>
|
||||
<span class="doc-type-badge">{{ doc.documentType }}</span>
|
||||
<span class="doc-id">{{ doc.documentId }}</span>
|
||||
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
|
||||
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedDocId() === doc.documentId) {
|
||||
<div class="doc-details">
|
||||
<!-- Provenance Section -->
|
||||
<div class="provenance-section">
|
||||
<h4 class="section-title">Provenance</h4>
|
||||
<dl class="provenance-grid">
|
||||
<div class="prov-item">
|
||||
<dt>Source</dt>
|
||||
<dd>
|
||||
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
|
||||
{{ doc.provenance.sourceId }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="prov-item">
|
||||
<dt>Digest</dt>
|
||||
<dd><code>{{ doc.provenance.digest }}</code></dd>
|
||||
</div>
|
||||
<div class="prov-item">
|
||||
<dt>Ingested</dt>
|
||||
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
|
||||
</div>
|
||||
@if (doc.provenance.submitter) {
|
||||
<div class="prov-item">
|
||||
<dt>Submitter</dt>
|
||||
<dd>{{ doc.provenance.submitter }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (doc.provenance.sourceUrl) {
|
||||
<div class="prov-item">
|
||||
<dt>Source URL</dt>
|
||||
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Violations Section -->
|
||||
<div class="violations-section">
|
||||
<h4 class="section-title">Violations</h4>
|
||||
<ul class="doc-violations-list">
|
||||
@for (v of doc.violations; track v.violationCode + v.field) {
|
||||
<li class="doc-violation-item">
|
||||
<div class="violation-header">
|
||||
<code class="violation-code">{{ v.violationCode }}</code>
|
||||
@if (v.field) {
|
||||
<span class="at-field">at</span>
|
||||
<code class="field-path highlighted">{{ v.field }}</code>
|
||||
}
|
||||
</div>
|
||||
@if (v.expected || v.actual) {
|
||||
<div class="value-diff">
|
||||
<div class="expected-row">
|
||||
<span class="label">Expected:</span>
|
||||
<code class="value">{{ v.expected || 'N/A' }}</code>
|
||||
</div>
|
||||
<div class="actual-row">
|
||||
<span class="label">Actual:</span>
|
||||
<code class="value error">{{ v.actual || 'N/A' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Raw Content Preview -->
|
||||
@if (doc.rawContent) {
|
||||
<div class="raw-content-section">
|
||||
<h4 class="section-title">
|
||||
Document Fields
|
||||
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
|
||||
</h4>
|
||||
<div class="field-preview">
|
||||
@for (field of doc.highlightedFields; track field) {
|
||||
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
|
||||
<span class="field-name">{{ field }}</span>
|
||||
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredDocuments().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (searchFilter()) {
|
||||
<p>No documents match "{{ searchFilter() }}"</p>
|
||||
} @else {
|
||||
<p>No documents to display</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,585 @@
|
||||
.violation-drilldown {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drilldown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.severity-breakdown {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.critical {
|
||||
background: var(--color-critical-bg, #fef2f2);
|
||||
color: var(--color-critical, #dc2626);
|
||||
}
|
||||
|
||||
&.high {
|
||||
background: var(--color-error-bg, #fff7ed);
|
||||
color: var(--color-error, #ea580c);
|
||||
}
|
||||
|
||||
&.medium {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Violation List (By Violation View)
|
||||
.violation-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.violation-group {
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-critical {
|
||||
.group-header { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
.severity-icon { color: var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
&.severity-high {
|
||||
.group-header { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
.severity-icon { color: var(--color-error, #ea580c); }
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
.group-header { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
.severity-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
.group-header { border-left: 3px solid var(--color-info, #0284c7); }
|
||||
.severity-icon { color: var(--color-info, #0284c7); }
|
||||
}
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.severity-icon {
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.affected-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.group-details {
|
||||
padding: 0 1rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.remediation-hint {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.violations-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.field-path {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.highlighted {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.expected {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
&.actual.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.no-field,
|
||||
.no-value,
|
||||
.no-provenance {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.provenance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.source-type {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-id,
|
||||
.digest {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
// Document List (By Document View)
|
||||
.document-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.doc-type-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-id {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-details {
|
||||
padding: 0 1rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: normal;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.provenance-section,
|
||||
.violations-section,
|
||||
.raw-content-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.provenance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prov-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.url {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.doc-violation-item {
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.at-field {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.value-diff {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expected-row,
|
||||
.actual-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.actual-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-preview {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
|
||||
.field-name {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text, #374151);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="sources-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
[disabled]="verifying()"
|
||||
(click)="onVerifyLast24h()"
|
||||
>
|
||||
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading AOC metrics...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-state">
|
||||
<p class="error-message">{{ error() }}</p>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (metrics(); as m) {
|
||||
<div class="metrics-grid">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<div class="tile tile-pass-fail" [class]="passRateClass()">
|
||||
<h2 class="tile-title">AOC Pass/Fail</h2>
|
||||
<div class="tile-content">
|
||||
<div class="metric-large">
|
||||
<span class="value">{{ passRate() }}%</span>
|
||||
<span class="label">Pass Rate</span>
|
||||
</div>
|
||||
<div class="metric-details">
|
||||
<div class="detail">
|
||||
<span class="count pass">{{ m.passCount | number }}</span>
|
||||
<span class="label">Passed</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count fail">{{ m.failCount | number }}</span>
|
||||
<span class="label">Failed</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count total">{{ m.totalCount | number }}</span>
|
||||
<span class="label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Violations Tile -->
|
||||
<div class="tile tile-violations">
|
||||
<h2 class="tile-title">Recent Violations</h2>
|
||||
<div class="tile-content">
|
||||
@if (m.recentViolations.length === 0) {
|
||||
<p class="empty-state">No violations in time window</p>
|
||||
} @else {
|
||||
<ul class="violations-list">
|
||||
@for (v of m.recentViolations; track v.code) {
|
||||
<li class="violation-item" [class]="getSeverityClass(v.severity)">
|
||||
<div class="violation-header">
|
||||
<code class="violation-code">{{ v.code }}</code>
|
||||
<span class="violation-count">{{ v.count }}x</span>
|
||||
</div>
|
||||
<p class="violation-desc">{{ v.description }}</p>
|
||||
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<div class="tile tile-throughput" [class]="throughputStatus()">
|
||||
<h2 class="tile-title">Ingest Throughput</h2>
|
||||
<div class="tile-content">
|
||||
<div class="throughput-grid">
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
|
||||
<span class="label">docs/min</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
|
||||
<span class="label">avg ms</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
|
||||
<span class="label">p95 ms</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
|
||||
<span class="label">queue</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
|
||||
<span class="label">errors</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Result -->
|
||||
@if (verificationResult(); as result) {
|
||||
<div class="verification-result" [class]="'status-' + result.status">
|
||||
<h3>Verification Complete</h3>
|
||||
<div class="result-summary">
|
||||
<span class="status-badge">{{ result.status | titlecase }}</span>
|
||||
<span>Checked: {{ result.checkedCount | number }}</span>
|
||||
<span>Passed: {{ result.passedCount | number }}</span>
|
||||
<span>Failed: {{ result.failedCount | number }}</span>
|
||||
</div>
|
||||
@if (result.violations.length > 0) {
|
||||
<details class="violations-details">
|
||||
<summary>View {{ result.violations.length }} violation(s)</summary>
|
||||
<ul class="violation-list">
|
||||
@for (v of result.violations; track v.documentId) {
|
||||
<li>
|
||||
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
|
||||
@if (v.field) {
|
||||
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
<p class="cli-hint">
|
||||
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="time-window">
|
||||
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
|
||||
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,325 @@
|
||||
.sources-dashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-hover, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #d1d5db);
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e5e7eb);
|
||||
border-top-color: var(--color-primary, #2563eb);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error, #dc2626);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-bg-card, white);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-success, #059669); }
|
||||
&.good .metric-large .value { color: var(--color-success-muted, #10b981); }
|
||||
&.warning .metric-large .value { color: var(--color-warning, #d97706); }
|
||||
&.critical .metric-large .value { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.pass { color: var(--color-success, #059669); }
|
||||
&.fail { color: var(--color-error, #dc2626); }
|
||||
&.total { color: var(--color-text, #374151); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-error, #dc2626); }
|
||||
&.severity-high { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-info, #2563eb); }
|
||||
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: 0.8125rem;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-error, #dc2626); }
|
||||
&.warning .throughput-item .value { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); }
|
||||
&.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); }
|
||||
&.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-success, #059669); color: white; }
|
||||
&.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; }
|
||||
&.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: 0.75rem 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
code {
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
AocVerificationResult,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SourcesDashboardComponent implements OnInit {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly metrics = signal<AocMetrics | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly verificationResult = signal<AocVerificationResult | null>(null);
|
||||
|
||||
readonly passRate = computed(() => {
|
||||
const m = this.metrics();
|
||||
return m ? m.passRate.toFixed(2) : '0.00';
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.passRate >= 99.5) return 'excellent';
|
||||
if (m.passRate >= 95) return 'good';
|
||||
if (m.passRate >= 90) return 'warning';
|
||||
return 'critical';
|
||||
});
|
||||
|
||||
readonly throughputStatus = computed(() => {
|
||||
const m = this.metrics();
|
||||
if (!m) return 'neutral';
|
||||
if (m.ingestThroughput.queueDepth > 100) return 'critical';
|
||||
if (m.ingestThroughput.queueDepth > 50) return 'warning';
|
||||
return 'good';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics();
|
||||
}
|
||||
|
||||
loadMetrics(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.aocClient.getMetrics('default').subscribe({
|
||||
next: (metrics) => {
|
||||
this.metrics.set(metrics);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load AOC metrics');
|
||||
this.loading.set(false);
|
||||
console.error('AOC metrics error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onVerifyLast24h(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationResult.set(null);
|
||||
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
|
||||
next: (result) => {
|
||||
this.verificationResult.set(result);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.verifying.set(false);
|
||||
console.error('AOC verification error:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: AocViolationSummary['severity']): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return diffMins + 'm ago';
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return diffHours + 'h ago';
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return diffDays + 'd ago';
|
||||
}
|
||||
}
|
||||
@@ -1,200 +1,200 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,255 +1,255 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
AocChainEntry,
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
LinksetConflict,
|
||||
Observation,
|
||||
PolicyDecision,
|
||||
PolicyEvidence,
|
||||
PolicyRuleResult,
|
||||
SOURCE_INFO,
|
||||
SourceInfo,
|
||||
} from '../../core/api/evidence.models';
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
|
||||
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
|
||||
type ObservationView = 'side-by-side' | 'stacked';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './evidence-panel.component.html',
|
||||
styleUrls: ['./evidence-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePanelComponent {
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
// Inputs
|
||||
readonly advisoryId = input.required<string>();
|
||||
readonly evidenceData = input<EvidenceData | null>(null);
|
||||
|
||||
// Outputs
|
||||
readonly close = output<void>();
|
||||
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
|
||||
|
||||
// UI State
|
||||
readonly activeTab = signal<TabId>('observations');
|
||||
readonly observationView = signal<ObservationView>('side-by-side');
|
||||
readonly expandedObservation = signal<string | null>(null);
|
||||
readonly expandedAocEntry = signal<string | null>(null);
|
||||
readonly showConflictDetails = signal(false);
|
||||
|
||||
// Loading/error state
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
|
||||
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
|
||||
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
|
||||
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
|
||||
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
|
||||
|
||||
readonly aocChain = computed(() => {
|
||||
const policy = this.policyEvidence();
|
||||
return policy?.aocChain ?? [];
|
||||
});
|
||||
|
||||
readonly policyDecisionClass = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionClass(decision);
|
||||
});
|
||||
|
||||
readonly policyDecisionLabel = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionLabel(decision);
|
||||
});
|
||||
|
||||
readonly observationSources = computed(() => {
|
||||
const obs = this.observations();
|
||||
return obs.map((o) => this.getSourceInfo(o.source));
|
||||
});
|
||||
|
||||
// Tab methods
|
||||
setActiveTab(tab: TabId): void {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
|
||||
isActiveTab(tab: TabId): boolean {
|
||||
return this.activeTab() === tab;
|
||||
}
|
||||
|
||||
// Observation view methods
|
||||
setObservationView(view: ObservationView): void {
|
||||
this.observationView.set(view);
|
||||
}
|
||||
|
||||
toggleObservationExpanded(observationId: string): void {
|
||||
const current = this.expandedObservation();
|
||||
this.expandedObservation.set(current === observationId ? null : observationId);
|
||||
}
|
||||
|
||||
isObservationExpanded(observationId: string): boolean {
|
||||
return this.expandedObservation() === observationId;
|
||||
}
|
||||
|
||||
// AOC chain methods
|
||||
toggleAocEntry(attestationId: string): void {
|
||||
const current = this.expandedAocEntry();
|
||||
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
|
||||
}
|
||||
|
||||
isAocEntryExpanded(attestationId: string): boolean {
|
||||
return this.expandedAocEntry() === attestationId;
|
||||
}
|
||||
|
||||
// Conflict methods
|
||||
toggleConflictDetails(): void {
|
||||
this.showConflictDetails.update((v) => !v);
|
||||
}
|
||||
|
||||
// Source info helper
|
||||
getSourceInfo(sourceId: string): SourceInfo {
|
||||
return (
|
||||
SOURCE_INFO[sourceId] ?? {
|
||||
sourceId,
|
||||
name: sourceId.toUpperCase(),
|
||||
icon: 'file',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Decision helpers
|
||||
getDecisionClass(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'decision-pass';
|
||||
case 'warn':
|
||||
return 'decision-warn';
|
||||
case 'block':
|
||||
return 'decision-block';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'decision-pending';
|
||||
}
|
||||
}
|
||||
|
||||
getDecisionLabel(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'Passed';
|
||||
case 'warn':
|
||||
return 'Warning';
|
||||
case 'block':
|
||||
return 'Blocked';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Rule result helpers
|
||||
getRuleClass(passed: boolean): string {
|
||||
return passed ? 'rule-passed' : 'rule-failed';
|
||||
}
|
||||
|
||||
getRuleIcon(passed: boolean): string {
|
||||
return passed ? 'check-circle' : 'x-circle';
|
||||
}
|
||||
|
||||
// AOC chain helpers
|
||||
getAocTypeLabel(type: AocChainEntry['type']): string {
|
||||
switch (type) {
|
||||
case 'observation':
|
||||
return 'Observation';
|
||||
case 'linkset':
|
||||
return 'Linkset';
|
||||
case 'policy':
|
||||
return 'Policy Decision';
|
||||
case 'signature':
|
||||
return 'Signature';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
getAocTypeClass(type: AocChainEntry['type']): string {
|
||||
return `aoc-type-${type}`;
|
||||
}
|
||||
|
||||
// Severity helpers
|
||||
getSeverityClass(score: number): string {
|
||||
if (score >= 9.0) return 'severity-critical';
|
||||
if (score >= 7.0) return 'severity-high';
|
||||
if (score >= 4.0) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getSeverityLabel(score: number): string {
|
||||
if (score >= 9.0) return 'Critical';
|
||||
if (score >= 7.0) return 'High';
|
||||
if (score >= 4.0) return 'Medium';
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
// Download handlers
|
||||
onDownloadObservation(observationId: string): void {
|
||||
this.downloadDocument.emit({ type: 'observation', id: observationId });
|
||||
}
|
||||
|
||||
onDownloadLinkset(linksetId: string): void {
|
||||
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
|
||||
}
|
||||
|
||||
// Close handler
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash truncation for display
|
||||
truncateHash(hash: string | undefined, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
// Track by functions for ngFor
|
||||
trackByObservationId(_: number, obs: Observation): string {
|
||||
return obs.observationId;
|
||||
}
|
||||
|
||||
trackByAocId(_: number, entry: AocChainEntry): string {
|
||||
return entry.attestationId;
|
||||
}
|
||||
|
||||
trackByConflictField(_: number, conflict: LinksetConflict): string {
|
||||
return conflict.field;
|
||||
}
|
||||
|
||||
trackByRuleId(_: number, rule: PolicyRuleResult): string {
|
||||
return rule.ruleId;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
AocChainEntry,
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
LinksetConflict,
|
||||
Observation,
|
||||
PolicyDecision,
|
||||
PolicyEvidence,
|
||||
PolicyRuleResult,
|
||||
SOURCE_INFO,
|
||||
SourceInfo,
|
||||
} from '../../core/api/evidence.models';
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
|
||||
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
|
||||
type ObservationView = 'side-by-side' | 'stacked';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './evidence-panel.component.html',
|
||||
styleUrls: ['./evidence-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePanelComponent {
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
// Inputs
|
||||
readonly advisoryId = input.required<string>();
|
||||
readonly evidenceData = input<EvidenceData | null>(null);
|
||||
|
||||
// Outputs
|
||||
readonly close = output<void>();
|
||||
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
|
||||
|
||||
// UI State
|
||||
readonly activeTab = signal<TabId>('observations');
|
||||
readonly observationView = signal<ObservationView>('side-by-side');
|
||||
readonly expandedObservation = signal<string | null>(null);
|
||||
readonly expandedAocEntry = signal<string | null>(null);
|
||||
readonly showConflictDetails = signal(false);
|
||||
|
||||
// Loading/error state
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
|
||||
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
|
||||
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
|
||||
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
|
||||
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
|
||||
|
||||
readonly aocChain = computed(() => {
|
||||
const policy = this.policyEvidence();
|
||||
return policy?.aocChain ?? [];
|
||||
});
|
||||
|
||||
readonly policyDecisionClass = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionClass(decision);
|
||||
});
|
||||
|
||||
readonly policyDecisionLabel = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionLabel(decision);
|
||||
});
|
||||
|
||||
readonly observationSources = computed(() => {
|
||||
const obs = this.observations();
|
||||
return obs.map((o) => this.getSourceInfo(o.source));
|
||||
});
|
||||
|
||||
// Tab methods
|
||||
setActiveTab(tab: TabId): void {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
|
||||
isActiveTab(tab: TabId): boolean {
|
||||
return this.activeTab() === tab;
|
||||
}
|
||||
|
||||
// Observation view methods
|
||||
setObservationView(view: ObservationView): void {
|
||||
this.observationView.set(view);
|
||||
}
|
||||
|
||||
toggleObservationExpanded(observationId: string): void {
|
||||
const current = this.expandedObservation();
|
||||
this.expandedObservation.set(current === observationId ? null : observationId);
|
||||
}
|
||||
|
||||
isObservationExpanded(observationId: string): boolean {
|
||||
return this.expandedObservation() === observationId;
|
||||
}
|
||||
|
||||
// AOC chain methods
|
||||
toggleAocEntry(attestationId: string): void {
|
||||
const current = this.expandedAocEntry();
|
||||
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
|
||||
}
|
||||
|
||||
isAocEntryExpanded(attestationId: string): boolean {
|
||||
return this.expandedAocEntry() === attestationId;
|
||||
}
|
||||
|
||||
// Conflict methods
|
||||
toggleConflictDetails(): void {
|
||||
this.showConflictDetails.update((v) => !v);
|
||||
}
|
||||
|
||||
// Source info helper
|
||||
getSourceInfo(sourceId: string): SourceInfo {
|
||||
return (
|
||||
SOURCE_INFO[sourceId] ?? {
|
||||
sourceId,
|
||||
name: sourceId.toUpperCase(),
|
||||
icon: 'file',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Decision helpers
|
||||
getDecisionClass(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'decision-pass';
|
||||
case 'warn':
|
||||
return 'decision-warn';
|
||||
case 'block':
|
||||
return 'decision-block';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'decision-pending';
|
||||
}
|
||||
}
|
||||
|
||||
getDecisionLabel(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'Passed';
|
||||
case 'warn':
|
||||
return 'Warning';
|
||||
case 'block':
|
||||
return 'Blocked';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Rule result helpers
|
||||
getRuleClass(passed: boolean): string {
|
||||
return passed ? 'rule-passed' : 'rule-failed';
|
||||
}
|
||||
|
||||
getRuleIcon(passed: boolean): string {
|
||||
return passed ? 'check-circle' : 'x-circle';
|
||||
}
|
||||
|
||||
// AOC chain helpers
|
||||
getAocTypeLabel(type: AocChainEntry['type']): string {
|
||||
switch (type) {
|
||||
case 'observation':
|
||||
return 'Observation';
|
||||
case 'linkset':
|
||||
return 'Linkset';
|
||||
case 'policy':
|
||||
return 'Policy Decision';
|
||||
case 'signature':
|
||||
return 'Signature';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
getAocTypeClass(type: AocChainEntry['type']): string {
|
||||
return `aoc-type-${type}`;
|
||||
}
|
||||
|
||||
// Severity helpers
|
||||
getSeverityClass(score: number): string {
|
||||
if (score >= 9.0) return 'severity-critical';
|
||||
if (score >= 7.0) return 'severity-high';
|
||||
if (score >= 4.0) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getSeverityLabel(score: number): string {
|
||||
if (score >= 9.0) return 'Critical';
|
||||
if (score >= 7.0) return 'High';
|
||||
if (score >= 4.0) return 'Medium';
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
// Download handlers
|
||||
onDownloadObservation(observationId: string): void {
|
||||
this.downloadDocument.emit({ type: 'observation', id: observationId });
|
||||
}
|
||||
|
||||
onDownloadLinkset(linksetId: string): void {
|
||||
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
|
||||
}
|
||||
|
||||
// Close handler
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash truncation for display
|
||||
truncateHash(hash: string | undefined, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
// Track by functions for ngFor
|
||||
trackByObservationId(_: number, obs: Observation): string {
|
||||
return obs.observationId;
|
||||
}
|
||||
|
||||
trackByAocId(_: number, entry: AocChainEntry): string {
|
||||
return entry.attestationId;
|
||||
}
|
||||
|
||||
trackByConflictField(_: number, conflict: LinksetConflict): string {
|
||||
return conflict.field;
|
||||
}
|
||||
|
||||
trackByRuleId(_: number, rule: PolicyRuleResult): string {
|
||||
return rule.ruleId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
|
||||
@@ -1,489 +1,302 @@
|
||||
<div class="exception-center" role="main" aria-label="Exception Center">
|
||||
<!-- Screen reader announcements (ARIA live region) -->
|
||||
<div
|
||||
class="sr-only"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{{ screenReaderAnnouncement() }}
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts hint -->
|
||||
<div class="keyboard-hints" aria-hidden="true">
|
||||
<span class="keyboard-hint">
|
||||
<kbd>X</kbd> Create
|
||||
</span>
|
||||
<span class="keyboard-hint">
|
||||
<kbd>A</kbd> Approve
|
||||
</span>
|
||||
<span class="keyboard-hint">
|
||||
<kbd>R</kbd> Reject
|
||||
</span>
|
||||
<span class="keyboard-hint">
|
||||
<kbd>Esc</kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="exception-center__header">
|
||||
<div class="exception-center__title-section">
|
||||
<h1>Exception Center</h1>
|
||||
<p class="exception-center__subtitle">Manage policy exceptions with workflow approvals</p>
|
||||
</div>
|
||||
|
||||
<div class="exception-center__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="refreshData()"
|
||||
[disabled]="loading()"
|
||||
aria-label="Refresh exception list"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="openWizard()"
|
||||
aria-label="Create new exception (keyboard shortcut: X)"
|
||||
title="Create Exception (X)"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="exception-center__stats" *ngIf="stats() as s">
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ s.total }}</span>
|
||||
<span class="stat-card__label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--pending">
|
||||
<span class="stat-card__value">{{ s.pendingApproval }}</span>
|
||||
<span class="stat-card__label">Pending Review</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--warning">
|
||||
<span class="stat-card__value">{{ s.expiringWithin7Days }}</span>
|
||||
<span class="stat-card__label">Expiring Soon</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--approved">
|
||||
<span class="stat-card__value">{{ s.byStatus['approved'] ?? 0 }}</span>
|
||||
<span class="stat-card__label">Approved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Toast -->
|
||||
<div
|
||||
class="exception-center__message"
|
||||
*ngIf="message() as msg"
|
||||
[class.exception-center__message--success]="messageType() === 'success'"
|
||||
[class.exception-center__message--error]="messageType() === 'error'"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="exception-center__toolbar">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'list'"
|
||||
(click)="setViewMode('list')"
|
||||
title="List view"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'kanban'"
|
||||
(click)="setViewMode('kanban')"
|
||||
title="Kanban view"
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
class="search-box__input"
|
||||
placeholder="Search exceptions..."
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="search-box__clear"
|
||||
*ngIf="searchQuery()"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="statusFilter()"
|
||||
(change)="setStatusFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option *ngFor="let status of allStatuses" [value]="status">
|
||||
{{ statusLabels[status] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Severity</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="severityFilter()"
|
||||
(change)="setSeverityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option *ngFor="let sev of allSeverities" [value]="sev">
|
||||
{{ severityLabels[sev] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="exception-center__loading" *ngIf="loading()">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading exceptions...</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="exception-center__content" *ngIf="!loading()">
|
||||
<!-- List View -->
|
||||
<div class="list-view" *ngIf="viewMode() === 'list'">
|
||||
<table class="exception-table" *ngIf="filteredExceptions().length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('name')">
|
||||
Name {{ getSortIcon('name') }}
|
||||
</th>
|
||||
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('status')">
|
||||
Status {{ getSortIcon('status') }}
|
||||
</th>
|
||||
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('severity')">
|
||||
Severity {{ getSortIcon('severity') }}
|
||||
</th>
|
||||
<th class="exception-table__th">Scope</th>
|
||||
<th class="exception-table__th">Timebox</th>
|
||||
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('createdAt')">
|
||||
Created {{ getSortIcon('createdAt') }}
|
||||
</th>
|
||||
<th class="exception-table__th">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let exc of filteredExceptions(); trackBy: trackByException"
|
||||
class="exception-table__row"
|
||||
[class.exception-table__row--selected]="selectedExceptionId() === exc.exceptionId"
|
||||
(click)="selectException(exc.exceptionId)"
|
||||
>
|
||||
<td class="exception-table__td">
|
||||
<div class="exception-name">
|
||||
<span class="exception-name__title">{{ exc.displayName || exc.name }}</span>
|
||||
<span class="exception-name__id">{{ exc.exceptionId }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="exception-table__td">
|
||||
<span class="chip" [ngClass]="getStatusClass(exc.status)">
|
||||
{{ statusLabels[exc.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="exception-table__td">
|
||||
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
|
||||
{{ severityLabels[exc.severity] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="exception-table__td">
|
||||
<span class="scope-badge">{{ exc.scope.type }}</span>
|
||||
</td>
|
||||
<td class="exception-table__td">
|
||||
<div class="timebox">
|
||||
<span>{{ formatDate(exc.timebox.startDate) }}</span>
|
||||
<span class="timebox__separator">-</span>
|
||||
<span [class.timebox__expiring]="isExpiringSoon(exc)">
|
||||
{{ formatDate(exc.timebox.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="exception-table__td">
|
||||
{{ formatDate(exc.createdAt) }}
|
||||
</td>
|
||||
<td class="exception-table__td exception-table__td--actions">
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
|
||||
type="button"
|
||||
class="btn btn--small btn--action"
|
||||
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
|
||||
>
|
||||
{{ statusLabels[targetStatus] }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--small btn--danger"
|
||||
(click)="deleteException(exc.exceptionId); $event.stopPropagation()"
|
||||
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty-state" *ngIf="filteredExceptions().length === 0">
|
||||
<p>No exceptions found matching your filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<div class="kanban-view" *ngIf="viewMode() === 'kanban'">
|
||||
<div
|
||||
class="kanban-column"
|
||||
*ngFor="let column of kanbanColumns(); trackBy: trackByColumn"
|
||||
>
|
||||
<div class="kanban-column__header">
|
||||
<h3 class="kanban-column__title">{{ column.label }}</h3>
|
||||
<span class="kanban-column__count">{{ column.count }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kanban-column__cards">
|
||||
<div
|
||||
class="kanban-card"
|
||||
*ngFor="let exc of column.items; trackBy: trackByException"
|
||||
[class.kanban-card--selected]="selectedExceptionId() === exc.exceptionId"
|
||||
(click)="selectException(exc.exceptionId)"
|
||||
>
|
||||
<div class="kanban-card__header">
|
||||
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
|
||||
{{ severityLabels[exc.severity] }}
|
||||
</span>
|
||||
<span class="kanban-card__expiry" *ngIf="isExpiringSoon(exc)">
|
||||
Expiring soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 class="kanban-card__title">{{ exc.displayName || exc.name }}</h4>
|
||||
|
||||
<p class="kanban-card__description" *ngIf="exc.description">
|
||||
{{ exc.description | slice:0:80 }}{{ exc.description.length > 80 ? '...' : '' }}
|
||||
</p>
|
||||
|
||||
<div class="kanban-card__meta">
|
||||
<span class="scope-badge scope-badge--small">{{ exc.scope.type }}</span>
|
||||
<span class="kanban-card__date">{{ formatDate(exc.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kanban-card__actions">
|
||||
<button
|
||||
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
|
||||
type="button"
|
||||
class="btn btn--small btn--action"
|
||||
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
|
||||
>
|
||||
{{ statusLabels[targetStatus] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kanban-column__empty" *ngIf="column.items.length === 0">
|
||||
No exceptions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel (Side Panel) -->
|
||||
<div class="detail-panel" *ngIf="selectedException() as exc">
|
||||
<div class="detail-panel__header">
|
||||
<h2>{{ exc.displayName || exc.name }}</h2>
|
||||
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel__content">
|
||||
<div class="detail-section">
|
||||
<h3>Status</h3>
|
||||
<span class="chip chip--large" [ngClass]="getStatusClass(exc.status)">
|
||||
{{ statusLabels[exc.status] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Severity</h3>
|
||||
<span class="chip chip--large" [ngClass]="getSeverityClass(exc.severity)">
|
||||
{{ severityLabels[exc.severity] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Description</h3>
|
||||
<p>{{ exc.description || 'No description provided.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Scope</h3>
|
||||
<div class="scope-details">
|
||||
<div class="scope-detail">
|
||||
<span class="scope-detail__label">Type:</span>
|
||||
<span class="scope-detail__value">{{ exc.scope.type }}</span>
|
||||
</div>
|
||||
<div class="scope-detail" *ngIf="exc.scope.vulnIds?.length">
|
||||
<span class="scope-detail__label">Vulnerabilities:</span>
|
||||
<span class="scope-detail__value">{{ exc.scope.vulnIds.join(', ') }}</span>
|
||||
</div>
|
||||
<div class="scope-detail" *ngIf="exc.scope.componentPurls?.length">
|
||||
<span class="scope-detail__label">Components:</span>
|
||||
<span class="scope-detail__value">{{ exc.scope.componentPurls.join(', ') }}</span>
|
||||
</div>
|
||||
<div class="scope-detail" *ngIf="exc.scope.assetIds?.length">
|
||||
<span class="scope-detail__label">Assets:</span>
|
||||
<span class="scope-detail__value">{{ exc.scope.assetIds.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Justification</h3>
|
||||
<p>{{ exc.justification.text }}</p>
|
||||
<span class="chip chip--small" *ngIf="exc.justification.template">
|
||||
Template: {{ exc.justification.template }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Timebox</h3>
|
||||
<div class="timebox-details">
|
||||
<div class="timebox-detail">
|
||||
<span class="timebox-detail__label">Start:</span>
|
||||
<span class="timebox-detail__value">{{ formatDateTime(exc.timebox.startDate) }}</span>
|
||||
</div>
|
||||
<div class="timebox-detail">
|
||||
<span class="timebox-detail__label">End:</span>
|
||||
<span class="timebox-detail__value" [class.timebox__expiring]="isExpiringSoon(exc)">
|
||||
{{ formatDateTime(exc.timebox.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timebox-detail" *ngIf="exc.timebox.autoRenew">
|
||||
<span class="timebox-detail__label">Auto-renew:</span>
|
||||
<span class="timebox-detail__value">Yes ({{ exc.timebox.renewalCount || 0 }}/{{ exc.timebox.maxRenewals || 'unlimited' }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" *ngIf="exc.approvals?.length">
|
||||
<h3>Approvals</h3>
|
||||
<div class="approval-list">
|
||||
<div class="approval-item" *ngFor="let approval of exc.approvals">
|
||||
<div class="approval-item__header">
|
||||
<span class="approval-item__by">{{ approval.approvedBy }}</span>
|
||||
<span class="approval-item__date">{{ formatDateTime(approval.approvedAt) }}</span>
|
||||
</div>
|
||||
<p class="approval-item__comment" *ngIf="approval.comment">{{ approval.comment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Metadata</h3>
|
||||
<div class="metadata-grid">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-item__label">Created by:</span>
|
||||
<span class="metadata-item__value">{{ exc.createdBy }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-item__label">Created at:</span>
|
||||
<span class="metadata-item__value">{{ formatDateTime(exc.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="metadata-item" *ngIf="exc.updatedBy">
|
||||
<span class="metadata-item__label">Updated by:</span>
|
||||
<span class="metadata-item__value">{{ exc.updatedBy }}</span>
|
||||
</div>
|
||||
<div class="metadata-item" *ngIf="exc.updatedAt">
|
||||
<span class="metadata-item__label">Updated at:</span>
|
||||
<span class="metadata-item__value">{{ formatDateTime(exc.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Trail Toggle -->
|
||||
<div class="detail-section">
|
||||
<button type="button" class="btn btn--secondary" (click)="toggleAuditPanel()">
|
||||
{{ showAuditPanel() ? 'Hide' : 'Show' }} Audit Trail
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audit Trail -->
|
||||
<div class="detail-section" *ngIf="showAuditPanel() && exc.auditTrail?.length">
|
||||
<h3>Audit Trail</h3>
|
||||
<div class="audit-list">
|
||||
<div class="audit-item" *ngFor="let entry of exc.auditTrail; trackBy: trackByAudit">
|
||||
<div class="audit-item__header">
|
||||
<span class="audit-item__action">{{ entry.action }}</span>
|
||||
<span class="audit-item__date">{{ formatDateTime(entry.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="audit-item__actor">by {{ entry.actor }}</div>
|
||||
<div class="audit-item__transition" *ngIf="entry.previousStatus && entry.newStatus">
|
||||
{{ statusLabels[entry.previousStatus] }} → {{ statusLabels[entry.newStatus] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" *ngIf="showAuditPanel() && !exc.auditTrail?.length">
|
||||
<p class="empty-audit">No audit entries recorded.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions">
|
||||
<button
|
||||
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="transitionStatus(exc.exceptionId, targetStatus)"
|
||||
>
|
||||
{{ statusLabels[targetStatus] }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--danger"
|
||||
(click)="deleteException(exc.exceptionId)"
|
||||
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wizard Modal -->
|
||||
<div class="wizard-modal" *ngIf="showWizard()">
|
||||
<div class="wizard-modal__backdrop" (click)="closeWizard()"></div>
|
||||
<div class="wizard-modal__container">
|
||||
<app-exception-wizard
|
||||
(created)="onExceptionCreated($event)"
|
||||
(cancelled)="closeWizard()"
|
||||
></app-exception-wizard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exception-center">
|
||||
<!-- Header -->
|
||||
<header class="center-header">
|
||||
<div class="header-left">
|
||||
<h2 class="center-title">Exception Center</h2>
|
||||
<div class="status-chips">
|
||||
@for (col of kanbanColumns; track col.status) {
|
||||
<span
|
||||
class="status-chip"
|
||||
[style.borderColor]="col.color"
|
||||
[class.active]="filter().status?.includes(col.status)"
|
||||
(click)="updateFilter('status', filter().status?.includes(col.status)
|
||||
? filter().status?.filter(s => s !== col.status)
|
||||
: [...(filter().status || []), col.status])"
|
||||
>
|
||||
{{ col.label }}
|
||||
<span class="chip-count">{{ statusCounts()[col.status] || 0 }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'list'"
|
||||
(click)="setViewMode('list')"
|
||||
title="List view"
|
||||
>
|
||||
=
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'kanban'"
|
||||
(click)="setViewMode('kanban')"
|
||||
title="Kanban view"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-filter" (click)="toggleFilters()" [class.active]="showFilters()">
|
||||
Filters
|
||||
</button>
|
||||
<button class="btn-create" (click)="onCreate()">
|
||||
+ New Exception
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters Panel -->
|
||||
@if (showFilters()) {
|
||||
<div class="filters-panel">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Search</label>
|
||||
<input
|
||||
type="search"
|
||||
class="filter-input"
|
||||
placeholder="Search exceptions..."
|
||||
[value]="filter().search || ''"
|
||||
(input)="updateFilter('search', $any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Type</label>
|
||||
<div class="filter-chips">
|
||||
@for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) {
|
||||
<button
|
||||
class="filter-chip"
|
||||
[class.active]="filter().type?.includes($any(type))"
|
||||
(click)="updateFilter('type', filter().type?.includes($any(type))
|
||||
? filter().type?.filter(t => t !== type)
|
||||
: [...(filter().type || []), type])"
|
||||
>
|
||||
{{ type | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Severity</label>
|
||||
<div class="filter-chips">
|
||||
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
|
||||
<button
|
||||
class="filter-chip"
|
||||
[class]="'sev-' + sev"
|
||||
[class.active]="filter().severity?.includes(sev)"
|
||||
(click)="updateFilter('severity', filter().severity?.includes(sev)
|
||||
? filter().severity?.filter(s => s !== sev)
|
||||
: [...(filter().severity || []), sev])"
|
||||
>
|
||||
{{ sev | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Tags</label>
|
||||
<div class="filter-chips tags">
|
||||
@for (tag of allTags().slice(0, 8); track tag) {
|
||||
<button
|
||||
class="filter-chip tag"
|
||||
[class.active]="filter().tags?.includes(tag)"
|
||||
(click)="updateFilter('tags', filter().tags?.includes(tag)
|
||||
? filter().tags?.filter(t => t !== tag)
|
||||
: [...(filter().tags || []), tag])"
|
||||
>
|
||||
{{ tag }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="filter().expiringSoon"
|
||||
(change)="updateFilter('expiringSoon', $any($event.target).checked)"
|
||||
/>
|
||||
Expiring soon
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-clear-filters" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- List View -->
|
||||
@if (viewMode() === 'list') {
|
||||
<div class="list-view">
|
||||
<!-- Sort Header -->
|
||||
<div class="list-header">
|
||||
<button class="sort-btn" (click)="setSort('title')">
|
||||
Title
|
||||
@if (sort().field === 'title') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<button class="sort-btn" (click)="setSort('severity')">
|
||||
Severity
|
||||
@if (sort().field === 'severity') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<span class="col-header">Status</span>
|
||||
<button class="sort-btn" (click)="setSort('expiresAt')">
|
||||
Expires
|
||||
@if (sort().field === 'expiresAt') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<button class="sort-btn" (click)="setSort('updatedAt')">
|
||||
Updated
|
||||
@if (sort().field === 'updatedAt') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<span class="col-header">Actions</span>
|
||||
</div>
|
||||
|
||||
<!-- Exception Rows -->
|
||||
<div class="list-body">
|
||||
@for (exc of filteredExceptions(); track exc.id) {
|
||||
<div class="exception-row" [class]="'status-' + exc.status">
|
||||
<button class="row-main" (click)="onSelect(exc)">
|
||||
<div class="exc-title-cell">
|
||||
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
|
||||
<div class="exc-title-info">
|
||||
<span class="exc-title">{{ exc.title }}</span>
|
||||
<span class="exc-id">{{ exc.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exc-severity-cell">
|
||||
<span class="severity-badge" [class]="getSeverityClass(exc.severity)">
|
||||
{{ exc.severity | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exc-status-cell">
|
||||
<span class="status-badge" [class]="'status-' + exc.status">
|
||||
{{ getStatusIcon(exc.status) }} {{ exc.status | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exc-expires-cell">
|
||||
<span
|
||||
class="expires-text"
|
||||
[class.warning]="exc.timebox.isWarning"
|
||||
[class.expired]="exc.timebox.isExpired"
|
||||
>
|
||||
{{ formatRemainingDays(exc.timebox.remainingDays) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exc-updated-cell">
|
||||
{{ formatDate(exc.updatedAt) }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="row-actions">
|
||||
@for (trans of getAvailableTransitions(exc); track trans.to) {
|
||||
<button
|
||||
class="action-btn"
|
||||
[title]="trans.action"
|
||||
(click)="onTransition(exc, trans.to)"
|
||||
>
|
||||
{{ trans.action }}
|
||||
</button>
|
||||
}
|
||||
<button class="action-btn audit" (click)="onViewAudit(exc)" title="View audit log">
|
||||
[A]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredExceptions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No exceptions match the current filters</p>
|
||||
<button class="btn-link" (click)="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Kanban View -->
|
||||
@if (viewMode() === 'kanban') {
|
||||
<div class="kanban-view">
|
||||
@for (col of kanbanColumns; track col.status) {
|
||||
<div class="kanban-column">
|
||||
<div class="column-header" [style.borderColor]="col.color">
|
||||
<span class="column-title">{{ col.label }}</span>
|
||||
<span class="column-count">{{ exceptionsByStatus().get(col.status)?.length || 0 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="column-body">
|
||||
@for (exc of exceptionsByStatus().get(col.status) || []; track exc.id) {
|
||||
<div class="kanban-card" [class]="getSeverityClass(exc.severity)">
|
||||
<button class="card-main" (click)="onSelect(exc)">
|
||||
<div class="card-header">
|
||||
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
|
||||
<span class="severity-dot" [class]="getSeverityClass(exc.severity)"></span>
|
||||
</div>
|
||||
<h4 class="card-title">{{ exc.title }}</h4>
|
||||
<p class="card-id">{{ exc.id }}</p>
|
||||
|
||||
<div class="card-meta">
|
||||
<span
|
||||
class="expires-badge"
|
||||
[class.warning]="exc.timebox.isWarning"
|
||||
[class.expired]="exc.timebox.isExpired"
|
||||
>
|
||||
{{ formatRemainingDays(exc.timebox.remainingDays) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (exc.tags.length > 0) {
|
||||
<div class="card-tags">
|
||||
@for (tag of exc.tags.slice(0, 3); track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div class="card-actions">
|
||||
@for (trans of getAvailableTransitions(exc); track trans.to) {
|
||||
<button
|
||||
class="card-action-btn"
|
||||
(click)="onTransition(exc, trans.to)"
|
||||
>
|
||||
{{ trans.action }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) {
|
||||
<div class="column-empty">No exceptions</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<footer class="center-footer">
|
||||
<span class="total-count">
|
||||
{{ filteredExceptions().length }} of {{ exceptions().length }} exceptions
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,564 +1,246 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
MockExceptionApiService,
|
||||
} from '../../core/api/exception.client';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionKanbanColumn,
|
||||
ExceptionSeverity,
|
||||
ExceptionStats,
|
||||
ExceptionStatus,
|
||||
ExceptionsQueryOptions,
|
||||
} from '../../core/api/exception.models';
|
||||
import { ExceptionWizardComponent } from './exception-wizard.component';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
type StatusFilter = ExceptionStatus | 'all';
|
||||
type SeverityFilter = ExceptionSeverity | 'all';
|
||||
type SortField = 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const STATUS_LABELS: Record<ExceptionStatus, string> = {
|
||||
draft: 'Draft',
|
||||
pending_review: 'Pending Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
};
|
||||
|
||||
const SEVERITY_LABELS: Record<ExceptionSeverity, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
};
|
||||
|
||||
const KANBAN_COLUMN_ORDER: ExceptionStatus[] = [
|
||||
'draft',
|
||||
'pending_review',
|
||||
'approved',
|
||||
'rejected',
|
||||
'expired',
|
||||
'revoked',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, ExceptionWizardComponent],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
|
||||
],
|
||||
})
|
||||
export class ExceptionCenterComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
// Screen reader announcements
|
||||
readonly screenReaderAnnouncement = signal('');
|
||||
private announcementTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
// View state
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly showWizard = signal(false);
|
||||
|
||||
// Data
|
||||
readonly exceptions = signal<Exception[]>([]);
|
||||
readonly stats = signal<ExceptionStats | null>(null);
|
||||
readonly selectedExceptionId = signal<string | null>(null);
|
||||
|
||||
// Filters & sorting
|
||||
readonly statusFilter = signal<StatusFilter>('all');
|
||||
readonly severityFilter = signal<SeverityFilter>('all');
|
||||
readonly searchQuery = signal('');
|
||||
readonly sortField = signal<SortField>('createdAt');
|
||||
readonly sortOrder = signal<SortOrder>('desc');
|
||||
|
||||
// Constants for template
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly severityLabels = SEVERITY_LABELS;
|
||||
readonly allStatuses: ExceptionStatus[] = KANBAN_COLUMN_ORDER;
|
||||
readonly allSeverities: ExceptionSeverity[] = ['critical', 'high', 'medium', 'low'];
|
||||
|
||||
// Computed: filtered and sorted list
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let items = [...this.exceptions()];
|
||||
const status = this.statusFilter();
|
||||
const severity = this.severityFilter();
|
||||
const search = this.searchQuery().toLowerCase();
|
||||
|
||||
if (status !== 'all') {
|
||||
items = items.filter((e) => e.status === status);
|
||||
}
|
||||
if (severity !== 'all') {
|
||||
items = items.filter((e) => e.severity === severity);
|
||||
}
|
||||
if (search) {
|
||||
items = items.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(search) ||
|
||||
e.displayName?.toLowerCase().includes(search) ||
|
||||
e.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return this.sortExceptions(items);
|
||||
});
|
||||
|
||||
// Computed: kanban columns
|
||||
readonly kanbanColumns = computed<ExceptionKanbanColumn[]>(() => {
|
||||
const items = this.exceptions();
|
||||
const severity = this.severityFilter();
|
||||
const search = this.searchQuery().toLowerCase();
|
||||
|
||||
let filtered = items;
|
||||
if (severity !== 'all') {
|
||||
filtered = filtered.filter((e) => e.severity === severity);
|
||||
}
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(search) ||
|
||||
e.displayName?.toLowerCase().includes(search) ||
|
||||
e.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return KANBAN_COLUMN_ORDER.map((status) => {
|
||||
const columnItems = filtered.filter((e) => e.status === status);
|
||||
return {
|
||||
status,
|
||||
label: STATUS_LABELS[status],
|
||||
items: columnItems,
|
||||
count: columnItems.length,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Computed: selected exception
|
||||
readonly selectedException = computed(() => {
|
||||
const id = this.selectedExceptionId();
|
||||
if (!id) return null;
|
||||
return this.exceptions().find((e) => e.exceptionId === id) ?? null;
|
||||
});
|
||||
|
||||
// Search form
|
||||
readonly searchForm = this.formBuilder.group({
|
||||
query: this.formBuilder.control(''),
|
||||
});
|
||||
|
||||
// Audit view state
|
||||
readonly showAuditPanel = signal(false);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadData();
|
||||
this.announceToScreenReader('Exception Center loaded. Press X to create exception, A to approve, R to reject.');
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.announcementTimeout) {
|
||||
clearTimeout(this.announcementTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyboardShortcut(event: KeyboardEvent): void {
|
||||
// Ignore shortcuts when typing in form fields
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if modifier keys are pressed
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'x':
|
||||
event.preventDefault();
|
||||
this.handleCreateShortcut();
|
||||
break;
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
this.handleApproveShortcut();
|
||||
break;
|
||||
case 'r':
|
||||
event.preventDefault();
|
||||
this.handleRejectShortcut();
|
||||
break;
|
||||
case 'escape':
|
||||
if (this.showWizard()) {
|
||||
event.preventDefault();
|
||||
this.closeWizard();
|
||||
} else if (this.selectedExceptionId()) {
|
||||
event.preventDefault();
|
||||
this.clearSelection();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleCreateShortcut(): void {
|
||||
if (this.showWizard()) {
|
||||
return; // Wizard already open
|
||||
}
|
||||
this.openWizard();
|
||||
this.announceToScreenReader('Exception creation wizard opened');
|
||||
}
|
||||
|
||||
private handleApproveShortcut(): void {
|
||||
const selected = this.selectedException();
|
||||
if (!selected) {
|
||||
this.announceToScreenReader('No exception selected. Select an exception first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.status !== 'pending_review') {
|
||||
this.announceToScreenReader(`Cannot approve exception. Current status is ${STATUS_LABELS[selected.status]}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.approveException(selected.exceptionId);
|
||||
}
|
||||
|
||||
private handleRejectShortcut(): void {
|
||||
const selected = this.selectedException();
|
||||
if (!selected) {
|
||||
this.announceToScreenReader('No exception selected. Select an exception first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.status !== 'pending_review') {
|
||||
this.announceToScreenReader(`Cannot reject exception. Current status is ${STATUS_LABELS[selected.status]}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.rejectException(selected.exceptionId);
|
||||
}
|
||||
|
||||
// Screen reader announcements
|
||||
private announceToScreenReader(message: string): void {
|
||||
// Clear any pending announcement
|
||||
if (this.announcementTimeout) {
|
||||
clearTimeout(this.announcementTimeout);
|
||||
}
|
||||
|
||||
// Set the announcement (this triggers the ARIA live region)
|
||||
this.screenReaderAnnouncement.set(message);
|
||||
|
||||
// Clear after a short delay to allow re-announcement of same message
|
||||
this.announcementTimeout = setTimeout(() => {
|
||||
this.screenReaderAnnouncement.set('');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const [exceptionsResponse, statsResponse] = await Promise.all([
|
||||
firstValueFrom(this.api.listExceptions()),
|
||||
firstValueFrom(this.api.getStats()),
|
||||
]);
|
||||
|
||||
this.exceptions.set([...exceptionsResponse.items]);
|
||||
this.stats.set(statsResponse);
|
||||
} catch (error) {
|
||||
this.showMessage(this.toErrorMessage(error), 'error');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshData(): Promise<void> {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
setStatusFilter(status: StatusFilter): void {
|
||||
this.statusFilter.set(status);
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: SeverityFilter): void {
|
||||
this.severityFilter.set(severity);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery.set('');
|
||||
this.searchForm.patchValue({ query: '' });
|
||||
}
|
||||
|
||||
// Sorting
|
||||
toggleSort(field: SortField): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortOrder.set('desc');
|
||||
}
|
||||
}
|
||||
|
||||
getSortIcon(field: SortField): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortOrder() === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectException(exceptionId: string): void {
|
||||
this.selectedExceptionId.set(exceptionId);
|
||||
this.showAuditPanel.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedExceptionId.set(null);
|
||||
this.showAuditPanel.set(false);
|
||||
}
|
||||
|
||||
// Workflow transitions
|
||||
async transitionStatus(exceptionId: string, newStatus: ExceptionStatus): Promise<void> {
|
||||
const exception = this.exceptions().find((e) => e.exceptionId === exceptionId);
|
||||
const exceptionName = exception?.name ?? 'Exception';
|
||||
|
||||
this.loading.set(true);
|
||||
this.announceToScreenReader(`Processing ${exceptionName}...`);
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.api.transitionStatus({ exceptionId, newStatus })
|
||||
);
|
||||
await this.loadData();
|
||||
const successMessage = `${exceptionName} transitioned to ${STATUS_LABELS[newStatus]}`;
|
||||
this.showMessage(successMessage, 'success');
|
||||
this.announceToScreenReader(successMessage);
|
||||
} catch (error) {
|
||||
const errorMessage = this.toErrorMessage(error);
|
||||
this.showMessage(errorMessage, 'error');
|
||||
this.announceToScreenReader(`Error: ${errorMessage}`);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async submitForReview(exceptionId: string): Promise<void> {
|
||||
this.announceToScreenReader('Submitting exception for review...');
|
||||
await this.transitionStatus(exceptionId, 'pending_review');
|
||||
}
|
||||
|
||||
async approveException(exceptionId: string): Promise<void> {
|
||||
this.announceToScreenReader('Approving exception...');
|
||||
await this.transitionStatus(exceptionId, 'approved');
|
||||
}
|
||||
|
||||
async rejectException(exceptionId: string): Promise<void> {
|
||||
this.announceToScreenReader('Rejecting exception...');
|
||||
await this.transitionStatus(exceptionId, 'rejected');
|
||||
}
|
||||
|
||||
async revokeException(exceptionId: string): Promise<void> {
|
||||
this.announceToScreenReader('Revoking exception...');
|
||||
await this.transitionStatus(exceptionId, 'revoked');
|
||||
}
|
||||
|
||||
// Delete
|
||||
async deleteException(exceptionId: string): Promise<void> {
|
||||
if (!confirm('Are you sure you want to delete this exception?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
try {
|
||||
await firstValueFrom(this.api.deleteException(exceptionId));
|
||||
if (this.selectedExceptionId() === exceptionId) {
|
||||
this.clearSelection();
|
||||
}
|
||||
await this.loadData();
|
||||
this.showMessage('Exception deleted', 'success');
|
||||
} catch (error) {
|
||||
this.showMessage(this.toErrorMessage(error), 'error');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit panel
|
||||
toggleAuditPanel(): void {
|
||||
this.showAuditPanel.set(!this.showAuditPanel());
|
||||
}
|
||||
|
||||
// Wizard
|
||||
openWizard(): void {
|
||||
this.showWizard.set(true);
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.showWizard.set(false);
|
||||
}
|
||||
|
||||
async onExceptionCreated(exception: Exception): Promise<void> {
|
||||
this.closeWizard();
|
||||
await this.loadData();
|
||||
this.showMessage(`Exception "${exception.name}" created`, 'success');
|
||||
this.selectException(exception.exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getStatusClass(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'status--approved';
|
||||
case 'pending_review':
|
||||
return 'status--pending';
|
||||
case 'rejected':
|
||||
return 'status--rejected';
|
||||
case 'expired':
|
||||
return 'status--expired';
|
||||
case 'revoked':
|
||||
return 'status--revoked';
|
||||
default:
|
||||
return 'status--draft';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: ExceptionSeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatDateTime(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
isExpiringSoon(exception: Exception): boolean {
|
||||
const endDate = new Date(exception.timebox.endDate);
|
||||
const now = new Date();
|
||||
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
return endDate <= sevenDaysFromNow && endDate > now;
|
||||
}
|
||||
|
||||
getAvailableTransitions(status: ExceptionStatus): ExceptionStatus[] {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return ['pending_review'];
|
||||
case 'pending_review':
|
||||
return ['approved', 'rejected'];
|
||||
case 'approved':
|
||||
return ['revoked'];
|
||||
case 'rejected':
|
||||
return ['draft'];
|
||||
case 'expired':
|
||||
return ['draft'];
|
||||
case 'revoked':
|
||||
return ['draft'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
trackByException = (_: number, item: Exception) => item.exceptionId;
|
||||
trackByColumn = (_: number, item: ExceptionKanbanColumn) => item.status;
|
||||
trackByAudit = (_: number, item: { auditId: string }) => item.auditId;
|
||||
|
||||
private sortExceptions(items: Exception[]): Exception[] {
|
||||
const field = this.sortField();
|
||||
const order = this.sortOrder();
|
||||
|
||||
return items.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (field) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'updatedAt':
|
||||
comparison = (a.updatedAt ?? a.createdAt).localeCompare(
|
||||
b.updatedAt ?? b.createdAt
|
||||
);
|
||||
break;
|
||||
default:
|
||||
comparison = a.createdAt.localeCompare(b.createdAt);
|
||||
}
|
||||
return order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return 'Operation failed. Please retry.';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStatus,
|
||||
ExceptionType,
|
||||
ExceptionFilter,
|
||||
ExceptionSortOption,
|
||||
ExceptionTransition,
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionCenterComponent {
|
||||
/** All exceptions */
|
||||
readonly exceptions = input.required<Exception[]>();
|
||||
|
||||
/** Current user role for transition permissions */
|
||||
readonly userRole = input<string>('user');
|
||||
|
||||
/** Emits when creating new exception */
|
||||
readonly create = output<void>();
|
||||
|
||||
/** Emits when selecting an exception */
|
||||
readonly select = output<Exception>();
|
||||
|
||||
/** Emits when performing a workflow transition */
|
||||
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
|
||||
|
||||
/** Emits when viewing audit log */
|
||||
readonly viewAudit = output<Exception>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly filter = signal<ExceptionFilter>({});
|
||||
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
|
||||
readonly expandedId = signal<string | null>(null);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
readonly kanbanColumns = KANBAN_COLUMNS;
|
||||
|
||||
readonly filteredExceptions = computed(() => {
|
||||
let result = [...this.exceptions()];
|
||||
const f = this.filter();
|
||||
|
||||
// Apply filters
|
||||
if (f.status && f.status.length > 0) {
|
||||
result = result.filter((e) => f.status!.includes(e.status));
|
||||
}
|
||||
if (f.type && f.type.length > 0) {
|
||||
result = result.filter((e) => f.type!.includes(e.type));
|
||||
}
|
||||
if (f.severity && f.severity.length > 0) {
|
||||
result = result.filter((e) => f.severity!.includes(e.severity));
|
||||
}
|
||||
if (f.search) {
|
||||
const search = f.search.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(search) ||
|
||||
e.justification.toLowerCase().includes(search) ||
|
||||
e.id.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
|
||||
}
|
||||
if (f.expiringSoon) {
|
||||
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const s = this.sort();
|
||||
result.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (s.field) {
|
||||
case 'createdAt':
|
||||
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case 'updatedAt':
|
||||
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
break;
|
||||
case 'expiresAt':
|
||||
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
|
||||
break;
|
||||
case 'severity':
|
||||
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
cmp = sevOrder[a.severity] - sevOrder[b.severity];
|
||||
break;
|
||||
case 'title':
|
||||
cmp = a.title.localeCompare(b.title);
|
||||
break;
|
||||
}
|
||||
return s.direction === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
readonly exceptionsByStatus = computed(() => {
|
||||
const byStatus = new Map<ExceptionStatus, Exception[]>();
|
||||
for (const col of KANBAN_COLUMNS) {
|
||||
byStatus.set(col.status, []);
|
||||
}
|
||||
for (const exc of this.filteredExceptions()) {
|
||||
const list = byStatus.get(exc.status) || [];
|
||||
list.push(exc);
|
||||
byStatus.set(exc.status, list);
|
||||
}
|
||||
return byStatus;
|
||||
});
|
||||
|
||||
readonly statusCounts = computed(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const exc of this.exceptions()) {
|
||||
counts[exc.status] = (counts[exc.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
readonly allTags = computed(() => {
|
||||
const tags = new Set<string>();
|
||||
for (const exc of this.exceptions()) {
|
||||
for (const tag of exc.tags) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
|
||||
this.filter.update((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filter.set({});
|
||||
}
|
||||
|
||||
setSort(field: ExceptionSortOption['field']): void {
|
||||
this.sort.update((s) => ({
|
||||
field,
|
||||
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
|
||||
}));
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedId.update((current) => (current === id ? null : id));
|
||||
}
|
||||
|
||||
onCreate(): void {
|
||||
this.create.emit();
|
||||
}
|
||||
|
||||
onSelect(exc: Exception): void {
|
||||
this.select.emit(exc);
|
||||
}
|
||||
|
||||
onTransition(exc: Exception, to: ExceptionStatus): void {
|
||||
this.transition.emit({ exception: exc, to });
|
||||
}
|
||||
|
||||
onViewAudit(exc: Exception): void {
|
||||
this.viewAudit.emit(exc);
|
||||
}
|
||||
|
||||
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
|
||||
return EXCEPTION_TRANSITIONS.filter(
|
||||
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: ExceptionStatus): string {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '[D]';
|
||||
case 'pending':
|
||||
return '[?]';
|
||||
case 'approved':
|
||||
return '[+]';
|
||||
case 'active':
|
||||
return '[*]';
|
||||
case 'expired':
|
||||
return '[X]';
|
||||
case 'revoked':
|
||||
return '[!]';
|
||||
default:
|
||||
return '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
getTypeIcon(type: ExceptionType): string {
|
||||
switch (type) {
|
||||
case 'vulnerability':
|
||||
return 'V';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'policy':
|
||||
return 'P';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
case 'determinism':
|
||||
return 'D';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
return 'severity-' + severity;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
formatRemainingDays(days: number): string {
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Expires today';
|
||||
if (days === 1) return '1 day left';
|
||||
return days + ' days left';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,168 +1,168 @@
|
||||
<div class="draft-inline">
|
||||
<header class="draft-inline__header">
|
||||
<h3>Draft Exception</h3>
|
||||
<span class="draft-inline__source">from {{ context.sourceLabel }}</span>
|
||||
</header>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="draft-inline__error" *ngIf="error()">
|
||||
{{ error() }}
|
||||
</div>
|
||||
|
||||
<!-- Scope Summary -->
|
||||
<div class="draft-inline__scope">
|
||||
<span class="draft-inline__scope-label">Scope:</span>
|
||||
<span class="draft-inline__scope-value">{{ scopeSummary() }}</span>
|
||||
<span class="draft-inline__scope-type">{{ scopeType() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities Preview -->
|
||||
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
|
||||
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
|
||||
<div class="draft-inline__vulns-list">
|
||||
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
|
||||
{{ vulnId }}
|
||||
</span>
|
||||
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
|
||||
+{{ context.vulnIds.length - 5 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components Preview -->
|
||||
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
|
||||
<span class="draft-inline__components-label">Components:</span>
|
||||
<div class="draft-inline__components-list">
|
||||
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
|
||||
{{ purl | slice:-40 }}
|
||||
</span>
|
||||
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
|
||||
+{{ context.componentPurls.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="draftForm" class="draft-inline__form">
|
||||
<!-- Name -->
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="draftName">
|
||||
Name <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="draftName"
|
||||
class="form-input"
|
||||
formControlName="name"
|
||||
placeholder="e.g., cve-2021-44228-exception"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Severity -->
|
||||
<div class="form-row">
|
||||
<label class="form-label">Severity</label>
|
||||
<div class="severity-chips">
|
||||
<label
|
||||
*ngFor="let opt of severityOptions"
|
||||
class="severity-option"
|
||||
[class.severity-option--selected]="draftForm.controls.severity.value === opt.value"
|
||||
>
|
||||
<input type="radio" [value]="opt.value" formControlName="severity" />
|
||||
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Justification -->
|
||||
<div class="form-row">
|
||||
<label class="form-label">Quick Justification</label>
|
||||
<div class="template-chips">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let template of quickTemplates"
|
||||
class="template-chip"
|
||||
[class.template-chip--selected]="draftForm.controls.justificationTemplate.value === template.id"
|
||||
(click)="selectTemplate(template.id)"
|
||||
>
|
||||
{{ template.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Justification Text -->
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="justificationText">
|
||||
Justification <span class="required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="justificationText"
|
||||
class="form-textarea"
|
||||
formControlName="justificationText"
|
||||
rows="3"
|
||||
placeholder="Explain why this exception is needed..."
|
||||
></textarea>
|
||||
<span class="form-hint">Minimum 20 characters</span>
|
||||
</div>
|
||||
|
||||
<!-- Timebox -->
|
||||
<div class="form-row form-row--inline">
|
||||
<label class="form-label" for="timeboxDays">Duration</label>
|
||||
<div class="timebox-quick">
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 7 })">7d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 14 })">14d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 30 })">30d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 90 })">90d</button>
|
||||
<input
|
||||
type="number"
|
||||
id="timeboxDays"
|
||||
class="form-input form-input--small"
|
||||
formControlName="timeboxDays"
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<span class="timebox-label">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Simulation Toggle -->
|
||||
<div class="draft-inline__simulation">
|
||||
<button type="button" class="simulation-toggle" (click)="toggleSimulation()">
|
||||
{{ showSimulation() ? 'Hide' : 'Show' }} Impact Simulation
|
||||
</button>
|
||||
|
||||
<div class="simulation-result" *ngIf="showSimulation() && simulationResult() as sim">
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Affected Findings:</span>
|
||||
<span class="simulation-stat__value">~{{ sim.affectedFindings }}</span>
|
||||
</div>
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Policy Impact:</span>
|
||||
<span class="simulation-stat__value simulation-stat__value--{{ sim.policyImpact }}">
|
||||
{{ sim.policyImpact }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Coverage Estimate:</span>
|
||||
<span class="simulation-stat__value">{{ sim.coverageEstimate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<footer class="draft-inline__footer">
|
||||
<button type="button" class="btn btn--text" (click)="cancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="expandToFullWizard()">
|
||||
Full Wizard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!canSubmit()"
|
||||
(click)="submitDraft()"
|
||||
>
|
||||
{{ loading() ? 'Creating...' : 'Create Draft' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="draft-inline">
|
||||
<header class="draft-inline__header">
|
||||
<h3>Draft Exception</h3>
|
||||
<span class="draft-inline__source">from {{ context.sourceLabel }}</span>
|
||||
</header>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="draft-inline__error" *ngIf="error()">
|
||||
{{ error() }}
|
||||
</div>
|
||||
|
||||
<!-- Scope Summary -->
|
||||
<div class="draft-inline__scope">
|
||||
<span class="draft-inline__scope-label">Scope:</span>
|
||||
<span class="draft-inline__scope-value">{{ scopeSummary() }}</span>
|
||||
<span class="draft-inline__scope-type">{{ scopeType() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities Preview -->
|
||||
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
|
||||
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
|
||||
<div class="draft-inline__vulns-list">
|
||||
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
|
||||
{{ vulnId }}
|
||||
</span>
|
||||
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
|
||||
+{{ context.vulnIds.length - 5 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components Preview -->
|
||||
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
|
||||
<span class="draft-inline__components-label">Components:</span>
|
||||
<div class="draft-inline__components-list">
|
||||
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
|
||||
{{ purl | slice:-40 }}
|
||||
</span>
|
||||
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
|
||||
+{{ context.componentPurls.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="draftForm" class="draft-inline__form">
|
||||
<!-- Name -->
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="draftName">
|
||||
Name <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="draftName"
|
||||
class="form-input"
|
||||
formControlName="name"
|
||||
placeholder="e.g., cve-2021-44228-exception"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Severity -->
|
||||
<div class="form-row">
|
||||
<label class="form-label">Severity</label>
|
||||
<div class="severity-chips">
|
||||
<label
|
||||
*ngFor="let opt of severityOptions"
|
||||
class="severity-option"
|
||||
[class.severity-option--selected]="draftForm.controls.severity.value === opt.value"
|
||||
>
|
||||
<input type="radio" [value]="opt.value" formControlName="severity" />
|
||||
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Justification -->
|
||||
<div class="form-row">
|
||||
<label class="form-label">Quick Justification</label>
|
||||
<div class="template-chips">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let template of quickTemplates"
|
||||
class="template-chip"
|
||||
[class.template-chip--selected]="draftForm.controls.justificationTemplate.value === template.id"
|
||||
(click)="selectTemplate(template.id)"
|
||||
>
|
||||
{{ template.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Justification Text -->
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="justificationText">
|
||||
Justification <span class="required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="justificationText"
|
||||
class="form-textarea"
|
||||
formControlName="justificationText"
|
||||
rows="3"
|
||||
placeholder="Explain why this exception is needed..."
|
||||
></textarea>
|
||||
<span class="form-hint">Minimum 20 characters</span>
|
||||
</div>
|
||||
|
||||
<!-- Timebox -->
|
||||
<div class="form-row form-row--inline">
|
||||
<label class="form-label" for="timeboxDays">Duration</label>
|
||||
<div class="timebox-quick">
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 7 })">7d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 14 })">14d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 30 })">30d</button>
|
||||
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 90 })">90d</button>
|
||||
<input
|
||||
type="number"
|
||||
id="timeboxDays"
|
||||
class="form-input form-input--small"
|
||||
formControlName="timeboxDays"
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<span class="timebox-label">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Simulation Toggle -->
|
||||
<div class="draft-inline__simulation">
|
||||
<button type="button" class="simulation-toggle" (click)="toggleSimulation()">
|
||||
{{ showSimulation() ? 'Hide' : 'Show' }} Impact Simulation
|
||||
</button>
|
||||
|
||||
<div class="simulation-result" *ngIf="showSimulation() && simulationResult() as sim">
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Affected Findings:</span>
|
||||
<span class="simulation-stat__value">~{{ sim.affectedFindings }}</span>
|
||||
</div>
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Policy Impact:</span>
|
||||
<span class="simulation-stat__value simulation-stat__value--{{ sim.policyImpact }}">
|
||||
{{ sim.policyImpact }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="simulation-stat">
|
||||
<span class="simulation-stat__label">Coverage Estimate:</span>
|
||||
<span class="simulation-stat__value">{{ sim.coverageEstimate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<footer class="draft-inline__footer">
|
||||
<button type="button" class="btn btn--text" (click)="cancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="expandToFullWizard()">
|
||||
Full Wizard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!canSubmit()"
|
||||
(click)="submitDraft()"
|
||||
>
|
||||
{{ loading() ? 'Creating...' : 'Create Draft' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,435 +1,435 @@
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
|
||||
&--more {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&--critical {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #475569;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #eef2ff;
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.25rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #4f46e5;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eef2ff;
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
|
||||
&--high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: white;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 480px) {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
// Header
|
||||
.draft-inline__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.draft-inline__source {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Error
|
||||
.draft-inline__error {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
// Scope
|
||||
.draft-inline__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.draft-inline__scope-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.draft-inline__scope-value {
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.draft-inline__scope-type {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// Vulnerabilities preview
|
||||
.draft-inline__vulns,
|
||||
.draft-inline__components {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.draft-inline__vulns-label,
|
||||
.draft-inline__components-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.draft-inline__vulns-list,
|
||||
.draft-inline__components-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.vuln-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
|
||||
&--more {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.component-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--more {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
// Form
|
||||
.draft-inline__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.6875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
// Severity chips
|
||||
.severity-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-option {
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--selected .severity-chip {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&--critical {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
// Template chips
|
||||
.template-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #475569;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #eef2ff;
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox
|
||||
.timebox-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.timebox-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.25rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #4f46e5;
|
||||
color: #4f46e5;
|
||||
}
|
||||
}
|
||||
|
||||
.timebox-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Simulation
|
||||
.draft-inline__simulation {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.simulation-toggle {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #4f46e5;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eef2ff;
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-result {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.simulation-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-stat__label {
|
||||
font-size: 0.625rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.simulation-stat__value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
|
||||
&--high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--moderate {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&--low {
|
||||
color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.draft-inline__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
// Buttons
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: white;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 480px) {
|
||||
.simulation-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.draft-inline__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.severity-chips,
|
||||
.template-chips {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +1,213 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
MockExceptionApiService,
|
||||
} from '../../core/api/exception.client';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionScope,
|
||||
ExceptionSeverity,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './exception-draft-inline.component.html',
|
||||
styleUrls: ['./exception-draft-inline.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
|
||||
],
|
||||
})
|
||||
export class ExceptionDraftInlineComponent implements OnInit {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
readonly scopeType = computed<ExceptionScope>(() => {
|
||||
if (this.context?.componentPurls?.length) return 'component';
|
||||
if (this.context?.assetIds?.length) return 'asset';
|
||||
if (this.context?.tenantId) return 'tenant';
|
||||
return 'global';
|
||||
});
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const created = await firstValueFrom(this.api.createException(exception));
|
||||
this.created.emit(created);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
MockExceptionApiService,
|
||||
} from '../../core/api/exception.client';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionScope,
|
||||
ExceptionSeverity,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
export interface ExceptionDraftContext {
|
||||
readonly vulnIds?: readonly string[];
|
||||
readonly componentPurls?: readonly string[];
|
||||
readonly assetIds?: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly suggestedName?: string;
|
||||
readonly suggestedSeverity?: ExceptionSeverity;
|
||||
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
|
||||
readonly sourceLabel: string;
|
||||
}
|
||||
|
||||
const QUICK_TEMPLATES = [
|
||||
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
|
||||
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
|
||||
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
|
||||
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
|
||||
] as const;
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-draft-inline',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './exception-draft-inline.component.html',
|
||||
styleUrls: ['./exception-draft-inline.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
|
||||
],
|
||||
})
|
||||
export class ExceptionDraftInlineComponent implements OnInit {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
@Input() context!: ExceptionDraftContext;
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly showSimulation = signal(false);
|
||||
|
||||
readonly quickTemplates = QUICK_TEMPLATES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
readonly draftForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
justificationTemplate: this.formBuilder.control('risk-accepted'),
|
||||
justificationText: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
timeboxDays: this.formBuilder.control(30),
|
||||
});
|
||||
|
||||
readonly scopeType = computed<ExceptionScope>(() => {
|
||||
if (this.context?.componentPurls?.length) return 'component';
|
||||
if (this.context?.assetIds?.length) return 'asset';
|
||||
if (this.context?.tenantId) return 'tenant';
|
||||
return 'global';
|
||||
});
|
||||
|
||||
readonly scopeSummary = computed(() => {
|
||||
const ctx = this.context;
|
||||
const items: string[] = [];
|
||||
|
||||
if (ctx?.vulnIds?.length) {
|
||||
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
|
||||
}
|
||||
if (ctx?.componentPurls?.length) {
|
||||
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.assetIds?.length) {
|
||||
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (ctx?.tenantId) {
|
||||
items.push(`Tenant: ${ctx.tenantId}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items.join(', ') : 'Global scope';
|
||||
});
|
||||
|
||||
readonly simulationResult = computed(() => {
|
||||
if (!this.showSimulation()) return null;
|
||||
|
||||
const vulnCount = this.context?.vulnIds?.length ?? 0;
|
||||
const componentCount = this.context?.componentPurls?.length ?? 0;
|
||||
|
||||
return {
|
||||
affectedFindings: vulnCount * Math.max(1, componentCount),
|
||||
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
|
||||
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
|
||||
};
|
||||
});
|
||||
|
||||
readonly canSubmit = computed(() => {
|
||||
return this.draftForm.valid && !this.loading();
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.context?.suggestedName) {
|
||||
this.draftForm.patchValue({ name: this.context.suggestedName });
|
||||
}
|
||||
if (this.context?.suggestedSeverity) {
|
||||
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
|
||||
}
|
||||
|
||||
const defaultTemplate = this.quickTemplates[0];
|
||||
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.quickTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.draftForm.patchValue({
|
||||
justificationTemplate: templateId,
|
||||
justificationText: template.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleSimulation(): void {
|
||||
this.showSimulation.set(!this.showSimulation());
|
||||
}
|
||||
|
||||
async submitDraft(): Promise<void> {
|
||||
if (!this.canSubmit()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const formValue = this.draftForm.getRawValue();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
|
||||
|
||||
const exception: Partial<Exception> = {
|
||||
name: formValue.name,
|
||||
severity: formValue.severity,
|
||||
status: 'draft',
|
||||
scope: {
|
||||
type: this.scopeType(),
|
||||
tenantId: this.context?.tenantId,
|
||||
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
|
||||
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
|
||||
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
|
||||
},
|
||||
justification: {
|
||||
template: formValue.justificationTemplate,
|
||||
text: formValue.justificationText,
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const created = await firstValueFrom(this.api.createException(exception));
|
||||
this.created.emit(created);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
expandToFullWizard(): void {
|
||||
this.openFullWizard.emit(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,438 +1,406 @@
|
||||
<div class="wizard">
|
||||
<!-- Progress Steps -->
|
||||
<nav class="wizard__steps">
|
||||
<button
|
||||
*ngFor="let step of steps; let i = index"
|
||||
type="button"
|
||||
class="wizard__step"
|
||||
[class.wizard__step--active]="isStepActive(step)"
|
||||
[class.wizard__step--completed]="isStepCompleted(step)"
|
||||
[class.wizard__step--disabled]="!canNavigateToStep(step)"
|
||||
(click)="goToStep(step)"
|
||||
[disabled]="!canNavigateToStep(step)"
|
||||
>
|
||||
<span class="wizard__step-number">{{ i + 1 }}</span>
|
||||
<span class="wizard__step-label">{{ getStepLabel(step) }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div class="wizard__error" *ngIf="error()">
|
||||
{{ error() }}
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="wizard__content">
|
||||
<!-- Step 1: Basics -->
|
||||
<div class="wizard__panel" *ngIf="currentStep() === 'basics'">
|
||||
<h2>Basic Information</h2>
|
||||
<p class="wizard__description">Provide a name and description for this exception.</p>
|
||||
|
||||
<form [formGroup]="basicsForm" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="name">
|
||||
Exception Name <span class="form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
class="form__input"
|
||||
formControlName="name"
|
||||
placeholder="e.g., log4j-legacy-exception"
|
||||
/>
|
||||
<span class="form__hint">A unique identifier (3-100 characters, no spaces preferred)</span>
|
||||
<span class="form__error" *ngIf="basicsForm.controls.name.touched && basicsForm.controls.name.errors?.['required']">
|
||||
Name is required
|
||||
</span>
|
||||
<span class="form__error" *ngIf="basicsForm.controls.name.errors?.['minlength']">
|
||||
Name must be at least 3 characters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="displayName">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="displayName"
|
||||
class="form__input"
|
||||
formControlName="displayName"
|
||||
placeholder="e.g., Log4j Legacy Exception"
|
||||
/>
|
||||
<span class="form__hint">Human-friendly name for display in UI</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form__textarea"
|
||||
formControlName="description"
|
||||
rows="3"
|
||||
placeholder="Briefly describe why this exception is needed..."
|
||||
></textarea>
|
||||
<span class="form__hint">Max 500 characters</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__label">Severity</label>
|
||||
<div class="form__radio-group">
|
||||
<label
|
||||
*ngFor="let opt of severityOptions"
|
||||
class="form__radio"
|
||||
[class.form__radio--selected]="basicsForm.controls.severity.value === opt.value"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
[value]="opt.value"
|
||||
formControlName="severity"
|
||||
/>
|
||||
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scope -->
|
||||
<div class="wizard__panel" *ngIf="currentStep() === 'scope'">
|
||||
<h2>Exception Scope</h2>
|
||||
<p class="wizard__description">Define what this exception applies to.</p>
|
||||
|
||||
<form [formGroup]="scopeForm" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label">Scope Type <span class="form__required">*</span></label>
|
||||
<div class="scope-type-cards">
|
||||
<button
|
||||
*ngFor="let type of scopeTypes"
|
||||
type="button"
|
||||
class="scope-type-card"
|
||||
[class.scope-type-card--selected]="scopeForm.controls.type.value === type.value"
|
||||
(click)="scopeForm.patchValue({ type: type.value })"
|
||||
>
|
||||
<span class="scope-type-card__label">{{ type.label }}</span>
|
||||
<span class="scope-type-card__desc">{{ type.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tenant scope -->
|
||||
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'tenant'">
|
||||
<label class="form__label" for="tenantId">
|
||||
Tenant ID <span class="form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tenantId"
|
||||
class="form__input"
|
||||
formControlName="tenantId"
|
||||
placeholder="e.g., tenant-prod-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Asset scope -->
|
||||
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'asset'">
|
||||
<label class="form__label" for="assetIds">
|
||||
Asset IDs <span class="form__required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="assetIds"
|
||||
class="form__textarea"
|
||||
formControlName="assetIds"
|
||||
rows="3"
|
||||
placeholder="Enter asset IDs, one per line or comma-separated"
|
||||
></textarea>
|
||||
<span class="form__hint">e.g., asset-web-prod, asset-api-prod</span>
|
||||
</div>
|
||||
|
||||
<!-- Component scope -->
|
||||
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'component'">
|
||||
<label class="form__label" for="componentPurls">
|
||||
Component PURLs <span class="form__required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="componentPurls"
|
||||
class="form__textarea"
|
||||
formControlName="componentPurls"
|
||||
rows="3"
|
||||
placeholder="Enter Package URLs, one per line"
|
||||
></textarea>
|
||||
<span class="form__hint">e.g., pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1</span>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerability IDs (for all non-global scopes) -->
|
||||
<div class="form__group" *ngIf="scopeForm.controls.type.value !== 'global'">
|
||||
<label class="form__label" for="vulnIds">Vulnerability IDs (Optional)</label>
|
||||
<textarea
|
||||
id="vulnIds"
|
||||
class="form__textarea"
|
||||
formControlName="vulnIds"
|
||||
rows="2"
|
||||
placeholder="CVE-2021-44228, CVE-2021-45046"
|
||||
></textarea>
|
||||
<span class="form__hint">Leave empty to apply to all vulnerabilities in scope</span>
|
||||
</div>
|
||||
|
||||
<!-- Scope Preview -->
|
||||
<div class="scope-preview">
|
||||
<h3 class="scope-preview__title">Scope Preview</h3>
|
||||
<ul class="scope-preview__list">
|
||||
<li *ngFor="let item of scopePreview()">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Justification -->
|
||||
<div class="wizard__panel" *ngIf="currentStep() === 'justification'">
|
||||
<h2>Justification</h2>
|
||||
<p class="wizard__description">Explain why this exception is needed.</p>
|
||||
|
||||
<form [formGroup]="justificationForm" class="form">
|
||||
<div class="form__group">
|
||||
<label class="form__label">Justification Template</label>
|
||||
<div class="template-cards">
|
||||
<button
|
||||
*ngFor="let template of justificationTemplates"
|
||||
type="button"
|
||||
class="template-card"
|
||||
[class.template-card--selected]="justificationForm.controls.template.value === template.id"
|
||||
(click)="selectTemplate(template.id)"
|
||||
>
|
||||
<span class="template-card__name">{{ template.name }}</span>
|
||||
<span class="template-card__desc">{{ template.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="justificationText">
|
||||
Justification Text <span class="form__required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="justificationText"
|
||||
class="form__textarea form__textarea--large"
|
||||
formControlName="text"
|
||||
rows="6"
|
||||
placeholder="Provide a detailed justification..."
|
||||
></textarea>
|
||||
<span class="form__hint">Minimum 20 characters. Be specific about the risk mitigation.</span>
|
||||
<span class="form__error" *ngIf="justificationForm.controls.text.touched && justificationForm.controls.text.errors?.['required']">
|
||||
Justification text is required
|
||||
</span>
|
||||
<span class="form__error" *ngIf="justificationForm.controls.text.errors?.['minlength']">
|
||||
Justification must be at least 20 characters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__label" for="attachments">Attachments (Optional)</label>
|
||||
<textarea
|
||||
id="attachments"
|
||||
class="form__textarea"
|
||||
formControlName="attachments"
|
||||
rows="2"
|
||||
placeholder="Links to supporting documents, one per line"
|
||||
></textarea>
|
||||
<span class="form__hint">e.g., JIRA ticket URLs, risk assessment documents</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Timebox -->
|
||||
<div class="wizard__panel" *ngIf="currentStep() === 'timebox'">
|
||||
<h2>Timebox</h2>
|
||||
<p class="wizard__description">Set the validity period for this exception.</p>
|
||||
|
||||
<form [formGroup]="timeboxForm" class="form">
|
||||
<div class="timebox-presets">
|
||||
<span class="timebox-presets__label">Quick presets:</span>
|
||||
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(7)">7 days</button>
|
||||
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(14)">14 days</button>
|
||||
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(30)">30 days</button>
|
||||
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(90)">90 days</button>
|
||||
</div>
|
||||
|
||||
<div class="form__row">
|
||||
<div class="form__group form__group--half">
|
||||
<label class="form__label" for="startDate">
|
||||
Start Date <span class="form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="startDate"
|
||||
class="form__input"
|
||||
formControlName="startDate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form__group form__group--half">
|
||||
<label class="form__label" for="endDate">
|
||||
End Date <span class="form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="endDate"
|
||||
class="form__input"
|
||||
formControlName="endDate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timebox-summary" [class.timebox-summary--warning]="timeboxWarning()">
|
||||
<span class="timebox-summary__duration">Duration: {{ timeboxDays() }} days</span>
|
||||
<span class="timebox-summary__warning" *ngIf="timeboxWarning()">
|
||||
{{ timeboxWarning() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group">
|
||||
<label class="form__checkbox">
|
||||
<input type="checkbox" formControlName="autoRenew" />
|
||||
<span>Enable auto-renewal</span>
|
||||
</label>
|
||||
<span class="form__hint">Automatically renew when the exception expires</span>
|
||||
</div>
|
||||
|
||||
<div class="form__group" *ngIf="timeboxForm.controls.autoRenew.value">
|
||||
<label class="form__label" for="maxRenewals">Maximum Renewals</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxRenewals"
|
||||
class="form__input form__input--small"
|
||||
formControlName="maxRenewals"
|
||||
min="0"
|
||||
max="12"
|
||||
/>
|
||||
<span class="form__hint">0 = unlimited (not recommended)</span>
|
||||
</div>
|
||||
|
||||
<div class="timebox-guardrails">
|
||||
<h4>Guardrails</h4>
|
||||
<ul>
|
||||
<li [class.guardrail--pass]="timeboxDays() <= 365">
|
||||
Maximum duration: 365 days
|
||||
</li>
|
||||
<li [class.guardrail--pass]="timeboxDays() <= 90" [class.guardrail--warning]="timeboxDays() > 90 && timeboxDays() <= 365">
|
||||
Recommended: ≤90 days with renewal
|
||||
</li>
|
||||
<li [class.guardrail--pass]="!timeboxForm.controls.autoRenew.value || timeboxForm.controls.maxRenewals.value > 0">
|
||||
Auto-renewal should have a limit
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
<div class="wizard__panel" *ngIf="currentStep() === 'review'">
|
||||
<h2>Review Exception</h2>
|
||||
<p class="wizard__description">Review all details before creating the exception.</p>
|
||||
|
||||
<div class="review-section">
|
||||
<h3>Basic Information</h3>
|
||||
<dl class="review-list">
|
||||
<dt>Name</dt>
|
||||
<dd>{{ exceptionPreview().name }}</dd>
|
||||
<dt>Display Name</dt>
|
||||
<dd>{{ exceptionPreview().displayName || '-' }}</dd>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ exceptionPreview().description || '-' }}</dd>
|
||||
<dt>Severity</dt>
|
||||
<dd>
|
||||
<span class="severity-chip severity-chip--{{ exceptionPreview().severity }}">
|
||||
{{ exceptionPreview().severity }}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
<button type="button" class="review-edit" (click)="goToStep('basics')">Edit</button>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h3>Scope</h3>
|
||||
<dl class="review-list">
|
||||
<dt>Type</dt>
|
||||
<dd>{{ exceptionPreview().scope?.type }}</dd>
|
||||
<dt>Details</dt>
|
||||
<dd>
|
||||
<ul class="review-scope-list">
|
||||
<li *ngFor="let item of scopePreview()">{{ item }}</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<button type="button" class="review-edit" (click)="goToStep('scope')">Edit</button>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h3>Justification</h3>
|
||||
<dl class="review-list">
|
||||
<dt>Template</dt>
|
||||
<dd>{{ selectedTemplate()?.name || 'Custom' }}</dd>
|
||||
<dt>Text</dt>
|
||||
<dd class="review-text">{{ exceptionPreview().justification?.text }}</dd>
|
||||
</dl>
|
||||
<button type="button" class="review-edit" (click)="goToStep('justification')">Edit</button>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h3>Timebox</h3>
|
||||
<dl class="review-list">
|
||||
<dt>Start Date</dt>
|
||||
<dd>{{ exceptionPreview().timebox?.startDate | date:'mediumDate' }}</dd>
|
||||
<dt>End Date</dt>
|
||||
<dd>{{ exceptionPreview().timebox?.endDate | date:'mediumDate' }}</dd>
|
||||
<dt>Duration</dt>
|
||||
<dd>{{ timeboxDays() }} days</dd>
|
||||
<dt>Auto-Renew</dt>
|
||||
<dd>{{ exceptionPreview().timebox?.autoRenew ? 'Yes' : 'No' }}</dd>
|
||||
</dl>
|
||||
<button type="button" class="review-edit" (click)="goToStep('timebox')">Edit</button>
|
||||
</div>
|
||||
|
||||
<div class="review-notice">
|
||||
<p>The exception will be created in <strong>Draft</strong> status. Submit it for review to start the approval workflow.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<footer class="wizard__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div class="wizard__footer-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="prevStep()"
|
||||
*ngIf="currentStep() !== 'basics'"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="nextStep()"
|
||||
[disabled]="!canProceed()"
|
||||
*ngIf="currentStep() !== 'review'"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="submitException()"
|
||||
[disabled]="loading()"
|
||||
*ngIf="currentStep() === 'review'"
|
||||
>
|
||||
{{ loading() ? 'Creating...' : 'Create Exception' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="exception-wizard">
|
||||
<!-- Progress Steps -->
|
||||
<div class="wizard-progress">
|
||||
@for (step of steps; track step; let i = $index) {
|
||||
<button
|
||||
class="progress-step"
|
||||
[class.active]="currentStep() === step"
|
||||
[class.completed]="i < currentStepIndex()"
|
||||
[class.disabled]="i > currentStepIndex()"
|
||||
(click)="goToStep(step)"
|
||||
[disabled]="i > currentStepIndex()"
|
||||
>
|
||||
<span class="step-number">{{ i + 1 }}</span>
|
||||
<span class="step-label">{{ step | titlecase }}</span>
|
||||
</button>
|
||||
@if (i < steps.length - 1) {
|
||||
<div class="step-connector" [class.completed]="i < currentStepIndex()"></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Step Content -->
|
||||
<div class="wizard-content">
|
||||
<!-- Step 1: Type Selection -->
|
||||
@if (currentStep() === 'type') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">What type of exception do you need?</h3>
|
||||
<p class="step-desc">Select the category that best matches your exception request.</p>
|
||||
|
||||
<div class="type-grid">
|
||||
@for (type of exceptionTypes; track type.type) {
|
||||
<button
|
||||
class="type-card"
|
||||
[class.selected]="draft().type === type.type"
|
||||
(click)="selectType(type.type)"
|
||||
>
|
||||
<span class="type-icon">{{ type.icon }}</span>
|
||||
<div class="type-info">
|
||||
<span class="type-label">{{ type.label }}</span>
|
||||
<span class="type-desc">{{ type.description }}</span>
|
||||
</div>
|
||||
@if (draft().type === type.type) {
|
||||
<span class="selected-check">[+]</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 2: Scope Definition -->
|
||||
@if (currentStep() === 'scope') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Define the exception scope</h3>
|
||||
<p class="step-desc">Specify what this exception applies to. Be as specific as possible.</p>
|
||||
|
||||
<div class="scope-form">
|
||||
@if (draft().type === 'vulnerability') {
|
||||
<div class="scope-field">
|
||||
<label class="field-label">CVEs</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
placeholder="Enter CVE IDs, one per line (e.g., CVE-2024-1234)"
|
||||
[value]="draft().scope.cves?.join('\n') || ''"
|
||||
(input)="updateScope('cves', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="scope-field">
|
||||
<label class="field-label">Packages (optional)</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
placeholder="Package names to scope (e.g., lodash, express)"
|
||||
[value]="draft().scope.packages?.join('\n') || ''"
|
||||
(input)="updateScope('packages', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (draft().type === 'license') {
|
||||
<div class="scope-field">
|
||||
<label class="field-label">Licenses</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
placeholder="License identifiers (e.g., GPL-3.0, AGPL-3.0)"
|
||||
[value]="draft().scope.licenses?.join('\n') || ''"
|
||||
(input)="updateScope('licenses', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (draft().type === 'policy') {
|
||||
<div class="scope-field">
|
||||
<label class="field-label">Policy Rules</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
placeholder="Policy rule IDs (e.g., SEC-001, COMP-002)"
|
||||
[value]="draft().scope.policyRules?.join('\n') || ''"
|
||||
(input)="updateScope('policyRules', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="scope-field">
|
||||
<label class="field-label">Images (optional - limits scope to specific images)</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
placeholder="Image references (e.g., myregistry/myimage:*, myregistry/app:v1.0)"
|
||||
[value]="draft().scope.images?.join('\n') || ''"
|
||||
(input)="updateScope('images', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
></textarea>
|
||||
<span class="field-hint">Use * for wildcards. Leave empty to apply to all images.</span>
|
||||
</div>
|
||||
|
||||
<div class="scope-field">
|
||||
<label class="field-label">Environments (optional)</label>
|
||||
<div class="env-chips">
|
||||
@for (env of ['development', 'staging', 'production']; track env) {
|
||||
<button
|
||||
class="env-chip"
|
||||
[class.selected]="draft().scope.environments?.includes(env)"
|
||||
(click)="updateScope('environments',
|
||||
draft().scope.environments?.includes(env)
|
||||
? draft().scope.environments?.filter(e => e !== env)
|
||||
: [...(draft().scope.environments || []), env])"
|
||||
>
|
||||
{{ env | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (scopePreview().length > 0) {
|
||||
<div class="scope-preview">
|
||||
<span class="preview-label">Scope preview:</span>
|
||||
<span class="preview-text">{{ scopePreview().join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 3: Justification -->
|
||||
@if (currentStep() === 'justification') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Provide justification</h3>
|
||||
<p class="step-desc">Explain why this exception is needed. Use a template or write your own.</p>
|
||||
|
||||
<div class="justification-form">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="Brief descriptive title for this exception"
|
||||
[value]="draft().title"
|
||||
(input)="updateDraft('title', $any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Severity</label>
|
||||
<div class="severity-options">
|
||||
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
|
||||
<button
|
||||
class="severity-btn"
|
||||
[class]="'sev-' + sev"
|
||||
[class.selected]="draft().severity === sev"
|
||||
(click)="updateDraft('severity', $any(sev))"
|
||||
>
|
||||
{{ sev | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (applicableTemplates().length > 0) {
|
||||
<div class="form-field">
|
||||
<label class="field-label">Templates</label>
|
||||
<div class="template-list">
|
||||
@for (tpl of applicableTemplates(); track tpl.id) {
|
||||
<button
|
||||
class="template-btn"
|
||||
[class.selected]="selectedTemplate() === tpl.id"
|
||||
(click)="selectTemplate(tpl.id)"
|
||||
>
|
||||
<span class="tpl-name">{{ tpl.name }}</span>
|
||||
<span class="tpl-desc">{{ tpl.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">
|
||||
Justification
|
||||
<span class="char-count">{{ draft().justification.length }} chars (min 20)</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="field-textarea large"
|
||||
placeholder="Provide detailed justification for this exception..."
|
||||
[value]="draft().justification"
|
||||
(input)="updateDraft('justification', $any($event.target).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Tags (optional)</label>
|
||||
<div class="tags-input">
|
||||
<div class="current-tags">
|
||||
@for (tag of draft().tags; track tag) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
<button class="tag-remove" (click)="removeTag(tag)">x</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="tag-input"
|
||||
placeholder="Add tag..."
|
||||
[value]="newTag()"
|
||||
(input)="onTagInput($event)"
|
||||
(keydown.enter)="addTag(); $event.preventDefault()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 4: Timebox -->
|
||||
@if (currentStep() === 'timebox') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Set exception duration</h3>
|
||||
<p class="step-desc">
|
||||
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
|
||||
</p>
|
||||
|
||||
<div class="timebox-form">
|
||||
<div class="timebox-presets">
|
||||
@for (preset of timeboxPresets; track preset.days) {
|
||||
<button
|
||||
class="preset-btn"
|
||||
[class.selected]="draft().expiresInDays === preset.days"
|
||||
[disabled]="preset.days > maxDurationDays()"
|
||||
(click)="selectTimebox(preset.days)"
|
||||
>
|
||||
<span class="preset-label">{{ preset.label }}</span>
|
||||
<span class="preset-desc">{{ preset.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="custom-duration">
|
||||
<label class="field-label">Or set custom duration (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="field-input duration-input"
|
||||
min="1"
|
||||
[max]="maxDurationDays()"
|
||||
[value]="draft().expiresInDays"
|
||||
(input)="updateDraft('expiresInDays', +$any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="timebox-preview">
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Expires on:</span>
|
||||
<span class="preview-value">{{ formatDate(expirationDate()) }}</span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="preview-label">Duration:</span>
|
||||
<span class="preview-value">{{ draft().expiresInDays }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (timeboxWarning()) {
|
||||
<div class="timebox-warning">
|
||||
<span class="warning-icon">[!]</span>
|
||||
<span>{{ timeboxWarning() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
@if (currentStep() === 'review') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Review and submit</h3>
|
||||
<p class="step-desc">Please review your exception request before submitting.</p>
|
||||
|
||||
<div class="review-summary">
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Type & Severity</h4>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Type:</span>
|
||||
<span class="review-value">{{ draft().type | titlecase }}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Severity:</span>
|
||||
<span class="review-value severity-badge" [class]="'sev-' + draft().severity">
|
||||
{{ draft().severity | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Scope</h4>
|
||||
@if (draft().scope.cves?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">CVEs:</span>
|
||||
<span class="review-value">{{ draft().scope.cves?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (draft().scope.packages?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Packages:</span>
|
||||
<span class="review-value">{{ draft().scope.packages?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (draft().scope.licenses?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Licenses:</span>
|
||||
<span class="review-value">{{ draft().scope.licenses?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (draft().scope.policyRules?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Policy Rules:</span>
|
||||
<span class="review-value">{{ draft().scope.policyRules?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (draft().scope.images?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Images:</span>
|
||||
<span class="review-value">{{ draft().scope.images?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (draft().scope.environments?.length) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Environments:</span>
|
||||
<span class="review-value">{{ draft().scope.environments?.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Details</h4>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Title:</span>
|
||||
<span class="review-value">{{ draft().title }}</span>
|
||||
</div>
|
||||
<div class="review-row full">
|
||||
<span class="review-label">Justification:</span>
|
||||
<p class="review-justification">{{ draft().justification }}</p>
|
||||
</div>
|
||||
@if (draft().tags.length > 0) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Tags:</span>
|
||||
<div class="review-tags">
|
||||
@for (tag of draft().tags; track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Timebox</h4>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Duration:</span>
|
||||
<span class="review-value">{{ draft().expiresInDays }} days</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Expires:</span>
|
||||
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="wizard-footer">
|
||||
<button class="btn-cancel" (click)="onCancel()">Cancel</button>
|
||||
<div class="footer-right">
|
||||
@if (canGoBack()) {
|
||||
<button class="btn-back" (click)="goBack()">Back</button>
|
||||
}
|
||||
@if (currentStep() !== 'review') {
|
||||
<button class="btn-next" [disabled]="!canGoNext()" (click)="goNext()">
|
||||
Next
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn-submit" [disabled]="!canGoNext()" (click)="onSubmit()">
|
||||
Submit Exception
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,453 +1,296 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
ExceptionApi,
|
||||
MockExceptionApiService,
|
||||
} from '../../core/api/exception.client';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionJustification,
|
||||
ExceptionScope,
|
||||
ExceptionSeverity,
|
||||
ExceptionTimebox,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type WizardStep = 'basics' | 'scope' | 'justification' | 'timebox' | 'review';
|
||||
|
||||
interface JustificationTemplate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly defaultText: string;
|
||||
readonly requiredFields: readonly string[];
|
||||
}
|
||||
|
||||
const JUSTIFICATION_TEMPLATES: readonly JustificationTemplate[] = [
|
||||
{
|
||||
id: 'risk-accepted',
|
||||
name: 'Risk Accepted',
|
||||
description: 'The risk has been formally accepted by stakeholders',
|
||||
defaultText: 'Risk has been reviewed and formally accepted. Rationale: ',
|
||||
requiredFields: ['approver', 'rationale'],
|
||||
},
|
||||
{
|
||||
id: 'compensating-control',
|
||||
name: 'Compensating Control',
|
||||
description: 'Alternative security controls are in place to mitigate the risk',
|
||||
defaultText: 'Compensating controls in place: ',
|
||||
requiredFields: ['controls', 'effectiveness'],
|
||||
},
|
||||
{
|
||||
id: 'false-positive',
|
||||
name: 'False Positive',
|
||||
description: 'The finding has been determined to be a false positive',
|
||||
defaultText: 'This finding is a false positive because: ',
|
||||
requiredFields: ['evidence'],
|
||||
},
|
||||
{
|
||||
id: 'scheduled-fix',
|
||||
name: 'Scheduled Fix',
|
||||
description: 'A fix is planned within the timebox period',
|
||||
defaultText: 'Fix scheduled for deployment. Timeline: ',
|
||||
requiredFields: ['timeline', 'ticket'],
|
||||
},
|
||||
{
|
||||
id: 'internal-only',
|
||||
name: 'Internal Only',
|
||||
description: 'Component is used only in internal/non-production environments',
|
||||
defaultText: 'This component is used only in internal environments with no external exposure. Environment: ',
|
||||
requiredFields: ['environment'],
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
description: 'Provide a custom justification',
|
||||
defaultText: '',
|
||||
requiredFields: [],
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_TYPES: readonly { value: ExceptionScope; label: string; description: string }[] = [
|
||||
{ value: 'global', label: 'Global', description: 'Applies to all assets and components' },
|
||||
{ value: 'tenant', label: 'Tenant', description: 'Applies to a specific tenant' },
|
||||
{ value: 'asset', label: 'Asset', description: 'Applies to specific assets' },
|
||||
{ value: 'component', label: 'Component', description: 'Applies to specific components/PURLs' },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
|
||||
{ value: 'critical', label: 'Critical' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
];
|
||||
|
||||
const MAX_TIMEBOX_DAYS = 365;
|
||||
const DEFAULT_TIMEBOX_DAYS = 30;
|
||||
const MIN_TIMEBOX_DAYS = 1;
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-wizard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './exception-wizard.component.html',
|
||||
styleUrls: ['./exception-wizard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
|
||||
],
|
||||
})
|
||||
export class ExceptionWizardComponent implements OnInit {
|
||||
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
@Output() readonly created = new EventEmitter<Exception>();
|
||||
@Output() readonly cancelled = new EventEmitter<void>();
|
||||
|
||||
// Wizard state
|
||||
readonly currentStep = signal<WizardStep>('basics');
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Constants for template
|
||||
readonly steps: readonly WizardStep[] = ['basics', 'scope', 'justification', 'timebox', 'review'];
|
||||
readonly justificationTemplates = JUSTIFICATION_TEMPLATES;
|
||||
readonly scopeTypes = SCOPE_TYPES;
|
||||
readonly severityOptions = SEVERITY_OPTIONS;
|
||||
|
||||
// Forms for each step
|
||||
readonly basicsForm = this.formBuilder.group({
|
||||
name: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)],
|
||||
}),
|
||||
displayName: this.formBuilder.control(''),
|
||||
description: this.formBuilder.control('', {
|
||||
validators: [Validators.maxLength(500)],
|
||||
}),
|
||||
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
|
||||
});
|
||||
|
||||
readonly scopeForm = this.formBuilder.group({
|
||||
type: this.formBuilder.control<ExceptionScope>('component'),
|
||||
tenantId: this.formBuilder.control(''),
|
||||
assetIds: this.formBuilder.control(''),
|
||||
componentPurls: this.formBuilder.control(''),
|
||||
vulnIds: this.formBuilder.control(''),
|
||||
});
|
||||
|
||||
readonly justificationForm = this.formBuilder.group({
|
||||
template: this.formBuilder.control('risk-accepted'),
|
||||
text: this.formBuilder.control('', {
|
||||
validators: [Validators.required, Validators.minLength(20)],
|
||||
}),
|
||||
attachments: this.formBuilder.control(''),
|
||||
});
|
||||
|
||||
readonly timeboxForm = this.formBuilder.group({
|
||||
startDate: this.formBuilder.control(this.formatDateForInput(new Date())),
|
||||
endDate: this.formBuilder.control(this.formatDateForInput(this.addDays(new Date(), DEFAULT_TIMEBOX_DAYS))),
|
||||
autoRenew: this.formBuilder.control(false),
|
||||
maxRenewals: this.formBuilder.control(0),
|
||||
});
|
||||
|
||||
// Computed: selected template
|
||||
readonly selectedTemplate = computed(() => {
|
||||
const templateId = this.justificationForm.controls.template.value;
|
||||
return this.justificationTemplates.find((t) => t.id === templateId);
|
||||
});
|
||||
|
||||
// Computed: scope preview
|
||||
readonly scopePreview = computed(() => {
|
||||
const scope = this.scopeForm.getRawValue();
|
||||
const items: string[] = [];
|
||||
|
||||
if (scope.type === 'global') {
|
||||
items.push('All assets and components');
|
||||
}
|
||||
if (scope.tenantId) {
|
||||
items.push(`Tenant: ${scope.tenantId}`);
|
||||
}
|
||||
if (scope.assetIds) {
|
||||
const assets = this.parseList(scope.assetIds);
|
||||
items.push(`Assets (${assets.length}): ${assets.slice(0, 3).join(', ')}${assets.length > 3 ? '...' : ''}`);
|
||||
}
|
||||
if (scope.componentPurls) {
|
||||
const purls = this.parseList(scope.componentPurls);
|
||||
items.push(`Components (${purls.length}): ${purls.slice(0, 2).join(', ')}${purls.length > 2 ? '...' : ''}`);
|
||||
}
|
||||
if (scope.vulnIds) {
|
||||
const vulns = this.parseList(scope.vulnIds);
|
||||
items.push(`Vulnerabilities (${vulns.length}): ${vulns.join(', ')}`);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : ['No scope defined'];
|
||||
});
|
||||
|
||||
// Computed: timebox validation
|
||||
readonly timeboxDays = computed(() => {
|
||||
const timebox = this.timeboxForm.getRawValue();
|
||||
const start = new Date(timebox.startDate);
|
||||
const end = new Date(timebox.endDate);
|
||||
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
});
|
||||
|
||||
readonly timeboxWarning = computed(() => {
|
||||
const days = this.timeboxDays();
|
||||
if (days > 90) {
|
||||
return 'Long exception period (>90 days). Consider shorter timebox with renewal.';
|
||||
}
|
||||
if (days > MAX_TIMEBOX_DAYS) {
|
||||
return `Maximum timebox is ${MAX_TIMEBOX_DAYS} days.`;
|
||||
}
|
||||
if (days < MIN_TIMEBOX_DAYS) {
|
||||
return 'End date must be after start date.';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
readonly timeboxValid = computed(() => {
|
||||
const days = this.timeboxDays();
|
||||
return days >= MIN_TIMEBOX_DAYS && days <= MAX_TIMEBOX_DAYS;
|
||||
});
|
||||
|
||||
// Computed: step completion status
|
||||
readonly stepStatus = computed(() => ({
|
||||
basics: this.basicsForm.valid,
|
||||
scope: this.isScopeValid(),
|
||||
justification: this.justificationForm.valid,
|
||||
timebox: this.timeboxValid(),
|
||||
review: true,
|
||||
}));
|
||||
|
||||
// Computed: can proceed to next step
|
||||
readonly canProceed = computed(() => {
|
||||
const step = this.currentStep();
|
||||
return this.stepStatus()[step];
|
||||
});
|
||||
|
||||
// Computed: exception preview
|
||||
readonly exceptionPreview = computed<Partial<Exception>>(() => {
|
||||
const basics = this.basicsForm.getRawValue();
|
||||
const scope = this.scopeForm.getRawValue();
|
||||
const justification = this.justificationForm.getRawValue();
|
||||
const timebox = this.timeboxForm.getRawValue();
|
||||
|
||||
return {
|
||||
name: basics.name,
|
||||
displayName: basics.displayName || undefined,
|
||||
description: basics.description || undefined,
|
||||
severity: basics.severity,
|
||||
status: 'draft',
|
||||
scope: this.buildScope(scope),
|
||||
justification: this.buildJustification(justification),
|
||||
timebox: this.buildTimebox(timebox),
|
||||
};
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Set default template text when template changes
|
||||
this.justificationForm.controls.template.valueChanges.subscribe((templateId) => {
|
||||
const template = this.justificationTemplates.find((t) => t.id === templateId);
|
||||
if (template && !this.justificationForm.controls.text.dirty) {
|
||||
this.justificationForm.patchValue({ text: template.defaultText });
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize with default template text
|
||||
const defaultTemplate = this.justificationTemplates[0];
|
||||
this.justificationForm.patchValue({ text: defaultTemplate.defaultText });
|
||||
}
|
||||
|
||||
// Navigation
|
||||
goToStep(step: WizardStep): void {
|
||||
const currentIndex = this.steps.indexOf(this.currentStep());
|
||||
const targetIndex = this.steps.indexOf(step);
|
||||
|
||||
// Can only go back or to completed steps
|
||||
if (targetIndex < currentIndex || this.canNavigateToStep(step)) {
|
||||
this.currentStep.set(step);
|
||||
}
|
||||
}
|
||||
|
||||
nextStep(): void {
|
||||
const currentIndex = this.steps.indexOf(this.currentStep());
|
||||
if (currentIndex < this.steps.length - 1 && this.canProceed()) {
|
||||
this.currentStep.set(this.steps[currentIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
prevStep(): void {
|
||||
const currentIndex = this.steps.indexOf(this.currentStep());
|
||||
if (currentIndex > 0) {
|
||||
this.currentStep.set(this.steps[currentIndex - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
isStepActive(step: WizardStep): boolean {
|
||||
return this.currentStep() === step;
|
||||
}
|
||||
|
||||
isStepCompleted(step: WizardStep): boolean {
|
||||
const stepIndex = this.steps.indexOf(step);
|
||||
const currentIndex = this.steps.indexOf(this.currentStep());
|
||||
return stepIndex < currentIndex && this.stepStatus()[step];
|
||||
}
|
||||
|
||||
canNavigateToStep(step: WizardStep): boolean {
|
||||
const stepIndex = this.steps.indexOf(step);
|
||||
// Can navigate to any previous step or current step
|
||||
// Can navigate forward only if all previous steps are complete
|
||||
for (let i = 0; i < stepIndex; i++) {
|
||||
if (!this.stepStatus()[this.steps[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getStepLabel(step: WizardStep): string {
|
||||
const labels: Record<WizardStep, string> = {
|
||||
basics: 'Basic Info',
|
||||
scope: 'Scope',
|
||||
justification: 'Justification',
|
||||
timebox: 'Timebox',
|
||||
review: 'Review',
|
||||
};
|
||||
return labels[step];
|
||||
}
|
||||
|
||||
// Template selection
|
||||
selectTemplate(templateId: string): void {
|
||||
this.justificationForm.patchValue({ template: templateId });
|
||||
const template = this.justificationTemplates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.justificationForm.patchValue({ text: template.defaultText });
|
||||
this.justificationForm.controls.text.markAsPristine();
|
||||
}
|
||||
}
|
||||
|
||||
// Timebox helpers
|
||||
setTimeboxPreset(days: number): void {
|
||||
const start = new Date();
|
||||
const end = this.addDays(start, days);
|
||||
this.timeboxForm.patchValue({
|
||||
startDate: this.formatDateForInput(start),
|
||||
endDate: this.formatDateForInput(end),
|
||||
});
|
||||
}
|
||||
|
||||
// Form submission
|
||||
async submitException(): Promise<void> {
|
||||
if (!this.isFormComplete()) {
|
||||
this.error.set('Please complete all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
const exception = this.exceptionPreview();
|
||||
const created = await firstValueFrom(this.api.createException(exception));
|
||||
this.created.emit(created);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create exception.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
private isScopeValid(): boolean {
|
||||
const scope = this.scopeForm.getRawValue();
|
||||
if (scope.type === 'global') {
|
||||
return true;
|
||||
}
|
||||
if (scope.type === 'tenant' && scope.tenantId) {
|
||||
return true;
|
||||
}
|
||||
if (scope.type === 'asset' && scope.assetIds) {
|
||||
return this.parseList(scope.assetIds).length > 0;
|
||||
}
|
||||
if (scope.type === 'component' && scope.componentPurls) {
|
||||
return this.parseList(scope.componentPurls).length > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private isFormComplete(): boolean {
|
||||
return (
|
||||
this.basicsForm.valid &&
|
||||
this.isScopeValid() &&
|
||||
this.justificationForm.valid &&
|
||||
this.timeboxValid()
|
||||
);
|
||||
}
|
||||
|
||||
// Builder helpers
|
||||
private buildScope(raw: ReturnType<typeof this.scopeForm.getRawValue>): Exception['scope'] {
|
||||
return {
|
||||
type: raw.type,
|
||||
tenantId: raw.tenantId || undefined,
|
||||
assetIds: raw.assetIds ? this.parseList(raw.assetIds) : undefined,
|
||||
componentPurls: raw.componentPurls ? this.parseList(raw.componentPurls) : undefined,
|
||||
vulnIds: raw.vulnIds ? this.parseList(raw.vulnIds) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private buildJustification(raw: ReturnType<typeof this.justificationForm.getRawValue>): ExceptionJustification {
|
||||
return {
|
||||
template: raw.template !== 'custom' ? raw.template : undefined,
|
||||
text: raw.text,
|
||||
attachments: raw.attachments ? this.parseList(raw.attachments) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private buildTimebox(raw: ReturnType<typeof this.timeboxForm.getRawValue>): ExceptionTimebox {
|
||||
return {
|
||||
startDate: new Date(raw.startDate).toISOString(),
|
||||
endDate: new Date(raw.endDate).toISOString(),
|
||||
autoRenew: raw.autoRenew || undefined,
|
||||
maxRenewals: raw.autoRenew && raw.maxRenewals > 0 ? raw.maxRenewals : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Utility helpers
|
||||
private parseList(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(/[\n,;]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
private formatDateForInput(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
private addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionType,
|
||||
ExceptionScope,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
type WizardStep = 'type' | 'scope' | 'justification' | 'timebox' | 'review';
|
||||
|
||||
export interface JustificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
template: string;
|
||||
type: ExceptionType[];
|
||||
}
|
||||
|
||||
export interface TimeboxPreset {
|
||||
label: string;
|
||||
days: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ExceptionDraft {
|
||||
type: ExceptionType | null;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
justification: string;
|
||||
scope: Partial<ExceptionScope>;
|
||||
expiresInDays: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-wizard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-wizard.component.html',
|
||||
styleUrls: ['./exception-wizard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionWizardComponent {
|
||||
/** Pre-selected type (e.g., from vulnerability view) */
|
||||
readonly preselectedType = input<ExceptionType>();
|
||||
|
||||
/** Pre-filled scope (e.g., specific CVE) */
|
||||
readonly prefilledScope = input<Partial<ExceptionScope>>();
|
||||
|
||||
/** Available justification templates */
|
||||
readonly templates = input<JustificationTemplate[]>(this.defaultTemplates);
|
||||
|
||||
/** Maximum allowed exception duration in days */
|
||||
readonly maxDurationDays = input(90);
|
||||
|
||||
/** Emits when wizard is cancelled */
|
||||
readonly cancel = output<void>();
|
||||
|
||||
/** Emits when exception is created */
|
||||
readonly create = output<ExceptionDraft>();
|
||||
|
||||
readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review'];
|
||||
readonly currentStep = signal<WizardStep>('type');
|
||||
|
||||
readonly draft = signal<ExceptionDraft>({
|
||||
type: null,
|
||||
severity: 'medium',
|
||||
title: '',
|
||||
justification: '',
|
||||
scope: {},
|
||||
expiresInDays: 30,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
readonly scopePreview = signal<string[]>([]);
|
||||
readonly selectedTemplate = signal<string | null>(null);
|
||||
readonly newTag = signal('');
|
||||
|
||||
readonly timeboxPresets: TimeboxPreset[] = [
|
||||
{ label: '7 days', days: 7, description: 'Short-term exception for urgent fixes' },
|
||||
{ label: '14 days', days: 14, description: 'Sprint-length exception' },
|
||||
{ label: '30 days', days: 30, description: 'Standard exception duration' },
|
||||
{ label: '60 days', days: 60, description: 'Extended exception for complex remediation' },
|
||||
{ label: '90 days', days: 90, description: 'Maximum allowed duration' },
|
||||
];
|
||||
|
||||
readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [
|
||||
{ type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' },
|
||||
{ type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' },
|
||||
{ type: 'policy', label: 'Policy', icon: 'P', description: 'Exception for policy rule violations' },
|
||||
{ type: 'entropy', label: 'Entropy', icon: 'E', description: 'Exception for high entropy findings' },
|
||||
{ type: 'determinism', label: 'Determinism', icon: 'D', description: 'Exception for determinism check failures' },
|
||||
];
|
||||
|
||||
readonly defaultTemplates: JustificationTemplate[] = [
|
||||
{
|
||||
id: 'false-positive',
|
||||
name: 'False Positive',
|
||||
description: 'The finding is a false positive and does not represent a real risk',
|
||||
template: 'This finding has been determined to be a false positive because:\n\n[Explain why this is a false positive]\n\nEvidence:\n- [Evidence 1]\n- [Evidence 2]',
|
||||
type: ['vulnerability', 'entropy', 'license'],
|
||||
},
|
||||
{
|
||||
id: 'mitigated',
|
||||
name: 'Mitigating Controls',
|
||||
description: 'Risk is mitigated by other security controls',
|
||||
template: 'The risk associated with this finding is mitigated by the following controls:\n\n1. [Control 1]\n2. [Control 2]\n\nResidual risk assessment: [Low/Medium]',
|
||||
type: ['vulnerability', 'policy'],
|
||||
},
|
||||
{
|
||||
id: 'planned-fix',
|
||||
name: 'Planned Remediation',
|
||||
description: 'Fix is planned but requires time to implement',
|
||||
template: 'Remediation is planned with the following timeline:\n\nPlanned fix date: [Date]\nAssigned to: [Team/Person]\nTracking ticket: [Ticket ID]\n\nReason for delay:\n[Explain why immediate fix is not possible]',
|
||||
type: ['vulnerability', 'license', 'policy', 'entropy', 'determinism'],
|
||||
},
|
||||
{
|
||||
id: 'business-need',
|
||||
name: 'Business Requirement',
|
||||
description: 'Required for critical business functionality',
|
||||
template: 'This exception is required for the following business reason:\n\n[Explain business requirement]\n\nImpact if not granted:\n- [Impact 1]\n- [Impact 2]\n\nApproved by: [Business Owner]',
|
||||
type: ['license', 'policy'],
|
||||
},
|
||||
];
|
||||
|
||||
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
|
||||
|
||||
readonly canGoNext = computed(() => {
|
||||
const step = this.currentStep();
|
||||
const d = this.draft();
|
||||
|
||||
switch (step) {
|
||||
case 'type':
|
||||
return d.type !== null;
|
||||
case 'scope':
|
||||
return this.hasValidScope();
|
||||
case 'justification':
|
||||
return d.title.trim().length > 0 && d.justification.trim().length > 20;
|
||||
case 'timebox':
|
||||
return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays();
|
||||
case 'review':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
|
||||
|
||||
readonly applicableTemplates = computed(() => {
|
||||
const type = this.draft().type;
|
||||
if (!type) return [];
|
||||
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
|
||||
});
|
||||
|
||||
readonly expirationDate = computed(() => {
|
||||
const days = this.draft().expiresInDays;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
});
|
||||
|
||||
readonly timeboxWarning = computed(() => {
|
||||
const days = this.draft().expiresInDays;
|
||||
if (days > 60) return 'Extended exceptions require additional justification';
|
||||
if (days > 30) return 'Consider if a shorter duration is sufficient';
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Apply preselected values
|
||||
if (this.preselectedType()) {
|
||||
this.updateDraft('type', this.preselectedType()!);
|
||||
this.currentStep.set('scope');
|
||||
}
|
||||
if (this.prefilledScope()) {
|
||||
this.updateDraft('scope', this.prefilledScope()!);
|
||||
}
|
||||
}
|
||||
|
||||
private hasValidScope(): boolean {
|
||||
const scope = this.draft().scope;
|
||||
return !!(
|
||||
(scope.cves && scope.cves.length > 0) ||
|
||||
(scope.packages && scope.packages.length > 0) ||
|
||||
(scope.images && scope.images.length > 0) ||
|
||||
(scope.licenses && scope.licenses.length > 0) ||
|
||||
(scope.policyRules && scope.policyRules.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
updateDraft<K extends keyof ExceptionDraft>(key: K, value: ExceptionDraft[K]): void {
|
||||
this.draft.update((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
updateScope<K extends keyof ExceptionScope>(key: K, value: ExceptionScope[K]): void {
|
||||
this.draft.update((d) => ({
|
||||
...d,
|
||||
scope: { ...d.scope, [key]: value },
|
||||
}));
|
||||
this.updateScopePreview();
|
||||
}
|
||||
|
||||
private updateScopePreview(): void {
|
||||
const scope = this.draft().scope;
|
||||
const preview: string[] = [];
|
||||
|
||||
if (scope.cves?.length) preview.push(`${scope.cves.length} CVE(s)`);
|
||||
if (scope.packages?.length) preview.push(`${scope.packages.length} package(s)`);
|
||||
if (scope.images?.length) preview.push(`${scope.images.length} image(s)`);
|
||||
if (scope.licenses?.length) preview.push(`${scope.licenses.length} license(s)`);
|
||||
if (scope.policyRules?.length) preview.push(`${scope.policyRules.length} rule(s)`);
|
||||
|
||||
this.scopePreview.set(preview);
|
||||
}
|
||||
|
||||
selectType(type: ExceptionType): void {
|
||||
this.updateDraft('type', type);
|
||||
}
|
||||
|
||||
selectTemplate(templateId: string): void {
|
||||
const template = this.applicableTemplates().find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
this.selectedTemplate.set(templateId);
|
||||
this.updateDraft('justification', template.template);
|
||||
}
|
||||
}
|
||||
|
||||
selectTimebox(days: number): void {
|
||||
this.updateDraft('expiresInDays', days);
|
||||
}
|
||||
|
||||
addTag(): void {
|
||||
const tag = this.newTag().trim();
|
||||
if (tag && !this.draft().tags.includes(tag)) {
|
||||
this.updateDraft('tags', [...this.draft().tags, tag]);
|
||||
this.newTag.set('');
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
this.updateDraft('tags', this.draft().tags.filter((t) => t !== tag));
|
||||
}
|
||||
|
||||
goNext(): void {
|
||||
if (!this.canGoNext()) return;
|
||||
const idx = this.currentStepIndex();
|
||||
if (idx < this.steps.length - 1) {
|
||||
this.currentStep.set(this.steps[idx + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (!this.canGoBack()) return;
|
||||
const idx = this.currentStepIndex();
|
||||
if (idx > 0) {
|
||||
this.currentStep.set(this.steps[idx - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
goToStep(step: WizardStep): void {
|
||||
const targetIdx = this.steps.indexOf(step);
|
||||
if (targetIdx <= this.currentStepIndex()) {
|
||||
this.currentStep.set(step);
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.canGoNext()) {
|
||||
this.create.emit(this.draft());
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
onTagInput(event: Event): void {
|
||||
this.newTag.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +1,310 @@
|
||||
<div class="graph-explorer">
|
||||
<!-- Header -->
|
||||
<header class="graph-explorer__header">
|
||||
<div class="graph-explorer__title-section">
|
||||
<h1>Graph Explorer</h1>
|
||||
<p class="graph-explorer__subtitle">Visualize asset dependencies and vulnerabilities</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-explorer__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="loadData()"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Message Toast -->
|
||||
<div
|
||||
class="graph-explorer__message"
|
||||
*ngIf="message() as msg"
|
||||
[class.graph-explorer__message--success]="messageType() === 'success'"
|
||||
[class.graph-explorer__message--error]="messageType() === 'error'"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="graph-explorer__toolbar">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
|
||||
(click)="setViewMode('hierarchy')"
|
||||
>
|
||||
Hierarchy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'flat'"
|
||||
(click)="setViewMode('flat')"
|
||||
>
|
||||
Flat List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Layer Toggles -->
|
||||
<div class="layer-toggles">
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showAssets()" (change)="toggleAssets()" />
|
||||
<span class="layer-toggle__icon">📦</span>
|
||||
<span>Assets</span>
|
||||
</label>
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showComponents()" (change)="toggleComponents()" />
|
||||
<span class="layer-toggle__icon">🧩</span>
|
||||
<span>Components</span>
|
||||
</label>
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showVulnerabilities()" (change)="toggleVulnerabilities()" />
|
||||
<span class="layer-toggle__icon">⚠️</span>
|
||||
<span>Vulnerabilities</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Severity Filter -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Severity</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="filterSeverity()"
|
||||
(change)="setSeverityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="graph-explorer__loading" *ngIf="loading()">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading graph data...</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="graph-explorer__content" *ngIf="!loading()">
|
||||
<!-- Hierarchy View -->
|
||||
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
|
||||
<!-- Assets Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">📦</span>
|
||||
Assets ({{ assets().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of assets(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__badge" *ngIf="node.vulnCount">
|
||||
{{ node.vulnCount }} vulns
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">🧩</span>
|
||||
Components ({{ components().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of components(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__version">{{ node.version }}</span>
|
||||
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span class="graph-node__exception" *ngIf="node.hasException">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">⚠️</span>
|
||||
Vulnerabilities ({{ vulnerabilities().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of vulnerabilities(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span class="graph-node__exception" *ngIf="node.hasException">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flat View -->
|
||||
<div class="flat-view" *ngIf="viewMode() === 'flat'">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Version/ID</th>
|
||||
<th>Severity</th>
|
||||
<th>Exception</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let node of filteredNodes(); trackBy: trackByNode"
|
||||
class="node-table__row"
|
||||
[class.node-table__row--selected]="selectedNodeId() === node.id"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<td>
|
||||
<span class="node-type-badge node-type-badge--{{ node.type }}">
|
||||
{{ getNodeTypeIcon(node.type) }} {{ node.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ node.name }}</td>
|
||||
<td>{{ node.version || node.purl || '-' }}</td>
|
||||
<td>
|
||||
<span class="chip chip--small" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span *ngIf="!node.severity">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="exception-indicator" *ngIf="node.hasException">✓ Excepted</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div class="detail-panel" *ngIf="selectedNode() as node">
|
||||
<div class="detail-panel__header">
|
||||
<div class="detail-panel__title">
|
||||
<span class="detail-panel__icon">{{ getNodeTypeIcon(node.type) }}</span>
|
||||
<h2>{{ node.name }}</h2>
|
||||
</div>
|
||||
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel__content">
|
||||
<!-- Node Info -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Type</span>
|
||||
<span class="node-type-badge node-type-badge--{{ node.type }}">
|
||||
{{ node.type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="node.version">
|
||||
<span class="detail-item__label">Version</span>
|
||||
<span class="detail-item__value">{{ node.version }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="node.severity">
|
||||
<span class="detail-item__label">Severity</span>
|
||||
<span class="chip" [class]="getSeverityClass(node.severity)">{{ node.severity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PURL -->
|
||||
<div class="detail-section" *ngIf="node.purl">
|
||||
<h4>Package URL</h4>
|
||||
<code class="purl-display">{{ node.purl }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Exception Status -->
|
||||
<div class="detail-section" *ngIf="getExceptionBadgeData(node) as badgeData">
|
||||
<h4>Exception Status</h4>
|
||||
<app-exception-badge
|
||||
[data]="badgeData"
|
||||
[compact]="false"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
|
||||
<!-- Related Nodes -->
|
||||
<div class="detail-section">
|
||||
<h4>Related Nodes ({{ relatedNodes().length }})</h4>
|
||||
<div class="related-nodes">
|
||||
<div
|
||||
*ngFor="let related of relatedNodes(); trackBy: trackByNode"
|
||||
class="related-node"
|
||||
(click)="selectNode(related.id)"
|
||||
>
|
||||
<span class="related-node__icon">{{ getNodeTypeIcon(related.type) }}</span>
|
||||
<span class="related-node__name">{{ related.name }}</span>
|
||||
<span class="chip chip--small" *ngIf="related.severity" [class]="getSeverityClass(related.severity)">
|
||||
{{ related.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="related-nodes__empty" *ngIf="relatedNodes().length === 0">
|
||||
No related nodes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions" *ngIf="!node.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
(cancelled)="cancelExceptionDraft()"
|
||||
(openFullWizard)="openFullWizard()"
|
||||
></app-exception-draft-inline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Explain Modal -->
|
||||
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
|
||||
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
|
||||
<div class="explain-modal__container">
|
||||
<app-exception-explain
|
||||
[data]="exceptionExplainData()!"
|
||||
(close)="closeExplain()"
|
||||
(viewException)="viewExceptionFromExplain($event)"
|
||||
></app-exception-explain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-explorer">
|
||||
<!-- Header -->
|
||||
<header class="graph-explorer__header">
|
||||
<div class="graph-explorer__title-section">
|
||||
<h1>Graph Explorer</h1>
|
||||
<p class="graph-explorer__subtitle">Visualize asset dependencies and vulnerabilities</p>
|
||||
</div>
|
||||
|
||||
<div class="graph-explorer__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="loadData()"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Message Toast -->
|
||||
<div
|
||||
class="graph-explorer__message"
|
||||
*ngIf="message() as msg"
|
||||
[class.graph-explorer__message--success]="messageType() === 'success'"
|
||||
[class.graph-explorer__message--error]="messageType() === 'error'"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="graph-explorer__toolbar">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
|
||||
(click)="setViewMode('hierarchy')"
|
||||
>
|
||||
Hierarchy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle__btn"
|
||||
[class.view-toggle__btn--active]="viewMode() === 'flat'"
|
||||
(click)="setViewMode('flat')"
|
||||
>
|
||||
Flat List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Layer Toggles -->
|
||||
<div class="layer-toggles">
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showAssets()" (change)="toggleAssets()" />
|
||||
<span class="layer-toggle__icon">📦</span>
|
||||
<span>Assets</span>
|
||||
</label>
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showComponents()" (change)="toggleComponents()" />
|
||||
<span class="layer-toggle__icon">🧩</span>
|
||||
<span>Components</span>
|
||||
</label>
|
||||
<label class="layer-toggle">
|
||||
<input type="checkbox" [checked]="showVulnerabilities()" (change)="toggleVulnerabilities()" />
|
||||
<span class="layer-toggle__icon">⚠️</span>
|
||||
<span>Vulnerabilities</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Severity Filter -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Severity</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="filterSeverity()"
|
||||
(change)="setSeverityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="graph-explorer__loading" *ngIf="loading()">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading graph data...</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="graph-explorer__content" *ngIf="!loading()">
|
||||
<!-- Hierarchy View -->
|
||||
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
|
||||
<!-- Assets Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">📦</span>
|
||||
Assets ({{ assets().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of assets(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__badge" *ngIf="node.vulnCount">
|
||||
{{ node.vulnCount }} vulns
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Components Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">🧩</span>
|
||||
Components ({{ components().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of components(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__version">{{ node.version }}</span>
|
||||
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span class="graph-node__exception" *ngIf="node.hasException">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerabilities Layer -->
|
||||
<div class="graph-layer">
|
||||
<h3 class="graph-layer__title">
|
||||
<span class="graph-layer__icon">⚠️</span>
|
||||
Vulnerabilities ({{ vulnerabilities().length }})
|
||||
</h3>
|
||||
<div class="graph-nodes">
|
||||
<div
|
||||
*ngFor="let node of vulnerabilities(); trackBy: trackByNode"
|
||||
class="graph-node"
|
||||
[class]="getNodeClass(node)"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<span class="graph-node__name">{{ node.name }}</span>
|
||||
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span class="graph-node__exception" *ngIf="node.hasException">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flat View -->
|
||||
<div class="flat-view" *ngIf="viewMode() === 'flat'">
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Version/ID</th>
|
||||
<th>Severity</th>
|
||||
<th>Exception</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let node of filteredNodes(); trackBy: trackByNode"
|
||||
class="node-table__row"
|
||||
[class.node-table__row--selected]="selectedNodeId() === node.id"
|
||||
(click)="selectNode(node.id)"
|
||||
>
|
||||
<td>
|
||||
<span class="node-type-badge node-type-badge--{{ node.type }}">
|
||||
{{ getNodeTypeIcon(node.type) }} {{ node.type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ node.name }}</td>
|
||||
<td>{{ node.version || node.purl || '-' }}</td>
|
||||
<td>
|
||||
<span class="chip chip--small" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
|
||||
{{ node.severity }}
|
||||
</span>
|
||||
<span *ngIf="!node.severity">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="exception-indicator" *ngIf="node.hasException">✓ Excepted</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div class="detail-panel" *ngIf="selectedNode() as node">
|
||||
<div class="detail-panel__header">
|
||||
<div class="detail-panel__title">
|
||||
<span class="detail-panel__icon">{{ getNodeTypeIcon(node.type) }}</span>
|
||||
<h2>{{ node.name }}</h2>
|
||||
</div>
|
||||
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel__content">
|
||||
<!-- Node Info -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Type</span>
|
||||
<span class="node-type-badge node-type-badge--{{ node.type }}">
|
||||
{{ node.type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="node.version">
|
||||
<span class="detail-item__label">Version</span>
|
||||
<span class="detail-item__value">{{ node.version }}</span>
|
||||
</div>
|
||||
<div class="detail-item" *ngIf="node.severity">
|
||||
<span class="detail-item__label">Severity</span>
|
||||
<span class="chip" [class]="getSeverityClass(node.severity)">{{ node.severity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PURL -->
|
||||
<div class="detail-section" *ngIf="node.purl">
|
||||
<h4>Package URL</h4>
|
||||
<code class="purl-display">{{ node.purl }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Exception Status -->
|
||||
<div class="detail-section" *ngIf="getExceptionBadgeData(node) as badgeData">
|
||||
<h4>Exception Status</h4>
|
||||
<app-exception-badge
|
||||
[data]="badgeData"
|
||||
[compact]="false"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
|
||||
<!-- Related Nodes -->
|
||||
<div class="detail-section">
|
||||
<h4>Related Nodes ({{ relatedNodes().length }})</h4>
|
||||
<div class="related-nodes">
|
||||
<div
|
||||
*ngFor="let related of relatedNodes(); trackBy: trackByNode"
|
||||
class="related-node"
|
||||
(click)="selectNode(related.id)"
|
||||
>
|
||||
<span class="related-node__icon">{{ getNodeTypeIcon(related.type) }}</span>
|
||||
<span class="related-node__name">{{ related.name }}</span>
|
||||
<span class="chip chip--small" *ngIf="related.severity" [class]="getSeverityClass(related.severity)">
|
||||
{{ related.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="related-nodes__empty" *ngIf="relatedNodes().length === 0">
|
||||
No related nodes found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions" *ngIf="!node.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
(cancelled)="cancelExceptionDraft()"
|
||||
(openFullWizard)="openFullWizard()"
|
||||
></app-exception-draft-inline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Explain Modal -->
|
||||
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
|
||||
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
|
||||
<div class="explain-modal__container">
|
||||
<app-exception-explain
|
||||
[data]="exceptionExplainData()!"
|
||||
(close)="closeExplain()"
|
||||
(viewException)="viewExceptionFromExplain($event)"
|
||||
></app-exception-explain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,410 +1,410 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('hierarchy');
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
readonly type: 'asset' | 'component' | 'vulnerability';
|
||||
readonly name: string;
|
||||
readonly purl?: string;
|
||||
readonly version?: string;
|
||||
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly vulnCount?: number;
|
||||
readonly hasException?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
|
||||
}
|
||||
|
||||
const MOCK_NODES: GraphNode[] = [
|
||||
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
|
||||
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
|
||||
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
|
||||
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
|
||||
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
|
||||
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
|
||||
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
|
||||
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
|
||||
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
|
||||
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
|
||||
];
|
||||
|
||||
const MOCK_EDGES: GraphEdge[] = [
|
||||
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
|
||||
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
|
||||
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
|
||||
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
|
||||
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
|
||||
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
|
||||
];
|
||||
|
||||
type ViewMode = 'hierarchy' | 'flat';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
readonly viewMode = signal<ViewMode>('hierarchy');
|
||||
|
||||
// Data
|
||||
readonly nodes = signal<GraphNode[]>([]);
|
||||
readonly edges = signal<GraphEdge[]>([]);
|
||||
readonly selectedNodeId = signal<string | null>(null);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainNodeId = signal<string | null>(null);
|
||||
|
||||
// Filters
|
||||
readonly showVulnerabilities = signal(true);
|
||||
readonly showComponents = signal(true);
|
||||
readonly showAssets = signal(true);
|
||||
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
// Computed: filtered nodes
|
||||
readonly filteredNodes = computed(() => {
|
||||
let items = [...this.nodes()];
|
||||
const showVulns = this.showVulnerabilities();
|
||||
const showComps = this.showComponents();
|
||||
const showAssetNodes = this.showAssets();
|
||||
const severity = this.filterSeverity();
|
||||
|
||||
items = items.filter((n) => {
|
||||
if (n.type === 'vulnerability' && !showVulns) return false;
|
||||
if (n.type === 'component' && !showComps) return false;
|
||||
if (n.type === 'asset' && !showAssetNodes) return false;
|
||||
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
// Computed: assets
|
||||
readonly assets = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'asset');
|
||||
});
|
||||
|
||||
// Computed: components
|
||||
readonly components = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'component');
|
||||
});
|
||||
|
||||
// Computed: vulnerabilities
|
||||
readonly vulnerabilities = computed(() => {
|
||||
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
|
||||
});
|
||||
|
||||
// Computed: selected node
|
||||
readonly selectedNode = computed(() => {
|
||||
const id = this.selectedNodeId();
|
||||
if (!id) return null;
|
||||
return this.nodes().find((n) => n.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: related nodes for selected
|
||||
readonly relatedNodes = computed(() => {
|
||||
const selectedId = this.selectedNodeId();
|
||||
if (!selectedId) return [];
|
||||
|
||||
const edgeList = this.edges();
|
||||
const relatedIds = new Set<string>();
|
||||
|
||||
edgeList.forEach((e) => {
|
||||
if (e.source === selectedId) relatedIds.add(e.target);
|
||||
if (e.target === selectedId) relatedIds.add(e.source);
|
||||
});
|
||||
|
||||
return this.nodes().filter((n) => relatedIds.has(n.id));
|
||||
});
|
||||
|
||||
// Get exception badge data for a node
|
||||
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
|
||||
if (!node.hasException) return null;
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
name: `${node.name} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected node
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const nodeId = this.explainNodeId();
|
||||
if (!nodeId) return null;
|
||||
|
||||
const node = this.nodes().find((n) => n.id === nodeId);
|
||||
if (!node || !node.hasException) return null;
|
||||
|
||||
const relatedComps = this.edges()
|
||||
.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
.map((e) => (e.source === nodeId ? e.target : e.source))
|
||||
.map((id) => this.nodes().find((n) => n.id === id))
|
||||
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
|
||||
|
||||
return {
|
||||
exceptionId: `exc-${node.id}`,
|
||||
name: `${node.name} Exception`,
|
||||
status: 'approved',
|
||||
severity: node.severity ?? 'medium',
|
||||
scope: {
|
||||
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
|
||||
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: 1,
|
||||
affectedAssets: 1,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const node = this.selectedNode();
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'component') {
|
||||
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
|
||||
return {
|
||||
componentPurls: node.purl ? [node.purl] : undefined,
|
||||
vulnIds: relatedVulns.map((v) => v.name),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'component',
|
||||
sourceLabel: `${node.name}@${node.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'vulnerability') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
vulnIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name.toLowerCase()}-exception`,
|
||||
suggestedSeverity: node.severity ?? 'medium',
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === 'asset') {
|
||||
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
|
||||
return {
|
||||
assetIds: [node.name],
|
||||
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
|
||||
suggestedName: `${node.name}-exception`,
|
||||
sourceType: 'asset',
|
||||
sourceLabel: node.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
loadData(): void {
|
||||
this.loading.set(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
this.nodes.set([...MOCK_NODES]);
|
||||
this.edges.set([...MOCK_EDGES]);
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// View mode
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
// Filters
|
||||
toggleVulnerabilities(): void {
|
||||
this.showVulnerabilities.set(!this.showVulnerabilities());
|
||||
}
|
||||
|
||||
toggleComponents(): void {
|
||||
this.showComponents.set(!this.showComponents());
|
||||
}
|
||||
|
||||
toggleAssets(): void {
|
||||
this.showAssets.set(!this.showAssets());
|
||||
}
|
||||
|
||||
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
|
||||
this.filterSeverity.set(severity);
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectNode(nodeId: string): void {
|
||||
this.selectedNodeId.set(nodeId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedNodeId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
// Find the node with this exception ID
|
||||
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
|
||||
if (node) {
|
||||
this.explainNodeId.set(node.id);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainNodeId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getNodeTypeIcon(type: GraphNode['type']): string {
|
||||
switch (type) {
|
||||
case 'asset':
|
||||
return '📦';
|
||||
case 'component':
|
||||
return '🧩';
|
||||
case 'vulnerability':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string | undefined): string {
|
||||
if (!severity) return '';
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getNodeClass(node: GraphNode): string {
|
||||
const classes = [`node--${node.type}`];
|
||||
if (node.severity) classes.push(`node--${node.severity}`);
|
||||
if (node.hasException) classes.push('node--excepted');
|
||||
if (this.selectedNodeId() === node.id) classes.push('node--selected');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
trackByNode = (_: number, item: GraphNode) => item.id;
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
@@ -1,328 +1,328 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,331 +1,331 @@
|
||||
<section class="release-flow">
|
||||
<!-- Header -->
|
||||
<header class="release-flow__header">
|
||||
<div class="header-left">
|
||||
@if (viewMode() === 'detail') {
|
||||
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
|
||||
<span aria-hidden="true">←</span> Releases
|
||||
</button>
|
||||
}
|
||||
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@if (isDeterminismEnabled()) {
|
||||
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
|
||||
Determinism Gates Active
|
||||
</span>
|
||||
} @else {
|
||||
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
|
||||
Determinism Gates Disabled
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading releases...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- List View -->
|
||||
@if (!loading() && viewMode() === 'list') {
|
||||
<div class="releases-list">
|
||||
@for (release of releases(); track trackByReleaseId($index, release)) {
|
||||
<article
|
||||
class="release-card"
|
||||
[class.release-card--blocked]="release.status === 'blocked'"
|
||||
(click)="selectRelease(release)"
|
||||
(keydown.enter)="selectRelease(release)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'View release ' + release.name"
|
||||
>
|
||||
<div class="release-card__header">
|
||||
<h2>{{ release.name }}</h2>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
|
||||
{{ release.status | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__meta">
|
||||
<span class="meta-item">
|
||||
<strong>Version:</strong> {{ release.version }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Target:</strong> {{ release.targetEnvironment }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Artifacts:</strong> {{ release.artifacts.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__gates">
|
||||
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
@if (artifact.policyEvaluation) {
|
||||
<div class="artifact-gates">
|
||||
<span class="artifact-name">{{ artifact.name }}:</span>
|
||||
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
|
||||
<span
|
||||
class="gate-pip"
|
||||
[ngClass]="getStatusClass(gate.status)"
|
||||
[title]="gate.name + ': ' + gate.status"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (release.status === 'blocked') {
|
||||
<div class="release-card__warning">
|
||||
<span class="warning-icon" aria-hidden="true">!</span>
|
||||
Policy gates blocking publish
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-state">No releases found.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Detail View -->
|
||||
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
|
||||
<div class="release-detail">
|
||||
<!-- Release Info -->
|
||||
<section class="detail-section">
|
||||
<h2>Release Information</h2>
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{{ selectedRelease()?.version }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
|
||||
{{ selectedRelease()?.status | titlecase }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target Environment</dt>
|
||||
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedRelease()?.notes) {
|
||||
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Determinism Gate Summary -->
|
||||
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
|
||||
<section class="detail-section determinism-blocking-banner">
|
||||
<div class="banner-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="banner-content">
|
||||
<h3>Determinism Check Failed</h3>
|
||||
<p>
|
||||
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
|
||||
Publishing is blocked until issues are resolved or a bypass is approved.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Artifacts Section -->
|
||||
<section class="detail-section">
|
||||
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
|
||||
<div class="artifacts-tabs" role="tablist">
|
||||
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="artifact-tab"
|
||||
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
|
||||
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
(click)="selectArtifact(artifact)"
|
||||
>
|
||||
<span class="artifact-tab__name">{{ artifact.name }}</span>
|
||||
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
|
||||
@if (!artifact.policyEvaluation?.canPublish) {
|
||||
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Selected Artifact Details -->
|
||||
@if (selectedArtifact()) {
|
||||
<div class="artifact-detail" role="tabpanel">
|
||||
<dl class="artifact-meta">
|
||||
<div>
|
||||
<dt>Digest</dt>
|
||||
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Registry</dt>
|
||||
<dd>{{ selectedArtifact()?.registry }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Policy Gates -->
|
||||
@if (selectedArtifact()?.policyEvaluation) {
|
||||
<div class="policy-gates">
|
||||
<h3>Policy Gates</h3>
|
||||
<div class="gates-list">
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
<app-policy-gate-indicator
|
||||
[gate]="gate"
|
||||
[featureFlags]="featureFlags()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism Details -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
|
||||
<div class="determinism-details">
|
||||
<h4>Determinism Evidence</h4>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Merkle Root</dt>
|
||||
<dd>
|
||||
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
|
||||
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
|
||||
} @else {
|
||||
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fragment Verification</dt>
|
||||
<dd>
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
|
||||
</dd>
|
||||
</div>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
|
||||
<div>
|
||||
<dt>Composition Manifest</dt>
|
||||
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<!-- Failed Fragments -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
|
||||
<div class="failed-fragments">
|
||||
<h5>Failed Fragment Layers</h5>
|
||||
<ul>
|
||||
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
|
||||
<li><code>{{ fragment }}</code></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Hints for Failed Gates -->
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
@if (gate.status === 'failed' && gate.remediation) {
|
||||
<app-remediation-hints [gate]="gate" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="detail-section actions-section">
|
||||
<h2>Actions</h2>
|
||||
<div class="action-buttons">
|
||||
@if (canPublishSelected()) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="publishRelease()"
|
||||
[disabled]="publishing()"
|
||||
>
|
||||
@if (publishing()) {
|
||||
Publishing...
|
||||
} @else {
|
||||
Publish Release
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary btn--disabled"
|
||||
disabled
|
||||
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
|
||||
>
|
||||
Publish Blocked
|
||||
</button>
|
||||
@if (canBypass() && determinismBlockingCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--warning"
|
||||
(click)="openBypassModal()"
|
||||
>
|
||||
Request Bypass
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn--secondary" (click)="backToList()">
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bypass Request Modal -->
|
||||
@if (showBypassModal()) {
|
||||
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
|
||||
<p class="modal-description">
|
||||
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
|
||||
This request requires approval from a security administrator.
|
||||
</p>
|
||||
<label for="bypass-reason">Justification</label>
|
||||
<textarea
|
||||
id="bypass-reason"
|
||||
rows="4"
|
||||
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
|
||||
[value]="bypassReason()"
|
||||
(input)="updateBypassReason($event)"
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="submitBypassRequest()"
|
||||
[disabled]="!bypassReason().trim()"
|
||||
>
|
||||
Submit Request
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
<section class="release-flow">
|
||||
<!-- Header -->
|
||||
<header class="release-flow__header">
|
||||
<div class="header-left">
|
||||
@if (viewMode() === 'detail') {
|
||||
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
|
||||
<span aria-hidden="true">←</span> Releases
|
||||
</button>
|
||||
}
|
||||
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@if (isDeterminismEnabled()) {
|
||||
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
|
||||
Determinism Gates Active
|
||||
</span>
|
||||
} @else {
|
||||
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
|
||||
Determinism Gates Disabled
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading releases...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- List View -->
|
||||
@if (!loading() && viewMode() === 'list') {
|
||||
<div class="releases-list">
|
||||
@for (release of releases(); track trackByReleaseId($index, release)) {
|
||||
<article
|
||||
class="release-card"
|
||||
[class.release-card--blocked]="release.status === 'blocked'"
|
||||
(click)="selectRelease(release)"
|
||||
(keydown.enter)="selectRelease(release)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'View release ' + release.name"
|
||||
>
|
||||
<div class="release-card__header">
|
||||
<h2>{{ release.name }}</h2>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
|
||||
{{ release.status | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__meta">
|
||||
<span class="meta-item">
|
||||
<strong>Version:</strong> {{ release.version }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Target:</strong> {{ release.targetEnvironment }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Artifacts:</strong> {{ release.artifacts.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__gates">
|
||||
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
@if (artifact.policyEvaluation) {
|
||||
<div class="artifact-gates">
|
||||
<span class="artifact-name">{{ artifact.name }}:</span>
|
||||
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
|
||||
<span
|
||||
class="gate-pip"
|
||||
[ngClass]="getStatusClass(gate.status)"
|
||||
[title]="gate.name + ': ' + gate.status"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (release.status === 'blocked') {
|
||||
<div class="release-card__warning">
|
||||
<span class="warning-icon" aria-hidden="true">!</span>
|
||||
Policy gates blocking publish
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-state">No releases found.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Detail View -->
|
||||
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
|
||||
<div class="release-detail">
|
||||
<!-- Release Info -->
|
||||
<section class="detail-section">
|
||||
<h2>Release Information</h2>
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{{ selectedRelease()?.version }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
|
||||
{{ selectedRelease()?.status | titlecase }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target Environment</dt>
|
||||
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedRelease()?.notes) {
|
||||
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Determinism Gate Summary -->
|
||||
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
|
||||
<section class="detail-section determinism-blocking-banner">
|
||||
<div class="banner-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="banner-content">
|
||||
<h3>Determinism Check Failed</h3>
|
||||
<p>
|
||||
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
|
||||
Publishing is blocked until issues are resolved or a bypass is approved.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Artifacts Section -->
|
||||
<section class="detail-section">
|
||||
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
|
||||
<div class="artifacts-tabs" role="tablist">
|
||||
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="artifact-tab"
|
||||
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
|
||||
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
(click)="selectArtifact(artifact)"
|
||||
>
|
||||
<span class="artifact-tab__name">{{ artifact.name }}</span>
|
||||
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
|
||||
@if (!artifact.policyEvaluation?.canPublish) {
|
||||
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Selected Artifact Details -->
|
||||
@if (selectedArtifact()) {
|
||||
<div class="artifact-detail" role="tabpanel">
|
||||
<dl class="artifact-meta">
|
||||
<div>
|
||||
<dt>Digest</dt>
|
||||
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Registry</dt>
|
||||
<dd>{{ selectedArtifact()?.registry }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Policy Gates -->
|
||||
@if (selectedArtifact()?.policyEvaluation) {
|
||||
<div class="policy-gates">
|
||||
<h3>Policy Gates</h3>
|
||||
<div class="gates-list">
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
<app-policy-gate-indicator
|
||||
[gate]="gate"
|
||||
[featureFlags]="featureFlags()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism Details -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
|
||||
<div class="determinism-details">
|
||||
<h4>Determinism Evidence</h4>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Merkle Root</dt>
|
||||
<dd>
|
||||
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
|
||||
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
|
||||
} @else {
|
||||
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fragment Verification</dt>
|
||||
<dd>
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
|
||||
</dd>
|
||||
</div>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
|
||||
<div>
|
||||
<dt>Composition Manifest</dt>
|
||||
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<!-- Failed Fragments -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
|
||||
<div class="failed-fragments">
|
||||
<h5>Failed Fragment Layers</h5>
|
||||
<ul>
|
||||
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
|
||||
<li><code>{{ fragment }}</code></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Hints for Failed Gates -->
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
@if (gate.status === 'failed' && gate.remediation) {
|
||||
<app-remediation-hints [gate]="gate" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="detail-section actions-section">
|
||||
<h2>Actions</h2>
|
||||
<div class="action-buttons">
|
||||
@if (canPublishSelected()) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="publishRelease()"
|
||||
[disabled]="publishing()"
|
||||
>
|
||||
@if (publishing()) {
|
||||
Publishing...
|
||||
} @else {
|
||||
Publish Release
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary btn--disabled"
|
||||
disabled
|
||||
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
|
||||
>
|
||||
Publish Blocked
|
||||
</button>
|
||||
@if (canBypass() && determinismBlockingCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--warning"
|
||||
(click)="openBypassModal()"
|
||||
>
|
||||
Request Bypass
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn--secondary" (click)="backToList()">
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bypass Request Modal -->
|
||||
@if (showBypassModal()) {
|
||||
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
|
||||
<p class="modal-description">
|
||||
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
|
||||
This request requires approval from a security administrator.
|
||||
</p>
|
||||
<label for="bypass-reason">Justification</label>
|
||||
<textarea
|
||||
id="bypass-reason"
|
||||
rows="4"
|
||||
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
|
||||
[value]="bypassReason()"
|
||||
(input)="updateBypassReason($event)"
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="submitBypassRequest()"
|
||||
[disabled]="!bypassReason().trim()"
|
||||
>
|
||||
Submit Request
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,229 +1,229 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
|
||||
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
import { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-flow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
|
||||
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
|
||||
templateUrl: './release-flow.component.html',
|
||||
styleUrls: ['./release-flow.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseFlowComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly releaseApi = inject(RELEASE_API);
|
||||
|
||||
// State
|
||||
readonly releases = signal<readonly Release[]>([]);
|
||||
readonly selectedRelease = signal<Release | null>(null);
|
||||
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
|
||||
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly bypassReason = signal('');
|
||||
readonly showBypassModal = signal(false);
|
||||
|
||||
// Computed values
|
||||
readonly canPublishSelected = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return false;
|
||||
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
|
||||
});
|
||||
|
||||
readonly blockingGatesCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly determinismBlockingCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
const gates = artifact.policyEvaluation?.gates ?? [];
|
||||
const deterministicBlocking = gates.filter(
|
||||
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
|
||||
);
|
||||
return count + deterministicBlocking.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly isDeterminismEnabled = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.enabled ?? false;
|
||||
});
|
||||
|
||||
readonly canBypass = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.bypassRoles && flags.bypassRoles.length > 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Load feature flags
|
||||
this.releaseApi.getFeatureFlags().subscribe({
|
||||
next: (flags) => this.featureFlags.set(flags),
|
||||
error: (err) => console.error('Failed to load feature flags:', err),
|
||||
});
|
||||
|
||||
// Load releases
|
||||
this.releaseApi.listReleases().subscribe({
|
||||
next: (releases) => {
|
||||
this.releases.set(releases);
|
||||
this.loading.set(false);
|
||||
|
||||
// Check if we should auto-select from route
|
||||
const releaseId = this.route.snapshot.paramMap.get('releaseId');
|
||||
if (releaseId) {
|
||||
const release = releases.find((r) => r.releaseId === releaseId);
|
||||
if (release) {
|
||||
this.selectRelease(release);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load releases:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectRelease(release: Release): void {
|
||||
this.selectedRelease.set(release);
|
||||
this.selectedArtifact.set(release.artifacts[0] ?? null);
|
||||
this.viewMode.set('detail');
|
||||
}
|
||||
|
||||
selectArtifact(artifact: ReleaseArtifact): void {
|
||||
this.selectedArtifact.set(artifact);
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
this.selectedRelease.set(null);
|
||||
this.selectedArtifact.set(null);
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
publishRelease(): void {
|
||||
const release = this.selectedRelease();
|
||||
if (!release || !this.canPublishSelected()) return;
|
||||
|
||||
this.publishing.set(true);
|
||||
this.releaseApi.publishRelease(release.releaseId).subscribe({
|
||||
next: (updated) => {
|
||||
// Update the release in the list
|
||||
this.releases.update((list) =>
|
||||
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
|
||||
);
|
||||
this.selectedRelease.set(updated);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Publish failed:', err);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBypassModal(): void {
|
||||
this.bypassReason.set('');
|
||||
this.showBypassModal.set(true);
|
||||
}
|
||||
|
||||
closeBypassModal(): void {
|
||||
this.showBypassModal.set(false);
|
||||
}
|
||||
|
||||
submitBypassRequest(): void {
|
||||
const release = this.selectedRelease();
|
||||
const reason = this.bypassReason();
|
||||
if (!release || !reason.trim()) return;
|
||||
|
||||
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
|
||||
next: (result) => {
|
||||
console.log('Bypass requested:', result.requestId);
|
||||
this.closeBypassModal();
|
||||
// In real implementation, would show notification and refresh
|
||||
},
|
||||
error: (err) => console.error('Bypass request failed:', err),
|
||||
});
|
||||
}
|
||||
|
||||
updateBypassReason(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
this.bypassReason.set(target.value);
|
||||
}
|
||||
|
||||
getStatusClass(status: PolicyGateStatus): string {
|
||||
const statusClasses: Record<PolicyGateStatus, string> = {
|
||||
passed: 'status--passed',
|
||||
failed: 'status--failed',
|
||||
pending: 'status--pending',
|
||||
warning: 'status--warning',
|
||||
skipped: 'status--skipped',
|
||||
};
|
||||
return statusClasses[status] ?? 'status--pending';
|
||||
}
|
||||
|
||||
getReleaseStatusClass(release: Release): string {
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: 'release-status--draft',
|
||||
pending_approval: 'release-status--pending',
|
||||
approved: 'release-status--approved',
|
||||
publishing: 'release-status--publishing',
|
||||
published: 'release-status--published',
|
||||
blocked: 'release-status--blocked',
|
||||
cancelled: 'release-status--cancelled',
|
||||
};
|
||||
return statusClasses[release.status] ?? 'release-status--draft';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
trackByReleaseId(_index: number, release: Release): string {
|
||||
return release.releaseId;
|
||||
}
|
||||
|
||||
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
trackByGateId(_index: number, gate: PolicyGateResult): string {
|
||||
return gate.gateId;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
|
||||
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
import { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-flow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
|
||||
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
|
||||
templateUrl: './release-flow.component.html',
|
||||
styleUrls: ['./release-flow.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseFlowComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly releaseApi = inject(RELEASE_API);
|
||||
|
||||
// State
|
||||
readonly releases = signal<readonly Release[]>([]);
|
||||
readonly selectedRelease = signal<Release | null>(null);
|
||||
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
|
||||
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly bypassReason = signal('');
|
||||
readonly showBypassModal = signal(false);
|
||||
|
||||
// Computed values
|
||||
readonly canPublishSelected = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return false;
|
||||
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
|
||||
});
|
||||
|
||||
readonly blockingGatesCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly determinismBlockingCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
const gates = artifact.policyEvaluation?.gates ?? [];
|
||||
const deterministicBlocking = gates.filter(
|
||||
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
|
||||
);
|
||||
return count + deterministicBlocking.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly isDeterminismEnabled = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.enabled ?? false;
|
||||
});
|
||||
|
||||
readonly canBypass = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.bypassRoles && flags.bypassRoles.length > 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Load feature flags
|
||||
this.releaseApi.getFeatureFlags().subscribe({
|
||||
next: (flags) => this.featureFlags.set(flags),
|
||||
error: (err) => console.error('Failed to load feature flags:', err),
|
||||
});
|
||||
|
||||
// Load releases
|
||||
this.releaseApi.listReleases().subscribe({
|
||||
next: (releases) => {
|
||||
this.releases.set(releases);
|
||||
this.loading.set(false);
|
||||
|
||||
// Check if we should auto-select from route
|
||||
const releaseId = this.route.snapshot.paramMap.get('releaseId');
|
||||
if (releaseId) {
|
||||
const release = releases.find((r) => r.releaseId === releaseId);
|
||||
if (release) {
|
||||
this.selectRelease(release);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load releases:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectRelease(release: Release): void {
|
||||
this.selectedRelease.set(release);
|
||||
this.selectedArtifact.set(release.artifacts[0] ?? null);
|
||||
this.viewMode.set('detail');
|
||||
}
|
||||
|
||||
selectArtifact(artifact: ReleaseArtifact): void {
|
||||
this.selectedArtifact.set(artifact);
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
this.selectedRelease.set(null);
|
||||
this.selectedArtifact.set(null);
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
publishRelease(): void {
|
||||
const release = this.selectedRelease();
|
||||
if (!release || !this.canPublishSelected()) return;
|
||||
|
||||
this.publishing.set(true);
|
||||
this.releaseApi.publishRelease(release.releaseId).subscribe({
|
||||
next: (updated) => {
|
||||
// Update the release in the list
|
||||
this.releases.update((list) =>
|
||||
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
|
||||
);
|
||||
this.selectedRelease.set(updated);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Publish failed:', err);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBypassModal(): void {
|
||||
this.bypassReason.set('');
|
||||
this.showBypassModal.set(true);
|
||||
}
|
||||
|
||||
closeBypassModal(): void {
|
||||
this.showBypassModal.set(false);
|
||||
}
|
||||
|
||||
submitBypassRequest(): void {
|
||||
const release = this.selectedRelease();
|
||||
const reason = this.bypassReason();
|
||||
if (!release || !reason.trim()) return;
|
||||
|
||||
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
|
||||
next: (result) => {
|
||||
console.log('Bypass requested:', result.requestId);
|
||||
this.closeBypassModal();
|
||||
// In real implementation, would show notification and refresh
|
||||
},
|
||||
error: (err) => console.error('Bypass request failed:', err),
|
||||
});
|
||||
}
|
||||
|
||||
updateBypassReason(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
this.bypassReason.set(target.value);
|
||||
}
|
||||
|
||||
getStatusClass(status: PolicyGateStatus): string {
|
||||
const statusClasses: Record<PolicyGateStatus, string> = {
|
||||
passed: 'status--passed',
|
||||
failed: 'status--failed',
|
||||
pending: 'status--pending',
|
||||
warning: 'status--warning',
|
||||
skipped: 'status--skipped',
|
||||
};
|
||||
return statusClasses[status] ?? 'status--pending';
|
||||
}
|
||||
|
||||
getReleaseStatusClass(release: Release): string {
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: 'release-status--draft',
|
||||
pending_approval: 'release-status--pending',
|
||||
approved: 'release-status--approved',
|
||||
publishing: 'release-status--publishing',
|
||||
published: 'release-status--published',
|
||||
blocked: 'release-status--blocked',
|
||||
cancelled: 'release-status--cancelled',
|
||||
};
|
||||
return statusClasses[release.status] ?? 'release-status--draft';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
trackByReleaseId(_index: number, release: Release): string {
|
||||
return release.releaseId;
|
||||
}
|
||||
|
||||
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
trackByGateId(_index: number, gate: PolicyGateResult): string {
|
||||
return gate.gateId;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,296 +1,296 @@
|
||||
<section class="aoc-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading dashboard...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && dashboard()) {
|
||||
<!-- Top Tiles Row -->
|
||||
<div class="tiles-row">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<article class="tile tile--pass-fail">
|
||||
<header class="tile__header">
|
||||
<h2>AOC Pass Rate</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="pass-rate-display">
|
||||
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
|
||||
<span class="pass-rate-trend" [ngClass]="trendClass()">
|
||||
{{ trendIcon() }}
|
||||
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pass-fail-stats">
|
||||
<div class="stat stat--passed">
|
||||
<span class="stat-label">Passed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--failed">
|
||||
<span class="stat-label">Failed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--pending">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini Chart -->
|
||||
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
|
||||
@for (point of chartData(); track point.timestamp) {
|
||||
<div
|
||||
class="chart-bar"
|
||||
[style.height.%]="point.height"
|
||||
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Critical Violations Tile -->
|
||||
<article class="tile tile--violations">
|
||||
<header class="tile__header">
|
||||
<h2>Recent Violations</h2>
|
||||
@if (criticalViolations() > 0) {
|
||||
<span class="critical-badge">{{ criticalViolations() }} critical</span>
|
||||
}
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<ul class="violations-list">
|
||||
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
|
||||
<li
|
||||
class="violation-item"
|
||||
(click)="selectViolation(violation)"
|
||||
(keydown.enter)="selectViolation(violation)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
|
||||
{{ violation.severity | uppercase }}
|
||||
</span>
|
||||
<span class="violation-code">{{ violation.code }}</span>
|
||||
<span class="violation-name">{{ violation.name }}</span>
|
||||
<span class="violation-count">{{ violation.count }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="no-violations">No recent violations</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<article class="tile tile--throughput">
|
||||
<header class="tile__header">
|
||||
<h2>Ingest Throughput</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="throughput-summary">
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
|
||||
<span class="throughput-label">Documents</span>
|
||||
</div>
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
|
||||
<span class="throughput-label">Total Size</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="throughput-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Docs</th>
|
||||
<th>Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
|
||||
<tr>
|
||||
<td>{{ tenant.tenantName }}</td>
|
||||
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
|
||||
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Sources Section -->
|
||||
<section class="sources-section">
|
||||
<header class="section-header">
|
||||
<h2>Sources</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="verify-button"
|
||||
(click)="startVerification()"
|
||||
[disabled]="verifying()"
|
||||
>
|
||||
@if (verifying()) {
|
||||
<span class="spinner-small"></span>
|
||||
Verifying...
|
||||
} @else {
|
||||
Verify Last 24h
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Verification Result -->
|
||||
@if (verificationRequest()) {
|
||||
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
|
||||
<div class="verification-header">
|
||||
<span class="verification-status">
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
Verification Complete
|
||||
} @else if (verificationRequest()!.status === 'running') {
|
||||
Verification Running...
|
||||
} @else {
|
||||
Verification {{ verificationRequest()!.status | titlecase }}
|
||||
}
|
||||
</span>
|
||||
@if (verificationRequest()!.completedAt) {
|
||||
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
<div class="verification-stats">
|
||||
<div class="verification-stat verification-stat--passed">
|
||||
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="verification-stat verification-stat--failed">
|
||||
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
<div class="verification-stat">
|
||||
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (verificationRequest()!.cliCommand) {
|
||||
<div class="cli-parity">
|
||||
<span class="cli-label">CLI Equivalent:</span>
|
||||
<code>{{ verificationRequest()!.cliCommand }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sources-grid">
|
||||
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
|
||||
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
|
||||
<div class="source-header">
|
||||
<span class="source-icon" [attr.aria-label]="source.type">
|
||||
@switch (source.type) {
|
||||
@case ('registry') { <span>📦</span> }
|
||||
@case ('pipeline') { <span>🔄</span> }
|
||||
@case ('repository') { <span>📁</span> }
|
||||
@case ('manual') { <span>📤</span> }
|
||||
}
|
||||
</span>
|
||||
<div class="source-info">
|
||||
<h3>{{ source.name }}</h3>
|
||||
<span class="source-type">{{ source.type | titlecase }}</span>
|
||||
</div>
|
||||
<span class="source-status-badge">{{ source.status | titlecase }}</span>
|
||||
</div>
|
||||
<div class="source-stats">
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ source.checkCount }}</span>
|
||||
<span class="source-stat-label">Checks</span>
|
||||
</div>
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
|
||||
<span class="source-stat-label">Pass Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (source.recentViolations.length > 0) {
|
||||
<div class="source-violations">
|
||||
<span class="source-violations-label">Recent:</span>
|
||||
@for (v of source.recentViolations; track v.code) {
|
||||
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
|
||||
{{ v.code }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="source-last-check">
|
||||
Last check: {{ formatDate(source.lastCheck) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Violation Detail Modal -->
|
||||
@if (selectedViolation()) {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeViolationDetail()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="'violation-title'"
|
||||
>
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<header class="modal-header">
|
||||
<h2 id="violation-title">
|
||||
<span class="modal-code">{{ selectedViolation()!.code }}</span>
|
||||
{{ selectedViolation()!.name }}
|
||||
</h2>
|
||||
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
|
||||
{{ selectedViolation()!.severity | uppercase }}
|
||||
</span>
|
||||
<p class="violation-description">{{ selectedViolation()!.description }}</p>
|
||||
<dl class="violation-meta">
|
||||
<div>
|
||||
<dt>Occurrences</dt>
|
||||
<dd>{{ selectedViolation()!.count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Seen</dt>
|
||||
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedViolation()!.documentationUrl) {
|
||||
<a
|
||||
[href]="selectedViolation()!.documentationUrl"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Documentation →
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<a
|
||||
[routerLink]="['/sources/violations', selectedViolation()!.code]"
|
||||
class="btn btn--primary"
|
||||
>
|
||||
View All Occurrences
|
||||
</a>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
<section class="aoc-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading dashboard...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && dashboard()) {
|
||||
<!-- Top Tiles Row -->
|
||||
<div class="tiles-row">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<article class="tile tile--pass-fail">
|
||||
<header class="tile__header">
|
||||
<h2>AOC Pass Rate</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="pass-rate-display">
|
||||
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
|
||||
<span class="pass-rate-trend" [ngClass]="trendClass()">
|
||||
{{ trendIcon() }}
|
||||
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pass-fail-stats">
|
||||
<div class="stat stat--passed">
|
||||
<span class="stat-label">Passed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--failed">
|
||||
<span class="stat-label">Failed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--pending">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini Chart -->
|
||||
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
|
||||
@for (point of chartData(); track point.timestamp) {
|
||||
<div
|
||||
class="chart-bar"
|
||||
[style.height.%]="point.height"
|
||||
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Critical Violations Tile -->
|
||||
<article class="tile tile--violations">
|
||||
<header class="tile__header">
|
||||
<h2>Recent Violations</h2>
|
||||
@if (criticalViolations() > 0) {
|
||||
<span class="critical-badge">{{ criticalViolations() }} critical</span>
|
||||
}
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<ul class="violations-list">
|
||||
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
|
||||
<li
|
||||
class="violation-item"
|
||||
(click)="selectViolation(violation)"
|
||||
(keydown.enter)="selectViolation(violation)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
|
||||
{{ violation.severity | uppercase }}
|
||||
</span>
|
||||
<span class="violation-code">{{ violation.code }}</span>
|
||||
<span class="violation-name">{{ violation.name }}</span>
|
||||
<span class="violation-count">{{ violation.count }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="no-violations">No recent violations</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<article class="tile tile--throughput">
|
||||
<header class="tile__header">
|
||||
<h2>Ingest Throughput</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="throughput-summary">
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
|
||||
<span class="throughput-label">Documents</span>
|
||||
</div>
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
|
||||
<span class="throughput-label">Total Size</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="throughput-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Docs</th>
|
||||
<th>Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
|
||||
<tr>
|
||||
<td>{{ tenant.tenantName }}</td>
|
||||
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
|
||||
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Sources Section -->
|
||||
<section class="sources-section">
|
||||
<header class="section-header">
|
||||
<h2>Sources</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="verify-button"
|
||||
(click)="startVerification()"
|
||||
[disabled]="verifying()"
|
||||
>
|
||||
@if (verifying()) {
|
||||
<span class="spinner-small"></span>
|
||||
Verifying...
|
||||
} @else {
|
||||
Verify Last 24h
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Verification Result -->
|
||||
@if (verificationRequest()) {
|
||||
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
|
||||
<div class="verification-header">
|
||||
<span class="verification-status">
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
Verification Complete
|
||||
} @else if (verificationRequest()!.status === 'running') {
|
||||
Verification Running...
|
||||
} @else {
|
||||
Verification {{ verificationRequest()!.status | titlecase }}
|
||||
}
|
||||
</span>
|
||||
@if (verificationRequest()!.completedAt) {
|
||||
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
<div class="verification-stats">
|
||||
<div class="verification-stat verification-stat--passed">
|
||||
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="verification-stat verification-stat--failed">
|
||||
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
<div class="verification-stat">
|
||||
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (verificationRequest()!.cliCommand) {
|
||||
<div class="cli-parity">
|
||||
<span class="cli-label">CLI Equivalent:</span>
|
||||
<code>{{ verificationRequest()!.cliCommand }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sources-grid">
|
||||
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
|
||||
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
|
||||
<div class="source-header">
|
||||
<span class="source-icon" [attr.aria-label]="source.type">
|
||||
@switch (source.type) {
|
||||
@case ('registry') { <span>📦</span> }
|
||||
@case ('pipeline') { <span>🔄</span> }
|
||||
@case ('repository') { <span>📁</span> }
|
||||
@case ('manual') { <span>📤</span> }
|
||||
}
|
||||
</span>
|
||||
<div class="source-info">
|
||||
<h3>{{ source.name }}</h3>
|
||||
<span class="source-type">{{ source.type | titlecase }}</span>
|
||||
</div>
|
||||
<span class="source-status-badge">{{ source.status | titlecase }}</span>
|
||||
</div>
|
||||
<div class="source-stats">
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ source.checkCount }}</span>
|
||||
<span class="source-stat-label">Checks</span>
|
||||
</div>
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
|
||||
<span class="source-stat-label">Pass Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (source.recentViolations.length > 0) {
|
||||
<div class="source-violations">
|
||||
<span class="source-violations-label">Recent:</span>
|
||||
@for (v of source.recentViolations; track v.code) {
|
||||
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
|
||||
{{ v.code }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="source-last-check">
|
||||
Last check: {{ formatDate(source.lastCheck) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Violation Detail Modal -->
|
||||
@if (selectedViolation()) {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeViolationDetail()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="'violation-title'"
|
||||
>
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<header class="modal-header">
|
||||
<h2 id="violation-title">
|
||||
<span class="modal-code">{{ selectedViolation()!.code }}</span>
|
||||
{{ selectedViolation()!.name }}
|
||||
</h2>
|
||||
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
|
||||
{{ selectedViolation()!.severity | uppercase }}
|
||||
</span>
|
||||
<p class="violation-description">{{ selectedViolation()!.description }}</p>
|
||||
<dl class="violation-meta">
|
||||
<div>
|
||||
<dt>Occurrences</dt>
|
||||
<dd>{{ selectedViolation()!.count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Seen</dt>
|
||||
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedViolation()!.documentationUrl) {
|
||||
<a
|
||||
[href]="selectedViolation()!.documentationUrl"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Documentation →
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<a
|
||||
[routerLink]="['/sources/violations', selectedViolation()!.code]"
|
||||
class="btn btn--primary"
|
||||
>
|
||||
View All Occurrences
|
||||
</a>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,207 +1,207 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import {
|
||||
AocDashboardSummary,
|
||||
AocViolationCode,
|
||||
IngestThroughput,
|
||||
AocSource,
|
||||
ViolationSeverity,
|
||||
VerificationRequest,
|
||||
} from '../../core/api/aoc.models';
|
||||
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
|
||||
|
||||
@Component({
|
||||
selector: 'app-aoc-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
providers: [{ provide: AOC_API, useClass: MockAocApi }],
|
||||
templateUrl: './aoc-dashboard.component.html',
|
||||
styleUrls: ['./aoc-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AocDashboardComponent implements OnInit {
|
||||
private readonly aocApi = inject(AOC_API);
|
||||
|
||||
// State
|
||||
readonly dashboard = signal<AocDashboardSummary | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly verificationRequest = signal<VerificationRequest | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly selectedViolation = signal<AocViolationCode | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly passRate = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const rate = this.passRate();
|
||||
if (rate >= 95) return 'rate--excellent';
|
||||
if (rate >= 85) return 'rate--good';
|
||||
if (rate >= 70) return 'rate--warning';
|
||||
return 'rate--critical';
|
||||
});
|
||||
|
||||
readonly trendIcon = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return '↑';
|
||||
if (trend === 'degrading') return '↓';
|
||||
return '→';
|
||||
});
|
||||
|
||||
readonly trendClass = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return 'trend--improving';
|
||||
if (trend === 'degrading') return 'trend--degrading';
|
||||
return 'trend--stable';
|
||||
});
|
||||
|
||||
readonly totalThroughput = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return { docs: 0, bytes: 0 };
|
||||
return dash.throughputByTenant.reduce(
|
||||
(acc, t) => ({
|
||||
docs: acc.docs + t.documentsIngested,
|
||||
bytes: acc.bytes + t.bytesIngested,
|
||||
}),
|
||||
{ docs: 0, bytes: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
readonly criticalViolations = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return 0;
|
||||
return dash.recentViolations
|
||||
.filter((v) => v.severity === 'critical')
|
||||
.reduce((sum, v) => sum + v.count, 0);
|
||||
});
|
||||
|
||||
readonly chartData = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return [];
|
||||
const history = dash.passFail.history;
|
||||
const max = Math.max(...history.map((p) => p.value));
|
||||
return history.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
height: (p.value / max) * 100,
|
||||
}));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDashboard();
|
||||
}
|
||||
|
||||
private loadDashboard(): void {
|
||||
this.loading.set(true);
|
||||
this.aocApi.getDashboardSummary().subscribe({
|
||||
next: (summary) => {
|
||||
this.dashboard.set(summary);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load AOC dashboard:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startVerification(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationRequest.set(null);
|
||||
|
||||
this.aocApi.startVerification().subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
// Poll for status updates (simplified - in real app would use interval)
|
||||
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to start verification:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollVerificationStatus(requestId: string): void {
|
||||
this.aocApi.getVerificationStatus(requestId).subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
if (request.status === 'completed' || request.status === 'failed') {
|
||||
this.verifying.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to get verification status:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectViolation(violation: AocViolationCode): void {
|
||||
this.selectedViolation.set(violation);
|
||||
}
|
||||
|
||||
closeViolationDetail(): void {
|
||||
this.selectedViolation.set(null);
|
||||
}
|
||||
|
||||
getSeverityClass(severity: ViolationSeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getSourceStatusClass(source: AocSource): string {
|
||||
return `source-status--${source.status}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
formatShortDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
trackByCode(_index: number, violation: AocViolationCode): string {
|
||||
return violation.code;
|
||||
}
|
||||
|
||||
trackByTenantId(_index: number, throughput: IngestThroughput): string {
|
||||
return throughput.tenantId;
|
||||
}
|
||||
|
||||
trackBySourceId(_index: number, source: AocSource): string {
|
||||
return source.sourceId;
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import {
|
||||
AocDashboardSummary,
|
||||
AocViolationCode,
|
||||
IngestThroughput,
|
||||
AocSource,
|
||||
ViolationSeverity,
|
||||
VerificationRequest,
|
||||
} from '../../core/api/aoc.models';
|
||||
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
|
||||
|
||||
@Component({
|
||||
selector: 'app-aoc-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
providers: [{ provide: AOC_API, useClass: MockAocApi }],
|
||||
templateUrl: './aoc-dashboard.component.html',
|
||||
styleUrls: ['./aoc-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AocDashboardComponent implements OnInit {
|
||||
private readonly aocApi = inject(AOC_API);
|
||||
|
||||
// State
|
||||
readonly dashboard = signal<AocDashboardSummary | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly verificationRequest = signal<VerificationRequest | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly selectedViolation = signal<AocViolationCode | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly passRate = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const rate = this.passRate();
|
||||
if (rate >= 95) return 'rate--excellent';
|
||||
if (rate >= 85) return 'rate--good';
|
||||
if (rate >= 70) return 'rate--warning';
|
||||
return 'rate--critical';
|
||||
});
|
||||
|
||||
readonly trendIcon = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return '↑';
|
||||
if (trend === 'degrading') return '↓';
|
||||
return '→';
|
||||
});
|
||||
|
||||
readonly trendClass = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return 'trend--improving';
|
||||
if (trend === 'degrading') return 'trend--degrading';
|
||||
return 'trend--stable';
|
||||
});
|
||||
|
||||
readonly totalThroughput = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return { docs: 0, bytes: 0 };
|
||||
return dash.throughputByTenant.reduce(
|
||||
(acc, t) => ({
|
||||
docs: acc.docs + t.documentsIngested,
|
||||
bytes: acc.bytes + t.bytesIngested,
|
||||
}),
|
||||
{ docs: 0, bytes: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
readonly criticalViolations = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return 0;
|
||||
return dash.recentViolations
|
||||
.filter((v) => v.severity === 'critical')
|
||||
.reduce((sum, v) => sum + v.count, 0);
|
||||
});
|
||||
|
||||
readonly chartData = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return [];
|
||||
const history = dash.passFail.history;
|
||||
const max = Math.max(...history.map((p) => p.value));
|
||||
return history.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
height: (p.value / max) * 100,
|
||||
}));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDashboard();
|
||||
}
|
||||
|
||||
private loadDashboard(): void {
|
||||
this.loading.set(true);
|
||||
this.aocApi.getDashboardSummary().subscribe({
|
||||
next: (summary) => {
|
||||
this.dashboard.set(summary);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load AOC dashboard:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startVerification(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationRequest.set(null);
|
||||
|
||||
this.aocApi.startVerification().subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
// Poll for status updates (simplified - in real app would use interval)
|
||||
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to start verification:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollVerificationStatus(requestId: string): void {
|
||||
this.aocApi.getVerificationStatus(requestId).subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
if (request.status === 'completed' || request.status === 'failed') {
|
||||
this.verifying.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to get verification status:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectViolation(violation: AocViolationCode): void {
|
||||
this.selectedViolation.set(violation);
|
||||
}
|
||||
|
||||
closeViolationDetail(): void {
|
||||
this.selectedViolation.set(null);
|
||||
}
|
||||
|
||||
getSeverityClass(severity: ViolationSeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getSourceStatusClass(source: AocSource): string {
|
||||
return `source-status--${source.status}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
formatShortDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
trackByCode(_index: number, violation: AocViolationCode): string {
|
||||
return violation.code;
|
||||
}
|
||||
|
||||
trackByTenantId(_index: number, throughput: IngestThroughput): string {
|
||||
return throughput.tenantId;
|
||||
}
|
||||
|
||||
trackBySourceId(_index: number, source: AocSource): string {
|
||||
return source.sourceId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { AocDashboardComponent } from './aoc-dashboard.component';
|
||||
export { ViolationDetailComponent } from './violation-detail.component';
|
||||
export { AocDashboardComponent } from './aoc-dashboard.component';
|
||||
export { ViolationDetailComponent } from './violation-detail.component';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,334 +1,334 @@
|
||||
<div class="vuln-explorer">
|
||||
<!-- Header -->
|
||||
<header class="vuln-explorer__header">
|
||||
<div class="vuln-explorer__title-section">
|
||||
<h1>Vulnerability Explorer</h1>
|
||||
<p class="vuln-explorer__subtitle">Browse and manage vulnerabilities across your assets</p>
|
||||
</div>
|
||||
|
||||
<div class="vuln-explorer__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="loadData()"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="vuln-explorer__stats" *ngIf="stats() as s">
|
||||
<div class="stat-card stat-card--critical">
|
||||
<span class="stat-card__value">{{ s.criticalOpen }}</span>
|
||||
<span class="stat-card__label">Critical Open</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--high">
|
||||
<span class="stat-card__value">{{ s.bySeverity['high'] }}</span>
|
||||
<span class="stat-card__label">High</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ s.total }}</span>
|
||||
<span class="stat-card__label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--excepted">
|
||||
<span class="stat-card__value">{{ s.withExceptions }}</span>
|
||||
<span class="stat-card__label">With Exceptions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Toast -->
|
||||
<div
|
||||
class="vuln-explorer__message"
|
||||
*ngIf="message() as msg"
|
||||
[class.vuln-explorer__message--success]="messageType() === 'success'"
|
||||
[class.vuln-explorer__message--error]="messageType() === 'error'"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="vuln-explorer__toolbar">
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
class="search-box__input"
|
||||
placeholder="Search CVE ID, title, description..."
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="search-box__clear"
|
||||
*ngIf="searchQuery()"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Severity</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="severityFilter()"
|
||||
(change)="setSeverityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option *ngFor="let sev of allSeverities" [value]="sev">
|
||||
{{ severityLabels[sev] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="statusFilter()"
|
||||
(change)="setStatusFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option *ngFor="let st of allStatuses" [value]="st">
|
||||
{{ statusLabels[st] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="showExceptedOnly()"
|
||||
(change)="toggleExceptedOnly()"
|
||||
/>
|
||||
<span>Show with exceptions only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="vuln-explorer__loading" *ngIf="loading()">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading vulnerabilities...</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="vuln-explorer__content" *ngIf="!loading()">
|
||||
<!-- Vulnerability List -->
|
||||
<div class="vuln-list">
|
||||
<table class="vuln-table" *ngIf="filteredVulnerabilities().length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cveId')">
|
||||
CVE ID {{ getSortIcon('cveId') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Title</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('severity')">
|
||||
Severity {{ getSortIcon('severity') }}
|
||||
</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
|
||||
CVSS {{ getSortIcon('cvssScore') }}
|
||||
</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
|
||||
Status {{ getSortIcon('status') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Components</th>
|
||||
<th class="vuln-table__th">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
|
||||
class="vuln-table__row"
|
||||
[class.vuln-table__row--selected]="selectedVulnId() === vuln.vulnId"
|
||||
[class.vuln-table__row--excepted]="vuln.hasException"
|
||||
(click)="selectVulnerability(vuln.vulnId)"
|
||||
>
|
||||
<td class="vuln-table__td">
|
||||
<div class="vuln-cve">
|
||||
<span class="vuln-cve__id">{{ vuln.cveId }}</span>
|
||||
<app-exception-badge
|
||||
*ngIf="getExceptionBadgeData(vuln) as badgeData"
|
||||
[data]="badgeData"
|
||||
[compact]="true"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="vuln-title">{{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip" [ngClass]="getSeverityClass(vuln.severity)">
|
||||
{{ severityLabels[vuln.severity] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="cvss-score" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td vuln-table__td--actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--small btn--action"
|
||||
(click)="startExceptionDraft(vuln); $event.stopPropagation()"
|
||||
*ngIf="!vuln.hasException"
|
||||
title="Create exception for this vulnerability"
|
||||
>
|
||||
+ Exception
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty-state" *ngIf="filteredVulnerabilities().length === 0">
|
||||
<p>No vulnerabilities found matching your filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div class="detail-panel" *ngIf="selectedVulnerability() as vuln">
|
||||
<div class="detail-panel__header">
|
||||
<h2>{{ vuln.cveId }}</h2>
|
||||
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel__content">
|
||||
<!-- Title & Description -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ vuln.title }}</h3>
|
||||
<p class="detail-description">{{ vuln.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Severity & CVSS -->
|
||||
<div class="detail-section detail-section--row">
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Severity</span>
|
||||
<span class="chip chip--large" [ngClass]="getSeverityClass(vuln.severity)">
|
||||
{{ severityLabels[vuln.severity] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">CVSS Score</span>
|
||||
<span class="cvss-score cvss-score--large" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Status</span>
|
||||
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Badge -->
|
||||
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
|
||||
<h4>Exception Status</h4>
|
||||
<app-exception-badge
|
||||
[data]="badgeData"
|
||||
[compact]="false"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
|
||||
<!-- Affected Components -->
|
||||
<div class="detail-section">
|
||||
<h4>Affected Components ({{ vuln.affectedComponents.length }})</h4>
|
||||
<div class="affected-components">
|
||||
<div
|
||||
class="affected-component"
|
||||
*ngFor="let comp of vuln.affectedComponents; trackBy: trackByComponent"
|
||||
>
|
||||
<div class="affected-component__header">
|
||||
<span class="affected-component__name">{{ comp.name }}</span>
|
||||
<span class="affected-component__version">{{ comp.version }}</span>
|
||||
</div>
|
||||
<div class="affected-component__purl">{{ comp.purl }}</div>
|
||||
<div class="affected-component__fix" *ngIf="comp.fixedVersion">
|
||||
Fixed in: <strong>{{ comp.fixedVersion }}</strong>
|
||||
</div>
|
||||
<div class="affected-component__assets">
|
||||
Assets: {{ comp.assetIds.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- References -->
|
||||
<div class="detail-section" *ngIf="vuln.references?.length">
|
||||
<h4>References</h4>
|
||||
<ul class="references-list">
|
||||
<li *ngFor="let ref of vuln.references">
|
||||
<a [href]="ref" target="_blank" rel="noopener noreferrer">{{ ref }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="detail-section">
|
||||
<h4>Timeline</h4>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item" *ngIf="vuln.publishedAt">
|
||||
<span class="timeline-item__label">Published:</span>
|
||||
<span class="timeline-item__value">{{ formatDate(vuln.publishedAt) }}</span>
|
||||
</div>
|
||||
<div class="timeline-item" *ngIf="vuln.modifiedAt">
|
||||
<span class="timeline-item__label">Last Modified:</span>
|
||||
<span class="timeline-item__value">{{ formatDate(vuln.modifiedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
(cancelled)="cancelExceptionDraft()"
|
||||
(openFullWizard)="openFullWizard()"
|
||||
></app-exception-draft-inline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Explain Modal -->
|
||||
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
|
||||
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
|
||||
<div class="explain-modal__container">
|
||||
<app-exception-explain
|
||||
[data]="exceptionExplainData()!"
|
||||
(close)="closeExplain()"
|
||||
(viewException)="viewExceptionFromExplain($event)"
|
||||
></app-exception-explain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vuln-explorer">
|
||||
<!-- Header -->
|
||||
<header class="vuln-explorer__header">
|
||||
<div class="vuln-explorer__title-section">
|
||||
<h1>Vulnerability Explorer</h1>
|
||||
<p class="vuln-explorer__subtitle">Browse and manage vulnerabilities across your assets</p>
|
||||
</div>
|
||||
|
||||
<div class="vuln-explorer__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="loadData()"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="vuln-explorer__stats" *ngIf="stats() as s">
|
||||
<div class="stat-card stat-card--critical">
|
||||
<span class="stat-card__value">{{ s.criticalOpen }}</span>
|
||||
<span class="stat-card__label">Critical Open</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--high">
|
||||
<span class="stat-card__value">{{ s.bySeverity['high'] }}</span>
|
||||
<span class="stat-card__label">High</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card__value">{{ s.total }}</span>
|
||||
<span class="stat-card__label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card--excepted">
|
||||
<span class="stat-card__value">{{ s.withExceptions }}</span>
|
||||
<span class="stat-card__label">With Exceptions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Toast -->
|
||||
<div
|
||||
class="vuln-explorer__message"
|
||||
*ngIf="message() as msg"
|
||||
[class.vuln-explorer__message--success]="messageType() === 'success'"
|
||||
[class.vuln-explorer__message--error]="messageType() === 'error'"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="vuln-explorer__toolbar">
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
class="search-box__input"
|
||||
placeholder="Search CVE ID, title, description..."
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="search-box__clear"
|
||||
*ngIf="searchQuery()"
|
||||
(click)="clearSearch()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Severity</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="severityFilter()"
|
||||
(change)="setSeverityFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option *ngFor="let sev of allSeverities" [value]="sev">
|
||||
{{ severityLabels[sev] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<select
|
||||
class="filter-group__select"
|
||||
[value]="statusFilter()"
|
||||
(change)="setStatusFilter($any($event.target).value)"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option *ngFor="let st of allStatuses" [value]="st">
|
||||
{{ statusLabels[st] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="showExceptedOnly()"
|
||||
(change)="toggleExceptedOnly()"
|
||||
/>
|
||||
<span>Show with exceptions only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="vuln-explorer__loading" *ngIf="loading()">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading vulnerabilities...</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="vuln-explorer__content" *ngIf="!loading()">
|
||||
<!-- Vulnerability List -->
|
||||
<div class="vuln-list">
|
||||
<table class="vuln-table" *ngIf="filteredVulnerabilities().length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cveId')">
|
||||
CVE ID {{ getSortIcon('cveId') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Title</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('severity')">
|
||||
Severity {{ getSortIcon('severity') }}
|
||||
</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
|
||||
CVSS {{ getSortIcon('cvssScore') }}
|
||||
</th>
|
||||
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
|
||||
Status {{ getSortIcon('status') }}
|
||||
</th>
|
||||
<th class="vuln-table__th">Components</th>
|
||||
<th class="vuln-table__th">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
|
||||
class="vuln-table__row"
|
||||
[class.vuln-table__row--selected]="selectedVulnId() === vuln.vulnId"
|
||||
[class.vuln-table__row--excepted]="vuln.hasException"
|
||||
(click)="selectVulnerability(vuln.vulnId)"
|
||||
>
|
||||
<td class="vuln-table__td">
|
||||
<div class="vuln-cve">
|
||||
<span class="vuln-cve__id">{{ vuln.cveId }}</span>
|
||||
<app-exception-badge
|
||||
*ngIf="getExceptionBadgeData(vuln) as badgeData"
|
||||
[data]="badgeData"
|
||||
[compact]="true"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="vuln-title">{{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip" [ngClass]="getSeverityClass(vuln.severity)">
|
||||
{{ severityLabels[vuln.severity] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="cvss-score" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="vuln-table__td">
|
||||
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
|
||||
</td>
|
||||
<td class="vuln-table__td vuln-table__td--actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--small btn--action"
|
||||
(click)="startExceptionDraft(vuln); $event.stopPropagation()"
|
||||
*ngIf="!vuln.hasException"
|
||||
title="Create exception for this vulnerability"
|
||||
>
|
||||
+ Exception
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty-state" *ngIf="filteredVulnerabilities().length === 0">
|
||||
<p>No vulnerabilities found matching your filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div class="detail-panel" *ngIf="selectedVulnerability() as vuln">
|
||||
<div class="detail-panel__header">
|
||||
<h2>{{ vuln.cveId }}</h2>
|
||||
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel__content">
|
||||
<!-- Title & Description -->
|
||||
<div class="detail-section">
|
||||
<h3>{{ vuln.title }}</h3>
|
||||
<p class="detail-description">{{ vuln.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Severity & CVSS -->
|
||||
<div class="detail-section detail-section--row">
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Severity</span>
|
||||
<span class="chip chip--large" [ngClass]="getSeverityClass(vuln.severity)">
|
||||
{{ severityLabels[vuln.severity] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">CVSS Score</span>
|
||||
<span class="cvss-score cvss-score--large" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
|
||||
{{ formatCvss(vuln.cvssScore) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-item__label">Status</span>
|
||||
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
|
||||
{{ statusLabels[vuln.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Badge -->
|
||||
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
|
||||
<h4>Exception Status</h4>
|
||||
<app-exception-badge
|
||||
[data]="badgeData"
|
||||
[compact]="false"
|
||||
(viewDetails)="onViewExceptionDetails($event)"
|
||||
(explain)="onExplainException($event)"
|
||||
></app-exception-badge>
|
||||
</div>
|
||||
|
||||
<!-- Affected Components -->
|
||||
<div class="detail-section">
|
||||
<h4>Affected Components ({{ vuln.affectedComponents.length }})</h4>
|
||||
<div class="affected-components">
|
||||
<div
|
||||
class="affected-component"
|
||||
*ngFor="let comp of vuln.affectedComponents; trackBy: trackByComponent"
|
||||
>
|
||||
<div class="affected-component__header">
|
||||
<span class="affected-component__name">{{ comp.name }}</span>
|
||||
<span class="affected-component__version">{{ comp.version }}</span>
|
||||
</div>
|
||||
<div class="affected-component__purl">{{ comp.purl }}</div>
|
||||
<div class="affected-component__fix" *ngIf="comp.fixedVersion">
|
||||
Fixed in: <strong>{{ comp.fixedVersion }}</strong>
|
||||
</div>
|
||||
<div class="affected-component__assets">
|
||||
Assets: {{ comp.assetIds.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- References -->
|
||||
<div class="detail-section" *ngIf="vuln.references?.length">
|
||||
<h4>References</h4>
|
||||
<ul class="references-list">
|
||||
<li *ngFor="let ref of vuln.references">
|
||||
<a [href]="ref" target="_blank" rel="noopener noreferrer">{{ ref }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Dates -->
|
||||
<div class="detail-section">
|
||||
<h4>Timeline</h4>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item" *ngIf="vuln.publishedAt">
|
||||
<span class="timeline-item__label">Published:</span>
|
||||
<span class="timeline-item__value">{{ formatDate(vuln.publishedAt) }}</span>
|
||||
</div>
|
||||
<div class="timeline-item" *ngIf="vuln.modifiedAt">
|
||||
<span class="timeline-item__label">Last Modified:</span>
|
||||
<span class="timeline-item__value">{{ formatDate(vuln.modifiedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="startExceptionDraft()"
|
||||
>
|
||||
Create Exception
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline Exception Draft -->
|
||||
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
|
||||
<app-exception-draft-inline
|
||||
[context]="exceptionDraftContext()!"
|
||||
(created)="onExceptionCreated()"
|
||||
(cancelled)="cancelExceptionDraft()"
|
||||
(openFullWizard)="openFullWizard()"
|
||||
></app-exception-draft-inline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Explain Modal -->
|
||||
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
|
||||
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
|
||||
<div class="explain-modal__container">
|
||||
<app-exception-explain
|
||||
[data]="exceptionExplainData()!"
|
||||
(close)="closeExplain()"
|
||||
(viewException)="viewExceptionFromExplain($event)"
|
||||
></app-exception-explain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,406 +1,406 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
VULNERABILITY_API,
|
||||
VulnerabilityApi,
|
||||
MockVulnerabilityApiService,
|
||||
} from '../../core/api/vulnerability.client';
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStats,
|
||||
VulnerabilityStatus,
|
||||
} from '../../core/api/vulnerability.models';
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
|
||||
type SeverityFilter = VulnerabilitySeverity | 'all';
|
||||
type StatusFilter = VulnerabilityStatus | 'all';
|
||||
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
|
||||
open: 'Open',
|
||||
fixed: 'Fixed',
|
||||
wont_fix: "Won't Fix",
|
||||
in_progress: 'In Progress',
|
||||
excepted: 'Excepted',
|
||||
};
|
||||
|
||||
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
unknown: 4,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-vulnerability-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
templateUrl: './vulnerability-explorer.component.html',
|
||||
styleUrls: ['./vulnerability-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
|
||||
],
|
||||
})
|
||||
export class VulnerabilityExplorerComponent implements OnInit {
|
||||
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
|
||||
// Data
|
||||
readonly vulnerabilities = signal<Vulnerability[]>([]);
|
||||
readonly stats = signal<VulnerabilityStats | null>(null);
|
||||
readonly selectedVulnId = signal<string | null>(null);
|
||||
|
||||
// Filters & sorting
|
||||
readonly severityFilter = signal<SeverityFilter>('all');
|
||||
readonly statusFilter = signal<StatusFilter>('all');
|
||||
readonly searchQuery = signal('');
|
||||
readonly sortField = signal<SortField>('severity');
|
||||
readonly sortOrder = signal<SortOrder>('asc');
|
||||
readonly showExceptedOnly = signal(false);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
readonly selectedForException = signal<Vulnerability[]>([]);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainExceptionId = signal<string | null>(null);
|
||||
|
||||
// Constants for template
|
||||
readonly severityLabels = SEVERITY_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
|
||||
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
|
||||
|
||||
// Computed: filtered and sorted list
|
||||
readonly filteredVulnerabilities = computed(() => {
|
||||
let items = [...this.vulnerabilities()];
|
||||
const severity = this.severityFilter();
|
||||
const status = this.statusFilter();
|
||||
const search = this.searchQuery().toLowerCase();
|
||||
const exceptedOnly = this.showExceptedOnly();
|
||||
|
||||
if (severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === severity);
|
||||
}
|
||||
if (status !== 'all') {
|
||||
items = items.filter((v) => v.status === status);
|
||||
}
|
||||
if (exceptedOnly) {
|
||||
items = items.filter((v) => v.hasException);
|
||||
}
|
||||
if (search) {
|
||||
items = items.filter(
|
||||
(v) =>
|
||||
v.cveId.toLowerCase().includes(search) ||
|
||||
v.title.toLowerCase().includes(search) ||
|
||||
v.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return this.sortVulnerabilities(items);
|
||||
});
|
||||
|
||||
// Computed: selected vulnerability
|
||||
readonly selectedVulnerability = computed(() => {
|
||||
const id = this.selectedVulnId();
|
||||
if (!id) return null;
|
||||
return this.vulnerabilities().find((v) => v.vulnId === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: get exception badge data for a vulnerability
|
||||
getExceptionBadgeData(vuln: Vulnerability): ExceptionBadgeData | null {
|
||||
if (!vuln.hasException || !vuln.exceptionId) return null;
|
||||
return {
|
||||
exceptionId: vuln.exceptionId,
|
||||
status: 'approved',
|
||||
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
|
||||
name: `${vuln.cveId} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls in place.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected exception
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const exceptionId = this.explainExceptionId();
|
||||
if (!exceptionId) return null;
|
||||
|
||||
const vuln = this.vulnerabilities().find((v) => v.exceptionId === exceptionId);
|
||||
if (!vuln) return null;
|
||||
|
||||
return {
|
||||
exceptionId,
|
||||
name: `${vuln.cveId} Exception`,
|
||||
status: 'approved',
|
||||
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
|
||||
scope: {
|
||||
type: 'vulnerability',
|
||||
vulnIds: [vuln.cveId],
|
||||
componentPurls: vuln.affectedComponents.map((c) => c.purl),
|
||||
assetIds: vuln.affectedComponents.flatMap((c) => c.assetIds),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The vulnerability affects internal services with restricted network access.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: vuln.affectedComponents.length,
|
||||
affectedAssets: [...new Set(vuln.affectedComponents.flatMap((c) => c.assetIds))].length,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const selected = this.selectedForException();
|
||||
if (selected.length === 0) return null;
|
||||
|
||||
const vulnIds = selected.map((v) => v.cveId);
|
||||
const componentPurls = [...new Set(selected.flatMap((v) => v.affectedComponents.map((c) => c.purl)))];
|
||||
const assetIds = [...new Set(selected.flatMap((v) => v.affectedComponents.flatMap((c) => c.assetIds)))];
|
||||
|
||||
const maxSeverity = selected.reduce((max, v) => {
|
||||
return SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[max] ? v.severity : max;
|
||||
}, 'low' as VulnerabilitySeverity);
|
||||
|
||||
return {
|
||||
vulnIds,
|
||||
componentPurls,
|
||||
assetIds,
|
||||
suggestedName: selected.length === 1 ? `${selected[0].cveId.toLowerCase()}-exception` : `multi-vuln-exception-${Date.now()}`,
|
||||
suggestedSeverity: maxSeverity === 'unknown' ? 'medium' : maxSeverity,
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: selected.length === 1 ? selected[0].cveId : `${selected.length} vulnerabilities`,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const [vulnsResponse, statsResponse] = await Promise.all([
|
||||
firstValueFrom(this.api.listVulnerabilities()),
|
||||
firstValueFrom(this.api.getStats()),
|
||||
]);
|
||||
|
||||
this.vulnerabilities.set([...vulnsResponse.items]);
|
||||
this.stats.set(statsResponse);
|
||||
} catch (error) {
|
||||
this.showMessage(this.toErrorMessage(error), 'error');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
setSeverityFilter(severity: SeverityFilter): void {
|
||||
this.severityFilter.set(severity);
|
||||
}
|
||||
|
||||
setStatusFilter(status: StatusFilter): void {
|
||||
this.statusFilter.set(status);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
toggleExceptedOnly(): void {
|
||||
this.showExceptedOnly.set(!this.showExceptedOnly());
|
||||
}
|
||||
|
||||
// Sorting
|
||||
toggleSort(field: SortField): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortOrder.set('asc');
|
||||
}
|
||||
}
|
||||
|
||||
getSortIcon(field: SortField): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortOrder() === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectVulnerability(vulnId: string): void {
|
||||
this.selectedVulnId.set(vulnId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedVulnId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(vuln?: Vulnerability): void {
|
||||
if (vuln) {
|
||||
this.selectedForException.set([vuln]);
|
||||
} else if (this.selectedVulnerability()) {
|
||||
this.selectedForException.set([this.selectedVulnerability()!]);
|
||||
}
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.selectedForException.set([]);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.selectedForException.set([]);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
this.explainExceptionId.set(exceptionId);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainExceptionId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
// In a real app, this would navigate to the Exception Center wizard
|
||||
// For now, just show a message
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getSeverityClass(severity: VulnerabilitySeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getStatusClass(status: VulnerabilityStatus): string {
|
||||
return `status--${status.replace('_', '-')}`;
|
||||
}
|
||||
|
||||
formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatCvss(score: number | undefined): string {
|
||||
if (score === undefined) return '-';
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
|
||||
trackByComponent = (_: number, item: { purl: string }) => item.purl;
|
||||
|
||||
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
|
||||
const field = this.sortField();
|
||||
const order = this.sortOrder();
|
||||
|
||||
return items.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (field) {
|
||||
case 'cveId':
|
||||
comparison = a.cveId.localeCompare(b.cveId);
|
||||
break;
|
||||
case 'severity':
|
||||
comparison = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
||||
break;
|
||||
case 'cvssScore':
|
||||
comparison = (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
|
||||
break;
|
||||
case 'publishedAt':
|
||||
comparison = (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '');
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
return order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
return 'Operation failed. Please retry.';
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
VULNERABILITY_API,
|
||||
VulnerabilityApi,
|
||||
MockVulnerabilityApiService,
|
||||
} from '../../core/api/vulnerability.client';
|
||||
import {
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStats,
|
||||
VulnerabilityStatus,
|
||||
} from '../../core/api/vulnerability.models';
|
||||
import {
|
||||
ExceptionDraftContext,
|
||||
ExceptionDraftInlineComponent,
|
||||
} from '../exceptions/exception-draft-inline.component';
|
||||
import {
|
||||
ExceptionBadgeComponent,
|
||||
ExceptionBadgeData,
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
|
||||
type SeverityFilter = VulnerabilitySeverity | 'all';
|
||||
type StatusFilter = VulnerabilityStatus | 'all';
|
||||
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
|
||||
open: 'Open',
|
||||
fixed: 'Fixed',
|
||||
wont_fix: "Won't Fix",
|
||||
in_progress: 'In Progress',
|
||||
excepted: 'Excepted',
|
||||
};
|
||||
|
||||
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
unknown: 4,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-vulnerability-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
templateUrl: './vulnerability-explorer.component.html',
|
||||
styleUrls: ['./vulnerability-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
|
||||
],
|
||||
})
|
||||
export class VulnerabilityExplorerComponent implements OnInit {
|
||||
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error' | 'info'>('info');
|
||||
|
||||
// Data
|
||||
readonly vulnerabilities = signal<Vulnerability[]>([]);
|
||||
readonly stats = signal<VulnerabilityStats | null>(null);
|
||||
readonly selectedVulnId = signal<string | null>(null);
|
||||
|
||||
// Filters & sorting
|
||||
readonly severityFilter = signal<SeverityFilter>('all');
|
||||
readonly statusFilter = signal<StatusFilter>('all');
|
||||
readonly searchQuery = signal('');
|
||||
readonly sortField = signal<SortField>('severity');
|
||||
readonly sortOrder = signal<SortOrder>('asc');
|
||||
readonly showExceptedOnly = signal(false);
|
||||
|
||||
// Exception draft state
|
||||
readonly showExceptionDraft = signal(false);
|
||||
readonly selectedForException = signal<Vulnerability[]>([]);
|
||||
|
||||
// Exception explain state
|
||||
readonly showExceptionExplain = signal(false);
|
||||
readonly explainExceptionId = signal<string | null>(null);
|
||||
|
||||
// Constants for template
|
||||
readonly severityLabels = SEVERITY_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
|
||||
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
|
||||
|
||||
// Computed: filtered and sorted list
|
||||
readonly filteredVulnerabilities = computed(() => {
|
||||
let items = [...this.vulnerabilities()];
|
||||
const severity = this.severityFilter();
|
||||
const status = this.statusFilter();
|
||||
const search = this.searchQuery().toLowerCase();
|
||||
const exceptedOnly = this.showExceptedOnly();
|
||||
|
||||
if (severity !== 'all') {
|
||||
items = items.filter((v) => v.severity === severity);
|
||||
}
|
||||
if (status !== 'all') {
|
||||
items = items.filter((v) => v.status === status);
|
||||
}
|
||||
if (exceptedOnly) {
|
||||
items = items.filter((v) => v.hasException);
|
||||
}
|
||||
if (search) {
|
||||
items = items.filter(
|
||||
(v) =>
|
||||
v.cveId.toLowerCase().includes(search) ||
|
||||
v.title.toLowerCase().includes(search) ||
|
||||
v.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return this.sortVulnerabilities(items);
|
||||
});
|
||||
|
||||
// Computed: selected vulnerability
|
||||
readonly selectedVulnerability = computed(() => {
|
||||
const id = this.selectedVulnId();
|
||||
if (!id) return null;
|
||||
return this.vulnerabilities().find((v) => v.vulnId === id) ?? null;
|
||||
});
|
||||
|
||||
// Computed: get exception badge data for a vulnerability
|
||||
getExceptionBadgeData(vuln: Vulnerability): ExceptionBadgeData | null {
|
||||
if (!vuln.hasException || !vuln.exceptionId) return null;
|
||||
return {
|
||||
exceptionId: vuln.exceptionId,
|
||||
status: 'approved',
|
||||
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
|
||||
name: `${vuln.cveId} Exception`,
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
justificationSummary: 'Risk accepted with compensating controls in place.',
|
||||
approvedBy: 'Security Team',
|
||||
};
|
||||
}
|
||||
|
||||
// Computed: explain data for selected exception
|
||||
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
|
||||
const exceptionId = this.explainExceptionId();
|
||||
if (!exceptionId) return null;
|
||||
|
||||
const vuln = this.vulnerabilities().find((v) => v.exceptionId === exceptionId);
|
||||
if (!vuln) return null;
|
||||
|
||||
return {
|
||||
exceptionId,
|
||||
name: `${vuln.cveId} Exception`,
|
||||
status: 'approved',
|
||||
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
|
||||
scope: {
|
||||
type: 'vulnerability',
|
||||
vulnIds: [vuln.cveId],
|
||||
componentPurls: vuln.affectedComponents.map((c) => c.purl),
|
||||
assetIds: vuln.affectedComponents.flatMap((c) => c.assetIds),
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted with compensating controls in place. The vulnerability affects internal services with restricted network access.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
autoRenew: false,
|
||||
},
|
||||
approvedBy: 'Security Team',
|
||||
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
impact: {
|
||||
affectedFindings: vuln.affectedComponents.length,
|
||||
affectedAssets: [...new Set(vuln.affectedComponents.flatMap((c) => c.assetIds))].length,
|
||||
policyOverrides: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Computed: exception draft context
|
||||
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
|
||||
const selected = this.selectedForException();
|
||||
if (selected.length === 0) return null;
|
||||
|
||||
const vulnIds = selected.map((v) => v.cveId);
|
||||
const componentPurls = [...new Set(selected.flatMap((v) => v.affectedComponents.map((c) => c.purl)))];
|
||||
const assetIds = [...new Set(selected.flatMap((v) => v.affectedComponents.flatMap((c) => c.assetIds)))];
|
||||
|
||||
const maxSeverity = selected.reduce((max, v) => {
|
||||
return SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[max] ? v.severity : max;
|
||||
}, 'low' as VulnerabilitySeverity);
|
||||
|
||||
return {
|
||||
vulnIds,
|
||||
componentPurls,
|
||||
assetIds,
|
||||
suggestedName: selected.length === 1 ? `${selected[0].cveId.toLowerCase()}-exception` : `multi-vuln-exception-${Date.now()}`,
|
||||
suggestedSeverity: maxSeverity === 'unknown' ? 'medium' : maxSeverity,
|
||||
sourceType: 'vulnerability',
|
||||
sourceLabel: selected.length === 1 ? selected[0].cveId : `${selected.length} vulnerabilities`,
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const [vulnsResponse, statsResponse] = await Promise.all([
|
||||
firstValueFrom(this.api.listVulnerabilities()),
|
||||
firstValueFrom(this.api.getStats()),
|
||||
]);
|
||||
|
||||
this.vulnerabilities.set([...vulnsResponse.items]);
|
||||
this.stats.set(statsResponse);
|
||||
} catch (error) {
|
||||
this.showMessage(this.toErrorMessage(error), 'error');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
setSeverityFilter(severity: SeverityFilter): void {
|
||||
this.severityFilter.set(severity);
|
||||
}
|
||||
|
||||
setStatusFilter(status: StatusFilter): void {
|
||||
this.statusFilter.set(status);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
toggleExceptedOnly(): void {
|
||||
this.showExceptedOnly.set(!this.showExceptedOnly());
|
||||
}
|
||||
|
||||
// Sorting
|
||||
toggleSort(field: SortField): void {
|
||||
if (this.sortField() === field) {
|
||||
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortField.set(field);
|
||||
this.sortOrder.set('asc');
|
||||
}
|
||||
}
|
||||
|
||||
getSortIcon(field: SortField): string {
|
||||
if (this.sortField() !== field) return '';
|
||||
return this.sortOrder() === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
// Selection
|
||||
selectVulnerability(vulnId: string): void {
|
||||
this.selectedVulnId.set(vulnId);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
this.selectedVulnId.set(null);
|
||||
this.showExceptionDraft.set(false);
|
||||
}
|
||||
|
||||
// Exception drafting
|
||||
startExceptionDraft(vuln?: Vulnerability): void {
|
||||
if (vuln) {
|
||||
this.selectedForException.set([vuln]);
|
||||
} else if (this.selectedVulnerability()) {
|
||||
this.selectedForException.set([this.selectedVulnerability()!]);
|
||||
}
|
||||
this.showExceptionDraft.set(true);
|
||||
}
|
||||
|
||||
cancelExceptionDraft(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.selectedForException.set([]);
|
||||
}
|
||||
|
||||
onExceptionCreated(): void {
|
||||
this.showExceptionDraft.set(false);
|
||||
this.selectedForException.set([]);
|
||||
this.showMessage('Exception draft created successfully', 'success');
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// Exception explain
|
||||
onViewExceptionDetails(exceptionId: string): void {
|
||||
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
|
||||
}
|
||||
|
||||
onExplainException(exceptionId: string): void {
|
||||
this.explainExceptionId.set(exceptionId);
|
||||
this.showExceptionExplain.set(true);
|
||||
}
|
||||
|
||||
closeExplain(): void {
|
||||
this.showExceptionExplain.set(false);
|
||||
this.explainExceptionId.set(null);
|
||||
}
|
||||
|
||||
viewExceptionFromExplain(exceptionId: string): void {
|
||||
this.closeExplain();
|
||||
this.onViewExceptionDetails(exceptionId);
|
||||
}
|
||||
|
||||
openFullWizard(): void {
|
||||
// In a real app, this would navigate to the Exception Center wizard
|
||||
// For now, just show a message
|
||||
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
getSeverityClass(severity: VulnerabilitySeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getStatusClass(status: VulnerabilityStatus): string {
|
||||
return `status--${status.replace('_', '-')}`;
|
||||
}
|
||||
|
||||
formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatCvss(score: number | undefined): string {
|
||||
if (score === undefined) return '-';
|
||||
return score.toFixed(1);
|
||||
}
|
||||
|
||||
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
|
||||
trackByComponent = (_: number, item: { purl: string }) => item.purl;
|
||||
|
||||
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
|
||||
const field = this.sortField();
|
||||
const order = this.sortOrder();
|
||||
|
||||
return items.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (field) {
|
||||
case 'cveId':
|
||||
comparison = a.cveId.localeCompare(b.cveId);
|
||||
break;
|
||||
case 'severity':
|
||||
comparison = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
||||
break;
|
||||
case 'cvssScore':
|
||||
comparison = (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
|
||||
break;
|
||||
case 'publishedAt':
|
||||
comparison = (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '');
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
return order === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
|
||||
this.message.set(text);
|
||||
this.messageType.set(type);
|
||||
setTimeout(() => this.message.set(null), 5000);
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
return 'Operation failed. Please retry.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,123 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../core/config/app-config.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-welcome-page',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="welcome-card">
|
||||
<h1>{{ title() }}</h1>
|
||||
<p class="message">{{ message() }}</p>
|
||||
|
||||
<dl class="config-grid">
|
||||
<div>
|
||||
<dt>Quickstart mode</dt>
|
||||
<dd>{{ quickstartEnabled() ? 'Enabled' : 'Disabled' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authority</dt>
|
||||
<dd>{{ config().authority.issuer }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Policy API</dt>
|
||||
<dd>{{ config().apiBaseUrls.policy }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scanner API</dt>
|
||||
<dd>{{ config().apiBaseUrls.scanner }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<a
|
||||
*ngIf="docsUrl() as docs"
|
||||
class="docs-link"
|
||||
[href]="docs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View deployment guide
|
||||
</a>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #4338ca;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WelcomePageComponent {
|
||||
private readonly configService = inject(AppConfigService);
|
||||
|
||||
readonly config = computed(() => this.configService.config);
|
||||
readonly quickstartEnabled = computed(
|
||||
() => this.config().quickstartMode ?? false
|
||||
);
|
||||
readonly title = computed(
|
||||
() => this.config().welcome?.title ?? 'Welcome to StellaOps'
|
||||
);
|
||||
readonly message = computed(
|
||||
() =>
|
||||
this.config().welcome?.message ??
|
||||
'This page surfaces safe deployment configuration for operators.'
|
||||
);
|
||||
readonly docsUrl = computed(() => this.config().welcome?.docsUrl);
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AppConfigService } from '../../core/config/app-config.service';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-welcome-page',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<section class="welcome-card">
|
||||
<h1>{{ title() }}</h1>
|
||||
<p class="message">{{ message() }}</p>
|
||||
|
||||
<dl class="config-grid">
|
||||
<div>
|
||||
<dt>Quickstart mode</dt>
|
||||
<dd>{{ quickstartEnabled() ? 'Enabled' : 'Disabled' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authority</dt>
|
||||
<dd>{{ config().authority.issuer }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Policy API</dt>
|
||||
<dd>{{ config().apiBaseUrls.policy }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scanner API</dt>
|
||||
<dd>{{ config().apiBaseUrls.scanner }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<a
|
||||
*ngIf="docsUrl() as docs"
|
||||
class="docs-link"
|
||||
[href]="docs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View deployment guide
|
||||
</a>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 0 0 1rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
color: #4338ca;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WelcomePageComponent {
|
||||
private readonly configService = inject(AppConfigService);
|
||||
|
||||
readonly config = computed(() => this.configService.config);
|
||||
readonly quickstartEnabled = computed(
|
||||
() => this.config().quickstartMode ?? false
|
||||
);
|
||||
readonly title = computed(
|
||||
() => this.config().welcome?.title ?? 'Welcome to StellaOps'
|
||||
);
|
||||
readonly message = computed(
|
||||
() =>
|
||||
this.config().welcome?.message ??
|
||||
'This page surfaces safe deployment configuration for operators.'
|
||||
);
|
||||
readonly docsUrl = computed(() => this.config().welcome?.docsUrl);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<div class="determinism-badge" [class]="'status-' + status().status">
|
||||
<!-- Compact Badge -->
|
||||
<button
|
||||
class="badge-trigger"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="isExpanded()"
|
||||
aria-controls="determinism-details"
|
||||
>
|
||||
<span class="badge-icon">{{ statusIcon() }}</span>
|
||||
<span class="badge-label">{{ statusLabel() }}</span>
|
||||
<span class="badge-stats">
|
||||
{{ fragmentStats().matched }}/{{ fragmentStats().total }} fragments
|
||||
</span>
|
||||
<span class="badge-expand-icon" [class.expanded]="isExpanded()">▼</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (isExpanded()) {
|
||||
<div id="determinism-details" class="badge-details" role="region" aria-label="Determinism details">
|
||||
<!-- Merkle Root Section -->
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">Merkle Root</h4>
|
||||
<div class="merkle-info">
|
||||
@if (status().merkleRoot) {
|
||||
<code class="hash" [title]="status().merkleRoot">
|
||||
{{ formatHash(status().merkleRoot!, 24) }}
|
||||
</code>
|
||||
<span class="consistency-badge" [class.consistent]="status().merkleConsistent">
|
||||
{{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="no-data">No Merkle root available</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Composition Metadata -->
|
||||
@if (status().composition; as comp) {
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">Composition</h4>
|
||||
<dl class="composition-meta">
|
||||
<div class="meta-item">
|
||||
<dt>Schema</dt>
|
||||
<dd>{{ comp.schemaVersion }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Scanner</dt>
|
||||
<dd>{{ comp.scannerVersion }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Built</dt>
|
||||
<dd>{{ comp.buildTimestamp | date:'short' }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Hash</dt>
|
||||
<dd><code [title]="comp.compositionHash">{{ formatHash(comp.compositionHash) }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="btn-link" (click)="onViewComposition()">
|
||||
View _composition.json →
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fragment Hashes -->
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">
|
||||
Fragment Hashes
|
||||
<span class="fragment-count">
|
||||
({{ fragmentStats().percentage | number:'1.0-0' }}% match)
|
||||
</span>
|
||||
</h4>
|
||||
<div class="fragments-list">
|
||||
@for (fragment of status().fragments; track fragment.id) {
|
||||
<div class="fragment-item" [class.mismatch]="!fragment.matches">
|
||||
<span class="fragment-icon">{{ getFragmentIcon(fragment) }}</span>
|
||||
<div class="fragment-info">
|
||||
<span class="fragment-id" [title]="fragment.id">
|
||||
{{ fragment.type }}: {{ formatHash(fragment.id, 16) }}
|
||||
</span>
|
||||
<span class="fragment-size">{{ formatBytes(fragment.size) }}</span>
|
||||
</div>
|
||||
<div class="fragment-hashes">
|
||||
<code class="hash expected" [title]="fragment.expectedHash">
|
||||
E: {{ formatHash(fragment.expectedHash) }}
|
||||
</code>
|
||||
<code class="hash computed" [title]="fragment.computedHash">
|
||||
C: {{ formatHash(fragment.computedHash) }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issues -->
|
||||
@if (status().issues.length > 0) {
|
||||
<div class="detail-section issues-section">
|
||||
<h4 class="section-title">
|
||||
Issues
|
||||
@if (issuesByLevel().errors.length > 0) {
|
||||
<span class="issue-count error">{{ issuesByLevel().errors.length }} errors</span>
|
||||
}
|
||||
@if (issuesByLevel().warnings.length > 0) {
|
||||
<span class="issue-count warning">{{ issuesByLevel().warnings.length }} warnings</span>
|
||||
}
|
||||
</h4>
|
||||
<ul class="issues-list">
|
||||
@for (issue of status().issues; track issue.code) {
|
||||
<li class="issue-item" [class]="'severity-' + issue.severity">
|
||||
<span class="issue-icon">{{ getIssueIcon(issue) }}</span>
|
||||
<div class="issue-content">
|
||||
<code class="issue-code">{{ issue.code }}</code>
|
||||
<span class="issue-message">{{ issue.message }}</span>
|
||||
@if (issue.fragmentId) {
|
||||
<span class="issue-fragment">Fragment: {{ formatHash(issue.fragmentId) }}</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="verified-at">
|
||||
Verified {{ status().verifiedAt | date:'medium' }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,322 @@
|
||||
.determinism-badge {
|
||||
font-size: 0.875rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.status-verified {
|
||||
.badge-trigger { border-left: 3px solid var(--color-success, #059669); }
|
||||
.badge-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
.badge-trigger { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
.badge-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
.badge-trigger { border-left: 3px solid var(--color-error, #dc2626); }
|
||||
.badge-icon { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
&.status-unknown {
|
||||
.badge-trigger { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
.badge-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
}
|
||||
|
||||
.badge-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.badge-stats {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.badge-expand-icon {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-size: 0.625rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.badge-details {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fragment-count {
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.merkle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.consistency-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
|
||||
&.consistent {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.composition-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.fragments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fragment-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&.mismatch {
|
||||
border-color: var(--color-error, #dc2626);
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-icon {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-success, #059669);
|
||||
|
||||
.mismatch & {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fragment-id {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fragment-size {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.fragment-hashes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.hash.expected {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hash.computed {
|
||||
.mismatch & {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.issues-section {
|
||||
.section-title {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
|
||||
&.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
.issues-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-error .issue-icon { color: var(--color-error, #dc2626); }
|
||||
&.severity-warning .issue-icon { color: var(--color-warning, #d97706); }
|
||||
&.severity-info .issue-icon { color: var(--color-info, #2563eb); }
|
||||
}
|
||||
|
||||
.issue-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.issue-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.issue-fragment {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.verified-at {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
DeterminismStatus,
|
||||
DeterminismFragment,
|
||||
DeterminismIssue,
|
||||
} from '../../core/api/determinism.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-determinism-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './determinism-badge.component.html',
|
||||
styleUrls: ['./determinism-badge.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DeterminismBadgeComponent {
|
||||
/** Determinism status data */
|
||||
readonly status = input.required<DeterminismStatus>();
|
||||
|
||||
/** Whether to show expanded details by default */
|
||||
readonly expanded = input(false);
|
||||
|
||||
/** Emits when user clicks to view full composition */
|
||||
readonly viewComposition = output<void>();
|
||||
|
||||
/** Local expanded state */
|
||||
readonly isExpanded = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.status().status) {
|
||||
case 'verified':
|
||||
return '✓';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.status().status) {
|
||||
case 'verified':
|
||||
return 'Deterministic';
|
||||
case 'warning':
|
||||
return 'Partial';
|
||||
case 'failed':
|
||||
return 'Non-deterministic';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
readonly fragmentStats = computed(() => {
|
||||
const fragments = this.status().fragments;
|
||||
const matched = fragments.filter((f) => f.matches).length;
|
||||
const total = fragments.length;
|
||||
return { matched, total, percentage: total > 0 ? (matched / total) * 100 : 0 };
|
||||
});
|
||||
|
||||
readonly issuesByLevel = computed(() => {
|
||||
const issues = this.status().issues;
|
||||
return {
|
||||
errors: issues.filter((i) => i.severity === 'error'),
|
||||
warnings: issues.filter((i) => i.severity === 'warning'),
|
||||
info: issues.filter((i) => i.severity === 'info'),
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Initialize expanded state from input
|
||||
this.isExpanded.set(this.expanded());
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.isExpanded.update((v) => !v);
|
||||
}
|
||||
|
||||
onViewComposition(): void {
|
||||
this.viewComposition.emit();
|
||||
}
|
||||
|
||||
formatHash(hash: string, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
getFragmentIcon(fragment: DeterminismFragment): string {
|
||||
return fragment.matches ? '✓' : '✗';
|
||||
}
|
||||
|
||||
getIssueIcon(issue: DeterminismIssue): string {
|
||||
switch (issue.severity) {
|
||||
case 'error':
|
||||
return '✗';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<div class="entropy-panel" [class]="riskClass()">
|
||||
<!-- Header with Overall Score -->
|
||||
<header class="panel-header">
|
||||
<div class="score-section">
|
||||
<div class="score-ring" [attr.data-score]="analysis().overallScore">
|
||||
<svg viewBox="0 0 100 100" class="score-svg">
|
||||
<circle class="score-bg" cx="50" cy="50" r="45" />
|
||||
<circle
|
||||
class="score-fill"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
[style.strokeDasharray]="(analysis().overallScore / 10 * 283) + ' 283'"
|
||||
/>
|
||||
</svg>
|
||||
<span class="score-value">{{ analysis().overallScore | number:'1.1-1' }}</span>
|
||||
</div>
|
||||
<div class="score-info">
|
||||
<h3 class="risk-label">{{ analysis().riskLevel | titlecase }} Risk</h3>
|
||||
<p class="score-desc">{{ scoreDescription() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-report" (click)="onViewReport()">
|
||||
View entropy.report.json →
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- Layer Donut Chart -->
|
||||
<section class="section layer-section">
|
||||
<h4 class="section-title">Layer Entropy Distribution</h4>
|
||||
<div class="layer-visualization">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="-50 -50 100 100" class="donut-svg">
|
||||
@for (layer of layerDonutData(); track layer.digest) {
|
||||
<path
|
||||
class="donut-segment"
|
||||
[attr.d]="'M 0 -40 A 40 40 0 ' + (layer.percentage > 50 ? 1 : 0) + ' 1 ' + (Math.sin(layer.percentage * 3.6 * Math.PI / 180) * 40) + ' ' + (-Math.cos(layer.percentage * 3.6 * Math.PI / 180) * 40)"
|
||||
[style.stroke]="layer.color"
|
||||
[style.transform]="'rotate(' + layer.startAngle + 'deg)'"
|
||||
(click)="onSelectLayer(layer.digest)"
|
||||
/>
|
||||
}
|
||||
</svg>
|
||||
<span class="donut-center">
|
||||
{{ analysis().layers.length }} layers
|
||||
</span>
|
||||
</div>
|
||||
<div class="layer-legend">
|
||||
@for (layer of analysis().layers; track layer.digest) {
|
||||
<button
|
||||
class="legend-item"
|
||||
(click)="onSelectLayer(layer.digest)"
|
||||
>
|
||||
<span class="legend-color" [style.background]="layerDonutData()[analysis().layers.indexOf(layer)]?.color"></span>
|
||||
<span class="legend-label" [title]="layer.command">
|
||||
{{ formatPath(layer.command, 20) }}
|
||||
</span>
|
||||
<span class="legend-value">{{ layer.opaqueByteRatio | number:'1.0-0' }}% opaque</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- High Entropy Files Heatmap -->
|
||||
<section class="section files-section">
|
||||
<h4 class="section-title">
|
||||
High Entropy Files
|
||||
<span class="count-badge">{{ analysis().highEntropyFiles.length }}</span>
|
||||
</h4>
|
||||
@if (topHighEntropyFiles().length === 0) {
|
||||
<p class="empty-state">No high entropy files detected</p>
|
||||
} @else {
|
||||
<div class="files-heatmap">
|
||||
@for (file of topHighEntropyFiles(); track file.path) {
|
||||
<button
|
||||
class="file-item"
|
||||
[class]="getEntropyClass(file.entropy)"
|
||||
(click)="onSelectFile(file.path)"
|
||||
>
|
||||
<span class="file-icon">{{ getClassificationIcon(file.classification) }}</span>
|
||||
<div class="file-info">
|
||||
<span class="file-path" [title]="file.path">{{ formatPath(file.path) }}</span>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatBytes(file.size) }}</span>
|
||||
<span class="file-class">{{ file.classification }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entropy-bar-container">
|
||||
<div class="entropy-bar" [style.width]="getEntropyBarWidth(file.entropy)"></div>
|
||||
<span class="entropy-value">{{ file.entropy | number:'1.2-2' }} bits</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (analysis().highEntropyFiles.length > 10) {
|
||||
<p class="more-files">
|
||||
+ {{ analysis().highEntropyFiles.length - 10 }} more files
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Why Risky? Detector Hints -->
|
||||
<section class="section hints-section">
|
||||
<h4 class="section-title">Why Risky?</h4>
|
||||
@if (analysis().detectorHints.length === 0) {
|
||||
<p class="empty-state">No specific risks detected</p>
|
||||
} @else {
|
||||
<div class="hint-chips">
|
||||
@for (group of detectorHintsByType(); track group.type) {
|
||||
<button class="hint-chip" [class]="'severity-' + group.maxSeverity">
|
||||
<span class="chip-icon">{{ getHintTypeIcon(group.type) }}</span>
|
||||
<span class="chip-label">{{ group.type | titlecase }}</span>
|
||||
<span class="chip-count">{{ group.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<ul class="hints-list">
|
||||
@for (hint of analysis().detectorHints.slice(0, 5); track hint.id) {
|
||||
<li class="hint-item" [class]="'severity-' + hint.severity">
|
||||
<div class="hint-header">
|
||||
<span class="hint-icon">{{ getHintTypeIcon(hint.type) }}</span>
|
||||
<span class="hint-type">{{ hint.type | titlecase }}</span>
|
||||
<span class="hint-confidence">{{ hint.confidence }}% confidence</span>
|
||||
</div>
|
||||
<p class="hint-desc">{{ hint.description }}</p>
|
||||
<p class="hint-remediation">
|
||||
<strong>Fix:</strong> {{ hint.remediation }}
|
||||
</p>
|
||||
@if (hint.affectedPaths.length > 0) {
|
||||
<details class="affected-paths">
|
||||
<summary>{{ hint.affectedPaths.length }} affected file(s)</summary>
|
||||
<ul>
|
||||
@for (path of hint.affectedPaths.slice(0, 3); track path) {
|
||||
<li><code>{{ formatPath(path, 50) }}</code></li>
|
||||
}
|
||||
@if (hint.affectedPaths.length > 3) {
|
||||
<li class="more">+ {{ hint.affectedPaths.length - 3 }} more</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (analysis().detectorHints.length > 5) {
|
||||
<p class="more-hints">
|
||||
+ {{ analysis().detectorHints.length - 5 }} more hints in report
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="panel-footer">
|
||||
<span class="analyzed-at">Analyzed {{ analysis().analyzedAt | date:'medium' }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,431 @@
|
||||
.entropy-panel {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.risk-low { --risk-color: var(--color-success, #059669); }
|
||||
&.risk-medium { --risk-color: var(--color-warning, #d97706); }
|
||||
&.risk-high { --risk-color: var(--color-error, #ea580c); }
|
||||
&.risk-critical { --risk-color: var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.score-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.score-ring {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.score-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.score-bg {
|
||||
fill: none;
|
||||
stroke: var(--color-border, #e5e7eb);
|
||||
stroke-width: 8;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
fill: none;
|
||||
stroke: var(--risk-color);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.5s ease;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--risk-color);
|
||||
}
|
||||
|
||||
.score-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--risk-color);
|
||||
}
|
||||
|
||||
.score-desc {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.btn-report {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.layer-visualization {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.donut-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.donut-segment {
|
||||
fill: none;
|
||||
stroke-width: 16;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text, #374151);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.files-heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
&.entropy-low { border-left: 3px solid var(--color-success, #059669); }
|
||||
&.entropy-medium { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.entropy-high { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
&.entropy-critical { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-family: monospace;
|
||||
color: var(--color-text, #374151);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.entropy-bar-container {
|
||||
width: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.entropy-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--color-success, #059669), var(--color-warning, #d97706), var(--color-critical, #dc2626));
|
||||
}
|
||||
|
||||
.entropy-value {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.more-files,
|
||||
.more-hints {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.hint-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hint-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 16px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #e5e7eb);
|
||||
}
|
||||
|
||||
&.severity-critical { border-color: var(--color-critical, #dc2626); background: #fef2f2; }
|
||||
&.severity-high { border-color: var(--color-error, #ea580c); background: #fff7ed; }
|
||||
&.severity-medium { border-color: var(--color-warning, #d97706); background: #fffbeb; }
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.hints-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
&.severity-high { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
.hint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hint-type {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hint-confidence {
|
||||
margin-left: auto;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.hint-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.hint-remediation {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.affected-paths {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.analyzed-at {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
EntropyAnalysis,
|
||||
LayerEntropy,
|
||||
HighEntropyFile,
|
||||
DetectorHint,
|
||||
} from '../../core/api/entropy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './entropy-panel.component.html',
|
||||
styleUrls: ['./entropy-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPanelComponent {
|
||||
/** Entropy analysis data */
|
||||
readonly analysis = input.required<EntropyAnalysis>();
|
||||
|
||||
/** Emits when user wants to view raw report */
|
||||
readonly viewReport = output<void>();
|
||||
|
||||
/** Emits when user clicks on a layer */
|
||||
readonly selectLayer = output<string>();
|
||||
|
||||
/** Emits when user clicks on a file */
|
||||
readonly selectFile = output<string>();
|
||||
|
||||
readonly riskClass = computed(() => 'risk-' + this.analysis().riskLevel);
|
||||
|
||||
readonly scoreDescription = computed(() => {
|
||||
const score = this.analysis().overallScore;
|
||||
if (score <= 2) return 'Minimal entropy detected';
|
||||
if (score <= 4) return 'Normal entropy levels';
|
||||
if (score <= 6) return 'Elevated entropy - review recommended';
|
||||
if (score <= 8) return 'High entropy - potential secrets detected';
|
||||
return 'Critical entropy - likely obfuscated content';
|
||||
});
|
||||
|
||||
readonly layerDonutData = computed(() => {
|
||||
const layers = this.analysis().layers;
|
||||
const total = layers.reduce((sum, l) => sum + l.riskContribution, 0);
|
||||
return layers.map((layer, index) => ({
|
||||
...layer,
|
||||
percentage: total > 0 ? (layer.riskContribution / total) * 100 : 0,
|
||||
startAngle: this.calculateStartAngle(layers, index, total),
|
||||
color: this.getLayerColor(layer.riskContribution, total / layers.length),
|
||||
}));
|
||||
});
|
||||
|
||||
readonly topHighEntropyFiles = computed(() => {
|
||||
return [...this.analysis().highEntropyFiles]
|
||||
.sort((a, b) => b.entropy - a.entropy)
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
readonly detectorHintsByType = computed(() => {
|
||||
const hints = this.analysis().detectorHints;
|
||||
const byType = new Map<string, DetectorHint[]>();
|
||||
for (const hint of hints) {
|
||||
const existing = byType.get(hint.type) || [];
|
||||
existing.push(hint);
|
||||
byType.set(hint.type, existing);
|
||||
}
|
||||
return Array.from(byType.entries()).map(([type, items]) => ({
|
||||
type,
|
||||
items,
|
||||
count: items.length,
|
||||
maxSeverity: this.getMaxSeverity(items),
|
||||
}));
|
||||
});
|
||||
|
||||
private calculateStartAngle(
|
||||
layers: LayerEntropy[],
|
||||
index: number,
|
||||
total: number
|
||||
): number {
|
||||
let angle = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
angle += total > 0 ? (layers[i].riskContribution / total) * 360 : 0;
|
||||
}
|
||||
return angle;
|
||||
}
|
||||
|
||||
private getLayerColor(contribution: number, avg: number): string {
|
||||
const ratio = contribution / (avg || 1);
|
||||
if (ratio > 2) return 'var(--color-entropy-critical, #dc2626)';
|
||||
if (ratio > 1.5) return 'var(--color-entropy-high, #ea580c)';
|
||||
if (ratio > 1) return 'var(--color-entropy-medium, #d97706)';
|
||||
return 'var(--color-entropy-low, #65a30d)';
|
||||
}
|
||||
|
||||
private getMaxSeverity(hints: DetectorHint[]): string {
|
||||
const severityOrder = ['critical', 'high', 'medium', 'low'];
|
||||
for (const severity of severityOrder) {
|
||||
if (hints.some((h) => h.severity === severity)) {
|
||||
return severity;
|
||||
}
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
getEntropyBarWidth(entropy: number): string {
|
||||
// Entropy is 0-8 bits, normalize to percentage
|
||||
return Math.min(entropy / 8 * 100, 100) + '%';
|
||||
}
|
||||
|
||||
getEntropyClass(entropy: number): string {
|
||||
if (entropy >= 7) return 'entropy-critical';
|
||||
if (entropy >= 6) return 'entropy-high';
|
||||
if (entropy >= 4.5) return 'entropy-medium';
|
||||
return 'entropy-low';
|
||||
}
|
||||
|
||||
getClassificationIcon(classification: HighEntropyFile['classification']): string {
|
||||
switch (classification) {
|
||||
case 'encrypted':
|
||||
return '🔐';
|
||||
case 'compressed':
|
||||
return '📦';
|
||||
case 'binary':
|
||||
return '⚙️';
|
||||
case 'suspicious':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
getHintTypeIcon(type: DetectorHint['type']): string {
|
||||
switch (type) {
|
||||
case 'credential':
|
||||
return '🔑';
|
||||
case 'key':
|
||||
return '🔏';
|
||||
case 'token':
|
||||
return '🎫';
|
||||
case 'obfuscated':
|
||||
return '🎭';
|
||||
case 'packed':
|
||||
return '📦';
|
||||
case 'crypto':
|
||||
return '🔐';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
formatPath(path: string, maxLength = 40): string {
|
||||
if (path.length <= maxLength) return path;
|
||||
return '...' + path.slice(-maxLength + 3);
|
||||
}
|
||||
|
||||
onViewReport(): void {
|
||||
this.viewReport.emit();
|
||||
}
|
||||
|
||||
onSelectLayer(digest: string): void {
|
||||
this.selectLayer.emit(digest);
|
||||
}
|
||||
|
||||
onSelectFile(path: string): void {
|
||||
this.selectFile.emit(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<div class="entropy-policy-banner" [class]="bannerClass()">
|
||||
<!-- Main Banner -->
|
||||
<div class="banner-main">
|
||||
<span class="banner-icon">{{ bannerIcon() }}</span>
|
||||
|
||||
<div class="banner-content">
|
||||
<h4 class="banner-title">{{ bannerTitle() }}</h4>
|
||||
<p class="banner-message">{{ bannerMessage() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="banner-actions">
|
||||
@if (config().reportUrl) {
|
||||
<button class="btn-secondary" (click)="onDownloadReport()">
|
||||
Download Report
|
||||
</button>
|
||||
}
|
||||
<button class="btn-secondary" (click)="onViewAnalysis()">
|
||||
View Analysis
|
||||
</button>
|
||||
@if (config().action !== 'allow') {
|
||||
<button
|
||||
class="btn-expand"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
{{ expanded() ? 'Hide' : 'Show' }} Details
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Visualization -->
|
||||
<div class="score-visualization">
|
||||
<div class="score-bar">
|
||||
<div class="score-track">
|
||||
<!-- Zone backgrounds -->
|
||||
<div class="zone allow" [style.width]="warnPercentage() + '%'"></div>
|
||||
<div
|
||||
class="zone warn"
|
||||
[style.left]="warnPercentage() + '%'"
|
||||
[style.width]="(blockPercentage() - warnPercentage()) + '%'"
|
||||
></div>
|
||||
<div
|
||||
class="zone block"
|
||||
[style.left]="blockPercentage() + '%'"
|
||||
[style.width]="(100 - blockPercentage()) + '%'"
|
||||
></div>
|
||||
|
||||
<!-- Threshold markers -->
|
||||
<div class="threshold-line warn" [style.left]="warnPercentage() + '%'">
|
||||
<span class="threshold-label">Warn</span>
|
||||
</div>
|
||||
<div class="threshold-line block" [style.left]="blockPercentage() + '%'">
|
||||
<span class="threshold-label">Block</span>
|
||||
</div>
|
||||
|
||||
<!-- Current score marker -->
|
||||
<div
|
||||
class="score-marker"
|
||||
[style.left]="scorePercentage() + '%'"
|
||||
[class]="'action-' + config().action"
|
||||
>
|
||||
<span class="score-value">{{ config().currentScore | number:'1.1-1' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scale-labels">
|
||||
<span>0</span>
|
||||
<span>2</span>
|
||||
<span>4</span>
|
||||
<span>6</span>
|
||||
<span>8</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="score-legend">
|
||||
<span class="legend-item allow">
|
||||
<span class="legend-dot"></span>
|
||||
Allow (< {{ config().warnThreshold }})
|
||||
</span>
|
||||
<span class="legend-item warn">
|
||||
<span class="legend-dot"></span>
|
||||
Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }})
|
||||
</span>
|
||||
<span class="legend-item block">
|
||||
<span class="legend-dot"></span>
|
||||
Block (> {{ config().blockThreshold }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (expanded()) {
|
||||
<div class="banner-details">
|
||||
<!-- Policy Info -->
|
||||
<div class="policy-info">
|
||||
<h5 class="section-title">Policy Information</h5>
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>Policy</dt>
|
||||
<dd>{{ config().policyName }}</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Policy ID</dt>
|
||||
<dd><code>{{ config().policyId }}</code></dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Warn Threshold</dt>
|
||||
<dd>{{ config().warnThreshold }} / 10</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Block Threshold</dt>
|
||||
<dd>{{ config().blockThreshold }} / 10</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>High Entropy Files</dt>
|
||||
<dd>{{ config().highEntropyFileCount }} files</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Explanation -->
|
||||
<div class="threshold-explanation">
|
||||
<h5 class="section-title">Understanding Entropy Thresholds</h5>
|
||||
<div class="explanation-content">
|
||||
<p>
|
||||
<strong>Entropy</strong> measures the randomness of data in files. High entropy often indicates:
|
||||
</p>
|
||||
<ul class="entropy-indicators">
|
||||
<li><span class="indicator-icon">[E]</span> Encrypted content</li>
|
||||
<li><span class="indicator-icon">[C]</span> Compressed files (zip, gz, etc.)</li>
|
||||
<li><span class="indicator-icon">[B]</span> Binary executables</li>
|
||||
<li><span class="indicator-icon">[S]</span> Potential secrets or credentials</li>
|
||||
<li><span class="indicator-icon">[O]</span> Obfuscated or packed code</li>
|
||||
</ul>
|
||||
<p class="explanation-note">
|
||||
While some high-entropy content is legitimate (fonts, images, compressed assets),
|
||||
unexpected high entropy may indicate security concerns requiring review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mitigation Steps -->
|
||||
@if (config().action !== 'allow') {
|
||||
<div class="mitigation-section">
|
||||
<h5 class="section-title">Mitigation Steps</h5>
|
||||
<div class="mitigation-list">
|
||||
@for (step of effectiveMitigationSteps(); track step.id) {
|
||||
<div class="mitigation-card">
|
||||
<div class="mitigation-header">
|
||||
<span class="mitigation-title">{{ step.title }}</span>
|
||||
<div class="mitigation-badges">
|
||||
<span class="badge impact" [class]="'impact-' + step.impact">
|
||||
{{ getImpactLabel(step.impact) }}
|
||||
</span>
|
||||
<span class="badge effort">{{ getEffortLabel(step.effort) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mitigation-desc">{{ step.description }}</p>
|
||||
|
||||
@if (step.command) {
|
||||
<div class="mitigation-command">
|
||||
<code>{{ step.command }}</code>
|
||||
<button class="btn-run" (click)="onRunMitigation(step)">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (step.docsUrl) {
|
||||
<a class="docs-link" [href]="step.docsUrl" target="_blank">
|
||||
Learn more ->
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Report Download -->
|
||||
@if (config().reportUrl) {
|
||||
<div class="report-section">
|
||||
<h5 class="section-title">Raw Evidence</h5>
|
||||
<div class="report-info">
|
||||
<span class="report-label">entropy.report.json</span>
|
||||
<p class="report-desc">
|
||||
Download the full entropy analysis report containing per-file entropy scores,
|
||||
detector findings, and detailed metrics.
|
||||
</p>
|
||||
<button class="btn-download" (click)="onDownloadReport()">
|
||||
Download entropy.report.json
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,489 @@
|
||||
.entropy-policy-banner {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.action-allow {
|
||||
border-color: var(--color-success-border, #a7f3d0);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
border-left: 4px solid var(--color-success, #059669);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-warn {
|
||||
border-color: var(--color-warning-border, #fde68a);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
border-left: 4px solid var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-block {
|
||||
border-color: var(--color-error-border, #fecaca);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-left: 4px solid var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-expand {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary, #2563eb);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
}
|
||||
}
|
||||
|
||||
// Score Visualization
|
||||
.score-visualization {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.score-track {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
|
||||
&.allow {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
}
|
||||
|
||||
&.block {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.threshold-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
transform: translateX(-1px);
|
||||
|
||||
&.warn {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.block {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.threshold-label {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.score-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
|
||||
&.action-allow {
|
||||
.score-value {
|
||||
background: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-warn {
|
||||
.score-value {
|
||||
background: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-block {
|
||||
.score-value {
|
||||
background: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.score-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&.allow .legend-dot { background: var(--color-success, #059669); }
|
||||
&.warn .legend-dot { background: var(--color-warning, #d97706); }
|
||||
&.block .legend-dot { background: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
// Expanded Details
|
||||
.banner-details {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.policy-info,
|
||||
.threshold-explanation,
|
||||
.mitigation-section,
|
||||
.report-section {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explanation-content {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
.entropy-indicators {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.25rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.explanation-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mitigation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mitigation-card {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.mitigation-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mitigation-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.mitigation-badges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.impact {
|
||||
&.impact-high {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
&.impact-medium {
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
|
||||
&.impact-low {
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
&.effort {
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
.mitigation-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.mitigation-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.report-info {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.report-label {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.report-desc {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface EntropyPolicyConfig {
|
||||
/** Warn threshold (0-10) */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Block threshold (0-10) */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Current entropy score */
|
||||
currentScore: number;
|
||||
|
||||
/** Action taken */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** Policy ID */
|
||||
policyId: string;
|
||||
|
||||
/** Policy name */
|
||||
policyName: string;
|
||||
|
||||
/** High entropy file count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Link to entropy report */
|
||||
reportUrl?: string;
|
||||
}
|
||||
|
||||
export interface EntropyMitigationStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
effort: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
command?: string;
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-policy-banner',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './entropy-policy-banner.component.html',
|
||||
styleUrls: ['./entropy-policy-banner.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPolicyBannerComponent {
|
||||
/** Policy configuration and current state */
|
||||
readonly config = input.required<EntropyPolicyConfig>();
|
||||
|
||||
/** Custom mitigation steps */
|
||||
readonly mitigationSteps = input<EntropyMitigationStep[]>([]);
|
||||
|
||||
/** Emits when user wants to download entropy report */
|
||||
readonly downloadReport = output<string>();
|
||||
|
||||
/** Emits when user runs a mitigation command */
|
||||
readonly runMitigation = output<EntropyMitigationStep>();
|
||||
|
||||
/** Emits when user wants to view detailed analysis */
|
||||
readonly viewAnalysis = output<void>();
|
||||
|
||||
/** Show expanded details */
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly bannerClass = computed(() => 'action-' + this.config().action);
|
||||
|
||||
readonly bannerIcon = computed(() => {
|
||||
switch (this.config().action) {
|
||||
case 'allow':
|
||||
return '[OK]';
|
||||
case 'warn':
|
||||
return '[!]';
|
||||
case 'block':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly bannerTitle = computed(() => {
|
||||
switch (this.config().action) {
|
||||
case 'allow':
|
||||
return 'Entropy Check Passed';
|
||||
case 'warn':
|
||||
return 'Entropy Warning';
|
||||
case 'block':
|
||||
return 'Entropy Block';
|
||||
default:
|
||||
return 'Entropy Status Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
readonly bannerMessage = computed(() => {
|
||||
const cfg = this.config();
|
||||
switch (cfg.action) {
|
||||
case 'allow':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' is within acceptable limits.';
|
||||
case 'warn':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds warning threshold (' + cfg.warnThreshold + '). Review recommended.';
|
||||
case 'block':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds block threshold (' + cfg.blockThreshold + '). Publication blocked.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly scorePercentage = computed(() =>
|
||||
(this.config().currentScore / 10) * 100
|
||||
);
|
||||
|
||||
readonly warnPercentage = computed(() =>
|
||||
(this.config().warnThreshold / 10) * 100
|
||||
);
|
||||
|
||||
readonly blockPercentage = computed(() =>
|
||||
(this.config().blockThreshold / 10) * 100
|
||||
);
|
||||
|
||||
readonly defaultMitigationSteps: EntropyMitigationStep[] = [
|
||||
{
|
||||
id: 'review-files',
|
||||
title: 'Review High-Entropy Files',
|
||||
description: 'Examine files flagged as high entropy to identify false positives or legitimate concerns.',
|
||||
impact: 'high',
|
||||
effort: 'easy',
|
||||
docsUrl: '/docs/security/entropy-analysis',
|
||||
},
|
||||
{
|
||||
id: 'exclude-known',
|
||||
title: 'Exclude Known Binary Artifacts',
|
||||
description: 'Add exclusion patterns for legitimate compressed files, fonts, or compiled assets.',
|
||||
impact: 'medium',
|
||||
effort: 'trivial',
|
||||
command: 'stella policy entropy exclude --pattern "*.woff2" --pattern "*.gz"',
|
||||
},
|
||||
{
|
||||
id: 'investigate-secrets',
|
||||
title: 'Investigate Potential Secrets',
|
||||
description: 'Check if high-entropy content contains accidentally committed secrets or credentials.',
|
||||
impact: 'high',
|
||||
effort: 'moderate',
|
||||
command: 'stella scan secrets --image $IMAGE_REF',
|
||||
docsUrl: '/docs/security/secret-detection',
|
||||
},
|
||||
{
|
||||
id: 'adjust-threshold',
|
||||
title: 'Adjust Policy Thresholds',
|
||||
description: 'If false positives are common, consider adjusting warn/block thresholds for this policy.',
|
||||
impact: 'medium',
|
||||
effort: 'easy',
|
||||
command: 'stella policy entropy set-threshold --policy $POLICY_ID --warn 7.0 --block 8.5',
|
||||
},
|
||||
];
|
||||
|
||||
readonly effectiveMitigationSteps = computed(() => {
|
||||
const custom = this.mitigationSteps();
|
||||
return custom.length > 0 ? custom : this.defaultMitigationSteps;
|
||||
});
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
onDownloadReport(): void {
|
||||
const url = this.config().reportUrl;
|
||||
if (url) {
|
||||
this.downloadReport.emit(url);
|
||||
}
|
||||
}
|
||||
|
||||
onViewAnalysis(): void {
|
||||
this.viewAnalysis.emit();
|
||||
}
|
||||
|
||||
onRunMitigation(step: EntropyMitigationStep): void {
|
||||
this.runMitigation.emit(step);
|
||||
}
|
||||
|
||||
getImpactLabel(impact: string): string {
|
||||
switch (impact) {
|
||||
case 'high':
|
||||
return 'High Impact';
|
||||
case 'medium':
|
||||
return 'Medium Impact';
|
||||
case 'low':
|
||||
return 'Low Impact';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getEffortLabel(effort: string): string {
|
||||
switch (effort) {
|
||||
case 'trivial':
|
||||
return '< 5 min';
|
||||
case 'easy':
|
||||
return '5-15 min';
|
||||
case 'moderate':
|
||||
return '15-60 min';
|
||||
case 'complex':
|
||||
return '> 1 hour';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,404 +1,404 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface ExceptionBadgeData {
|
||||
readonly exceptionId: string;
|
||||
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly name: string;
|
||||
readonly endDate: string;
|
||||
readonly justificationSummary?: string;
|
||||
readonly approvedBy?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="exception-badge"
|
||||
[class]="badgeClass()"
|
||||
[class.exception-badge--expanded]="expanded()"
|
||||
(click)="toggleExpanded()"
|
||||
(keydown.enter)="toggleExpanded()"
|
||||
(keydown.space)="toggleExpanded(); $event.preventDefault()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
>
|
||||
<!-- Collapsed View -->
|
||||
<div class="exception-badge__summary">
|
||||
<span class="exception-badge__icon">✓</span>
|
||||
<span class="exception-badge__label">Excepted</span>
|
||||
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
|
||||
{{ countdownText() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded View -->
|
||||
<div class="exception-badge__details" *ngIf="expanded()">
|
||||
<div class="exception-badge__header">
|
||||
<span class="exception-badge__name">{{ data.name }}</span>
|
||||
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__info">
|
||||
<div class="exception-badge__row">
|
||||
<span class="exception-badge__row-label">Severity:</span>
|
||||
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
|
||||
{{ data.severity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__row">
|
||||
<span class="exception-badge__row-label">Expires:</span>
|
||||
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
|
||||
{{ formatDate(data.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__row" *ngIf="data.approvedBy">
|
||||
<span class="exception-badge__row-label">Approved by:</span>
|
||||
<span>{{ data.approvedBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
|
||||
<span class="exception-badge__justification-label">Justification:</span>
|
||||
<p>{{ data.justificationSummary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="exception-badge__action"
|
||||
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="exception-badge__action exception-badge__action--secondary"
|
||||
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
|
||||
>
|
||||
Explain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.exception-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
background: #f3e8ff;
|
||||
border: 1px solid #c4b5fd;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:hover {
|
||||
background: #ede9fe;
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
.exception-badge__icon {
|
||||
color: #7c3aed;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.exception-badge__label {
|
||||
color: #6d28d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exception-badge__countdown {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exception-badge__details {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #c4b5fd;
|
||||
}
|
||||
|
||||
.exception-badge__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.exception-badge__status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--pending_review {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--draft {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background: #f1f5f9;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&--revoked {
|
||||
background: #fce7f3;
|
||||
color: #9d174d;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-badge__row-label {
|
||||
color: #64748b;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.exception-badge__severity {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
|
||||
&--critical {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__expiry {
|
||||
color: #1e293b;
|
||||
|
||||
&--soon {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__justification {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.exception-badge__justification-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.exception-badge__justification p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.exception-badge__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__action {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: white;
|
||||
color: #7c3aed;
|
||||
border: 1px solid #c4b5fd;
|
||||
|
||||
&:hover {
|
||||
background: #f3e8ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
|
||||
@Input({ required: true }) data!: ExceptionBadgeData;
|
||||
@Input() compact = false;
|
||||
|
||||
@Output() readonly viewDetails = new EventEmitter<string>();
|
||||
@Output() readonly explain = new EventEmitter<string>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private countdownInterval?: ReturnType<typeof setInterval>;
|
||||
private readonly now = signal(new Date());
|
||||
|
||||
readonly countdownText = computed(() => {
|
||||
const endDate = new Date(this.data.endDate);
|
||||
const current = this.now();
|
||||
const diffMs = endDate.getTime() - current.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Expired';
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${minutes}m`;
|
||||
});
|
||||
|
||||
readonly isExpiringSoon = computed(() => {
|
||||
const endDate = new Date(this.data.endDate);
|
||||
const current = this.now();
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
|
||||
});
|
||||
|
||||
readonly badgeClass = computed(() => {
|
||||
const classes = ['exception-badge'];
|
||||
if (this.data.status === 'expired') classes.push('exception-badge--expired');
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
pending_review: 'Pending',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
};
|
||||
return labels[this.data.status] || this.data.status;
|
||||
});
|
||||
|
||||
readonly ariaLabel = computed(() => {
|
||||
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isExpiringSoon()) {
|
||||
this.countdownInterval = setInterval(() => {
|
||||
this.now.set(new Date());
|
||||
}, 60000); // Update every minute
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
if (!this.compact) {
|
||||
this.expanded.set(!this.expanded());
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
computed,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface ExceptionBadgeData {
|
||||
readonly exceptionId: string;
|
||||
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly name: string;
|
||||
readonly endDate: string;
|
||||
readonly justificationSummary?: string;
|
||||
readonly approvedBy?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="exception-badge"
|
||||
[class]="badgeClass()"
|
||||
[class.exception-badge--expanded]="expanded()"
|
||||
(click)="toggleExpanded()"
|
||||
(keydown.enter)="toggleExpanded()"
|
||||
(keydown.space)="toggleExpanded(); $event.preventDefault()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
>
|
||||
<!-- Collapsed View -->
|
||||
<div class="exception-badge__summary">
|
||||
<span class="exception-badge__icon">✓</span>
|
||||
<span class="exception-badge__label">Excepted</span>
|
||||
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
|
||||
{{ countdownText() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded View -->
|
||||
<div class="exception-badge__details" *ngIf="expanded()">
|
||||
<div class="exception-badge__header">
|
||||
<span class="exception-badge__name">{{ data.name }}</span>
|
||||
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__info">
|
||||
<div class="exception-badge__row">
|
||||
<span class="exception-badge__row-label">Severity:</span>
|
||||
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
|
||||
{{ data.severity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__row">
|
||||
<span class="exception-badge__row-label">Expires:</span>
|
||||
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
|
||||
{{ formatDate(data.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__row" *ngIf="data.approvedBy">
|
||||
<span class="exception-badge__row-label">Approved by:</span>
|
||||
<span>{{ data.approvedBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
|
||||
<span class="exception-badge__justification-label">Justification:</span>
|
||||
<p>{{ data.justificationSummary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="exception-badge__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="exception-badge__action"
|
||||
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="exception-badge__action exception-badge__action--secondary"
|
||||
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
|
||||
>
|
||||
Explain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.exception-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
background: #f3e8ff;
|
||||
border: 1px solid #c4b5fd;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:hover {
|
||||
background: #ede9fe;
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
}
|
||||
|
||||
.exception-badge__icon {
|
||||
color: #7c3aed;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.exception-badge__label {
|
||||
color: #6d28d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exception-badge__countdown {
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exception-badge__details {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #c4b5fd;
|
||||
}
|
||||
|
||||
.exception-badge__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.exception-badge__status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--pending_review {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--draft {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--expired {
|
||||
background: #f1f5f9;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
&--revoked {
|
||||
background: #fce7f3;
|
||||
color: #9d174d;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-badge__row-label {
|
||||
color: #64748b;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.exception-badge__severity {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
|
||||
&--critical {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fefce8;
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__expiry {
|
||||
color: #1e293b;
|
||||
|
||||
&--soon {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-badge__justification {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.exception-badge__justification-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.exception-badge__justification p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.exception-badge__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.exception-badge__action {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: white;
|
||||
color: #7c3aed;
|
||||
border: 1px solid #c4b5fd;
|
||||
|
||||
&:hover {
|
||||
background: #f3e8ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
|
||||
@Input({ required: true }) data!: ExceptionBadgeData;
|
||||
@Input() compact = false;
|
||||
|
||||
@Output() readonly viewDetails = new EventEmitter<string>();
|
||||
@Output() readonly explain = new EventEmitter<string>();
|
||||
|
||||
readonly expanded = signal(false);
|
||||
private countdownInterval?: ReturnType<typeof setInterval>;
|
||||
private readonly now = signal(new Date());
|
||||
|
||||
readonly countdownText = computed(() => {
|
||||
const endDate = new Date(this.data.endDate);
|
||||
const current = this.now();
|
||||
const diffMs = endDate.getTime() - current.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Expired';
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${minutes}m`;
|
||||
});
|
||||
|
||||
readonly isExpiringSoon = computed(() => {
|
||||
const endDate = new Date(this.data.endDate);
|
||||
const current = this.now();
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000;
|
||||
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
|
||||
});
|
||||
|
||||
readonly badgeClass = computed(() => {
|
||||
const classes = ['exception-badge'];
|
||||
if (this.data.status === 'expired') classes.push('exception-badge--expired');
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Draft',
|
||||
pending_review: 'Pending',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
};
|
||||
return labels[this.data.status] || this.data.status;
|
||||
});
|
||||
|
||||
readonly ariaLabel = computed(() => {
|
||||
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isExpiringSoon()) {
|
||||
this.countdownInterval = setInterval(() => {
|
||||
this.now.set(new Date());
|
||||
}, 60000); // Update every minute
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
if (!this.compact) {
|
||||
this.expanded.set(!this.expanded());
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<div class="policy-gate-indicator" [class]="statusClass()" [class.compact]="compact()">
|
||||
<!-- Status Banner -->
|
||||
<div class="status-banner">
|
||||
<span class="status-icon">{{ statusIcon() }}</span>
|
||||
<div class="status-info">
|
||||
<span class="status-label">{{ statusLabel() }}</span>
|
||||
<span class="gate-summary">
|
||||
{{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed
|
||||
@if (warningGates().length > 0) {
|
||||
<span class="warning-count">({{ warningGates().length }} warnings)</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-actions">
|
||||
@if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) {
|
||||
<button class="btn-remediation" (click)="toggleRemediation()">
|
||||
{{ showRemediation() ? 'Hide' : 'Show' }} Fixes
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="btn-publish"
|
||||
[disabled]="!gateStatus().canPublish"
|
||||
(click)="onPublish()"
|
||||
[title]="gateStatus().blockReason || 'Publish artifact'"
|
||||
>
|
||||
@if (gateStatus().canPublish) {
|
||||
Publish
|
||||
} @else {
|
||||
Blocked
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Reason Banner -->
|
||||
@if (!gateStatus().canPublish && gateStatus().blockReason) {
|
||||
<div class="block-banner">
|
||||
<span class="block-icon">[!]</span>
|
||||
<span class="block-message">{{ gateStatus().blockReason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Gate List -->
|
||||
@if (!compact()) {
|
||||
<div class="gates-list">
|
||||
@for (gate of gateStatus().gates; track gate.gateId) {
|
||||
<div class="gate-item" [class]="'result-' + gate.result">
|
||||
<button
|
||||
class="gate-header"
|
||||
(click)="toggleGate(gate.gateId)"
|
||||
[attr.aria-expanded]="expandedGate() === gate.gateId"
|
||||
>
|
||||
<span class="gate-type-icon">{{ getGateIcon(gate.type) }}</span>
|
||||
<span class="result-icon">{{ getResultIcon(gate.result) }}</span>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate.name }}</span>
|
||||
@if (gate.required) {
|
||||
<span class="required-badge">Required</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" [class.expanded]="expandedGate() === gate.gateId">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedGate() === gate.gateId) {
|
||||
<div class="gate-details">
|
||||
<!-- Determinism Gate Details -->
|
||||
@if (gate.type === 'determinism' && getDeterminismDetails(gate)) {
|
||||
@let det = getDeterminismDetails(gate)!;
|
||||
<div class="determinism-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Merkle Root</span>
|
||||
<span class="detail-value" [class.mismatch]="!det.merkleRootConsistent">
|
||||
@if (det.merkleRootConsistent) {
|
||||
<span class="match-icon">[+]</span> Consistent
|
||||
} @else {
|
||||
<span class="mismatch-icon">[x]</span> Mismatch
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (!det.merkleRootConsistent) {
|
||||
<div class="hash-comparison">
|
||||
<div class="hash-row">
|
||||
<span class="hash-label">Expected:</span>
|
||||
<code class="hash">{{ formatHash(det.expectedMerkleRoot, 24) }}</code>
|
||||
</div>
|
||||
<div class="hash-row">
|
||||
<span class="hash-label">Computed:</span>
|
||||
<code class="hash mismatch">{{ formatHash(det.computedMerkleRoot, 24) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Fragments</span>
|
||||
<span class="detail-value">
|
||||
{{ det.matchingFragments }}/{{ det.totalFragments }} verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Composition File</span>
|
||||
<span class="detail-value" [class.missing]="!det.compositionPresent">
|
||||
{{ det.compositionPresent ? 'Present' : 'Missing' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (det.fragmentResults.length > 0) {
|
||||
<details class="fragment-details">
|
||||
<summary>Fragment Verification ({{ det.fragmentResults.length }})</summary>
|
||||
<ul class="fragment-list">
|
||||
@for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) {
|
||||
<li [class.mismatch]="!frag.match">
|
||||
<span class="frag-id">{{ frag.fragmentId }}</span>
|
||||
<span class="frag-status">{{ frag.match ? '+' : 'x' }}</span>
|
||||
</li>
|
||||
}
|
||||
@if (det.fragmentResults.length > 5) {
|
||||
<li class="more">+ {{ det.fragmentResults.length - 5 }} more</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Entropy Gate Details -->
|
||||
@if (gate.type === 'entropy' && getEntropyDetails(gate)) {
|
||||
@let ent = getEntropyDetails(gate)!;
|
||||
<div class="entropy-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Entropy Score</span>
|
||||
<span class="detail-value score" [class]="'action-' + ent.action">
|
||||
{{ ent.entropyScore | number:'1.1-1' }} / 10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="threshold-bar">
|
||||
<div class="threshold-track">
|
||||
<div class="threshold-marker warn" [style.left]="(ent.warnThreshold / 10 * 100) + '%'"></div>
|
||||
<div class="threshold-marker block" [style.left]="(ent.blockThreshold / 10 * 100) + '%'"></div>
|
||||
<div class="score-marker" [style.left]="(ent.entropyScore / 10 * 100) + '%'"></div>
|
||||
</div>
|
||||
<div class="threshold-labels">
|
||||
<span>0</span>
|
||||
<span class="warn-label">Warn ({{ ent.warnThreshold }})</span>
|
||||
<span class="block-label">Block ({{ ent.blockThreshold }})</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">High Entropy Files</span>
|
||||
<span class="detail-value">{{ ent.highEntropyFileCount }}</span>
|
||||
</div>
|
||||
|
||||
@if (ent.suspiciousPatterns.length > 0) {
|
||||
<div class="suspicious-patterns">
|
||||
<span class="detail-label">Suspicious Patterns</span>
|
||||
<ul class="pattern-list">
|
||||
@for (pattern of ent.suspiciousPatterns; track pattern) {
|
||||
<li>{{ pattern }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Evidence Links -->
|
||||
@if (gate.evidenceRefs && gate.evidenceRefs.length > 0) {
|
||||
<div class="evidence-links">
|
||||
<span class="evidence-label">Evidence:</span>
|
||||
@for (ref of gate.evidenceRefs; track ref) {
|
||||
<button class="evidence-link" (click)="onViewEvidence(ref)">
|
||||
{{ ref | slice:0:20 }}...
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Gate-specific Remediation -->
|
||||
@if (gate.result === 'failed') {
|
||||
@let hints = getHintsForGate(gate.gateId);
|
||||
@if (hints.length > 0) {
|
||||
<div class="gate-remediation">
|
||||
<h5 class="remediation-title">How to Fix</h5>
|
||||
@for (hint of hints; track hint.title) {
|
||||
<div class="hint-card">
|
||||
<div class="hint-header">
|
||||
<span class="hint-title">{{ hint.title }}</span>
|
||||
@if (hint.effort) {
|
||||
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
|
||||
}
|
||||
</div>
|
||||
<ol class="hint-steps">
|
||||
@for (step of hint.steps; track step) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (hint.cliCommand) {
|
||||
<div class="cli-command">
|
||||
<code>{{ hint.cliCommand }}</code>
|
||||
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
|
||||
</div>
|
||||
}
|
||||
@if (hint.docsUrl) {
|
||||
<a class="docs-link" [href]="hint.docsUrl" target="_blank">
|
||||
Documentation ->
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Blocking Issues Summary -->
|
||||
@if (gateStatus().blockingIssues.length > 0) {
|
||||
<div class="blocking-issues">
|
||||
<h4 class="issues-title">Blocking Issues ({{ gateStatus().blockingIssues.length }})</h4>
|
||||
<ul class="issues-list">
|
||||
@for (issue of gateStatus().blockingIssues; track issue.code) {
|
||||
<li class="issue-item" [class]="'severity-' + issue.severity">
|
||||
<span class="issue-code">{{ issue.code }}</span>
|
||||
<span class="issue-message">{{ issue.message }}</span>
|
||||
@if (issue.resource) {
|
||||
<span class="issue-resource">{{ issue.resource }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Panel -->
|
||||
@if (showRemediation() && gateStatus().remediationHints.length > 0) {
|
||||
<div class="remediation-panel">
|
||||
<h4 class="remediation-panel-title">Remediation Steps</h4>
|
||||
@for (hint of gateStatus().remediationHints; track hint.title) {
|
||||
<div class="remediation-card">
|
||||
<div class="remediation-header">
|
||||
<span class="remediation-for">{{ hint.forGate }}</span>
|
||||
<span class="remediation-title">{{ hint.title }}</span>
|
||||
@if (hint.effort) {
|
||||
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
|
||||
}
|
||||
</div>
|
||||
<ol class="remediation-steps">
|
||||
@for (step of hint.steps; track step) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (hint.cliCommand) {
|
||||
<div class="cli-command">
|
||||
<code>{{ hint.cliCommand }}</code>
|
||||
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="indicator-footer">
|
||||
<span class="eval-id">Evaluation: {{ gateStatus().evaluationId | slice:0:12 }}</span>
|
||||
<span class="eval-time">{{ gateStatus().evaluatedAt | date:'medium' }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,673 @@
|
||||
.policy-gate-indicator {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.status-passed {
|
||||
.status-banner { border-left: 4px solid var(--color-success, #059669); }
|
||||
.status-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
.status-banner { border-left: 4px solid var(--color-error, #dc2626); }
|
||||
.status-icon { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
.status-banner { border-left: 4px solid var(--color-warning, #d97706); }
|
||||
.status-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
.status-banner { border-left: 4px solid var(--color-info, #2563eb); }
|
||||
.status-icon { color: var(--color-info, #2563eb); }
|
||||
}
|
||||
|
||||
&.status-skipped {
|
||||
.status-banner { border-left: 4px solid var(--color-text-muted, #9ca3af); }
|
||||
.status-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.gates-list,
|
||||
.blocking-issues,
|
||||
.remediation-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.gate-summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.warning-count {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-remediation {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
padding: 0.375rem 1rem;
|
||||
background: var(--color-success, #059669);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-success-dark, #047857);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--color-text-muted, #9ca3af);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.block-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-bottom: 1px solid var(--color-error-border, #fecaca);
|
||||
}
|
||||
|
||||
.block-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.block-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.gates-list {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.gate-item {
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.result-passed {
|
||||
.result-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.result-failed {
|
||||
.result-icon { color: var(--color-error, #dc2626); }
|
||||
.gate-header { background: var(--color-error-bg, #fef2f2); }
|
||||
}
|
||||
|
||||
&.result-warning {
|
||||
.result-icon { color: var(--color-warning, #d97706); }
|
||||
.gate-header { background: var(--color-warning-bg, #fffbeb); }
|
||||
}
|
||||
|
||||
&.result-skipped {
|
||||
.result-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
.gate-name { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-type-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&.mismatch,
|
||||
&.missing {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.score {
|
||||
font-family: monospace;
|
||||
|
||||
&.action-allow { color: var(--color-success, #059669); }
|
||||
&.action-warn { color: var(--color-warning, #d97706); }
|
||||
&.action-block { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
}
|
||||
|
||||
.match-icon {
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
.mismatch-icon {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.hash-comparison {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.hash-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hash-label {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: monospace;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.mismatch {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-details {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.25rem 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.125rem 0;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.mismatch {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.more {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.frag-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.frag-status {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Entropy Details
|
||||
.threshold-bar {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.threshold-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: linear-gradient(to right,
|
||||
var(--color-success, #059669) 0%,
|
||||
var(--color-warning, #d97706) 50%,
|
||||
var(--color-error, #dc2626) 100%
|
||||
);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.threshold-marker {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: var(--color-text, #374151);
|
||||
|
||||
&.warn::after,
|
||||
&.block::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.warn::after {
|
||||
background: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.block::after {
|
||||
background: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.score-marker {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border: 2px solid var(--color-text, #374151);
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.threshold-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.warn-label {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.block-label {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.suspicious-patterns {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-list {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0 1rem;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evidence-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-remediation {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.remediation-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.hint-card,
|
||||
.remediation-card {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-header,
|
||||
.remediation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-title,
|
||||
.remediation-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.remediation-for {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.effort-badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
border-radius: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hint-steps,
|
||||
.remediation-steps {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
|
||||
li {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-issues {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-top: 1px solid var(--color-error-border, #fecaca);
|
||||
}
|
||||
|
||||
.issues-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error, #dc2626);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.issues-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid var(--color-error-border, #fecaca);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-critical .issue-code {
|
||||
background: var(--color-critical, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.severity-high .issue-code {
|
||||
background: var(--color-error, #ea580c);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
flex: 1;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.issue-resource {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.remediation-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-top: 1px solid var(--color-info-border, #bae6fd);
|
||||
}
|
||||
|
||||
.remediation-panel-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-info-dark, #0369a1);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.indicator-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.eval-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateStatus,
|
||||
PolicyGate,
|
||||
PolicyRemediationHint,
|
||||
DeterminismGateDetails,
|
||||
EntropyGateDetails,
|
||||
} from '../../core/api/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './policy-gate-indicator.component.html',
|
||||
styleUrls: ['./policy-gate-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
/** Policy gate status data */
|
||||
readonly gateStatus = input.required<PolicyGateStatus>();
|
||||
|
||||
/** Show compact view */
|
||||
readonly compact = input(false);
|
||||
|
||||
/** Emits when user clicks publish (if allowed) */
|
||||
readonly publish = output<string>();
|
||||
|
||||
/** Emits when user wants to view evidence */
|
||||
readonly viewEvidence = output<string>();
|
||||
|
||||
/** Emits when user wants to run remediation */
|
||||
readonly runRemediation = output<PolicyRemediationHint>();
|
||||
|
||||
/** Currently expanded gate */
|
||||
readonly expandedGate = signal<string | null>(null);
|
||||
|
||||
/** Show remediation panel */
|
||||
readonly showRemediation = signal(false);
|
||||
|
||||
readonly statusClass = computed(() => 'status-' + this.gateStatus().status);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.gateStatus().status) {
|
||||
case 'passed':
|
||||
return '[OK]';
|
||||
case 'failed':
|
||||
return '[X]';
|
||||
case 'warning':
|
||||
return '[!]';
|
||||
case 'pending':
|
||||
return '[...]';
|
||||
case 'skipped':
|
||||
return '[-]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.gateStatus().status) {
|
||||
case 'passed':
|
||||
return 'All Gates Passed';
|
||||
case 'failed':
|
||||
return 'Gates Failed';
|
||||
case 'warning':
|
||||
return 'Gates Passed with Warnings';
|
||||
case 'pending':
|
||||
return 'Evaluation Pending';
|
||||
case 'skipped':
|
||||
return 'Gates Skipped';
|
||||
default:
|
||||
return 'Unknown Status';
|
||||
}
|
||||
});
|
||||
|
||||
readonly passedGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'passed')
|
||||
);
|
||||
|
||||
readonly failedGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'failed')
|
||||
);
|
||||
|
||||
readonly warningGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'warning')
|
||||
);
|
||||
|
||||
readonly determinismGate = computed(() =>
|
||||
this.gateStatus().gates.find((g) => g.type === 'determinism')
|
||||
);
|
||||
|
||||
readonly entropyGate = computed(() =>
|
||||
this.gateStatus().gates.find((g) => g.type === 'entropy')
|
||||
);
|
||||
|
||||
toggleGate(gateId: string): void {
|
||||
this.expandedGate.update((current) => (current === gateId ? null : gateId));
|
||||
}
|
||||
|
||||
toggleRemediation(): void {
|
||||
this.showRemediation.update((v) => !v);
|
||||
}
|
||||
|
||||
onPublish(): void {
|
||||
if (this.gateStatus().canPublish) {
|
||||
this.publish.emit(this.gateStatus().evaluationId);
|
||||
}
|
||||
}
|
||||
|
||||
onViewEvidence(ref: string): void {
|
||||
this.viewEvidence.emit(ref);
|
||||
}
|
||||
|
||||
onRunRemediation(hint: PolicyRemediationHint): void {
|
||||
this.runRemediation.emit(hint);
|
||||
}
|
||||
|
||||
getGateIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'determinism':
|
||||
return '#';
|
||||
case 'vulnerability':
|
||||
return '!';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'signature':
|
||||
return 'S';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getResultIcon(result: string): string {
|
||||
switch (result) {
|
||||
case 'passed':
|
||||
return '+';
|
||||
case 'failed':
|
||||
return 'x';
|
||||
case 'warning':
|
||||
return '!';
|
||||
case 'skipped':
|
||||
return '-';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getEffortLabel(effort?: string): string {
|
||||
switch (effort) {
|
||||
case 'trivial':
|
||||
return '< 5 min';
|
||||
case 'easy':
|
||||
return '5-15 min';
|
||||
case 'moderate':
|
||||
return '15-60 min';
|
||||
case 'complex':
|
||||
return '> 1 hour';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getDeterminismDetails(gate: PolicyGate): DeterminismGateDetails | null {
|
||||
return gate.details as DeterminismGateDetails | null;
|
||||
}
|
||||
|
||||
getEntropyDetails(gate: PolicyGate): EntropyGateDetails | null {
|
||||
return gate.details as EntropyGateDetails | null;
|
||||
}
|
||||
|
||||
formatHash(hash: string | undefined, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
getHintsForGate(gateId: string): PolicyRemediationHint[] {
|
||||
return this.gateStatus().remediationHints.filter((h) => h.forGate === gateId);
|
||||
}
|
||||
}
|
||||
@@ -1,193 +1,193 @@
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStats,
|
||||
} from '../core/api/exception.models';
|
||||
|
||||
/**
|
||||
* Test fixtures for Exception Center components and services.
|
||||
*/
|
||||
|
||||
export const exceptionDraft: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-001',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-draft-exception',
|
||||
displayName: 'Test Draft Exception',
|
||||
description: 'A draft exception for testing purposes',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:npm/lodash@4.17.20'],
|
||||
vulnIds: ['CVE-2021-23337'],
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted for testing environment only.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
},
|
||||
labels: { env: 'test' },
|
||||
createdBy: 'test@example.com',
|
||||
createdAt: '2025-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionPendingReview: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-002',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-pending-exception',
|
||||
displayName: 'Test Pending Review Exception',
|
||||
description: 'An exception awaiting review',
|
||||
status: 'pending_review',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-web-prod'],
|
||||
vulnIds: ['CVE-2024-1234'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'WAF rules in place to mitigate risk.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionApproved: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-003',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-approved-exception',
|
||||
displayName: 'Test Approved Exception',
|
||||
description: 'An approved exception with audit trail',
|
||||
status: 'approved',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Emergency exception for production hotfix.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-01-27T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-test-001',
|
||||
approvedBy: 'security@example.com',
|
||||
approvedAt: '2025-01-20T09:30:00Z',
|
||||
comment: 'Approved for emergency hotfix window',
|
||||
},
|
||||
],
|
||||
auditTrail: [
|
||||
{
|
||||
auditId: 'aud-001',
|
||||
action: 'created',
|
||||
actor: 'dev@example.com',
|
||||
timestamp: '2025-01-19T16:00:00Z',
|
||||
},
|
||||
{
|
||||
auditId: 'aud-002',
|
||||
action: 'submitted_for_review',
|
||||
actor: 'dev@example.com',
|
||||
timestamp: '2025-01-19T16:05:00Z',
|
||||
previousStatus: 'draft',
|
||||
newStatus: 'pending_review',
|
||||
},
|
||||
{
|
||||
auditId: 'aud-003',
|
||||
action: 'approved',
|
||||
actor: 'security@example.com',
|
||||
timestamp: '2025-01-20T09:30:00Z',
|
||||
previousStatus: 'pending_review',
|
||||
newStatus: 'approved',
|
||||
},
|
||||
],
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-19T16:00:00Z',
|
||||
updatedBy: 'security@example.com',
|
||||
updatedAt: '2025-01-20T09:30:00Z',
|
||||
};
|
||||
|
||||
export const exceptionExpired: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-004',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-expired-exception',
|
||||
displayName: 'Test Expired Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-test',
|
||||
},
|
||||
justification: {
|
||||
text: 'Legacy system exception.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-06-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'admin@example.com',
|
||||
createdAt: '2024-05-15T08:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionRejected: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-005',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-rejected-exception',
|
||||
displayName: 'Test Rejected Exception',
|
||||
description: 'Rejected due to insufficient justification',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'We need this exception.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T10:00:00Z',
|
||||
};
|
||||
|
||||
export const allTestExceptions: Exception[] = [
|
||||
exceptionDraft,
|
||||
exceptionPendingReview,
|
||||
exceptionApproved,
|
||||
exceptionExpired,
|
||||
exceptionRejected,
|
||||
];
|
||||
|
||||
export const testExceptionStats: ExceptionStats = {
|
||||
total: 5,
|
||||
byStatus: {
|
||||
draft: 1,
|
||||
pending_review: 1,
|
||||
approved: 1,
|
||||
rejected: 1,
|
||||
expired: 1,
|
||||
revoked: 0,
|
||||
},
|
||||
bySeverity: {
|
||||
critical: 2,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 1,
|
||||
},
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: 1,
|
||||
};
|
||||
import {
|
||||
Exception,
|
||||
ExceptionStats,
|
||||
} from '../core/api/exception.models';
|
||||
|
||||
/**
|
||||
* Test fixtures for Exception Center components and services.
|
||||
*/
|
||||
|
||||
export const exceptionDraft: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-001',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-draft-exception',
|
||||
displayName: 'Test Draft Exception',
|
||||
description: 'A draft exception for testing purposes',
|
||||
status: 'draft',
|
||||
severity: 'medium',
|
||||
scope: {
|
||||
type: 'component',
|
||||
componentPurls: ['pkg:npm/lodash@4.17.20'],
|
||||
vulnIds: ['CVE-2021-23337'],
|
||||
},
|
||||
justification: {
|
||||
template: 'risk-accepted',
|
||||
text: 'Risk accepted for testing environment only.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-03-31T23:59:59Z',
|
||||
},
|
||||
labels: { env: 'test' },
|
||||
createdBy: 'test@example.com',
|
||||
createdAt: '2025-01-01T10:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionPendingReview: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-002',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-pending-exception',
|
||||
displayName: 'Test Pending Review Exception',
|
||||
description: 'An exception awaiting review',
|
||||
status: 'pending_review',
|
||||
severity: 'high',
|
||||
scope: {
|
||||
type: 'asset',
|
||||
assetIds: ['asset-web-prod'],
|
||||
vulnIds: ['CVE-2024-1234'],
|
||||
},
|
||||
justification: {
|
||||
template: 'compensating-control',
|
||||
text: 'WAF rules in place to mitigate risk.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-15T00:00:00Z',
|
||||
endDate: '2025-02-15T23:59:59Z',
|
||||
},
|
||||
createdBy: 'ops@example.com',
|
||||
createdAt: '2025-01-10T14:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionApproved: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-003',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-approved-exception',
|
||||
displayName: 'Test Approved Exception',
|
||||
description: 'An approved exception with audit trail',
|
||||
status: 'approved',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'Emergency exception for production hotfix.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-20T00:00:00Z',
|
||||
endDate: '2025-01-27T23:59:59Z',
|
||||
autoRenew: false,
|
||||
},
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-test-001',
|
||||
approvedBy: 'security@example.com',
|
||||
approvedAt: '2025-01-20T09:30:00Z',
|
||||
comment: 'Approved for emergency hotfix window',
|
||||
},
|
||||
],
|
||||
auditTrail: [
|
||||
{
|
||||
auditId: 'aud-001',
|
||||
action: 'created',
|
||||
actor: 'dev@example.com',
|
||||
timestamp: '2025-01-19T16:00:00Z',
|
||||
},
|
||||
{
|
||||
auditId: 'aud-002',
|
||||
action: 'submitted_for_review',
|
||||
actor: 'dev@example.com',
|
||||
timestamp: '2025-01-19T16:05:00Z',
|
||||
previousStatus: 'draft',
|
||||
newStatus: 'pending_review',
|
||||
},
|
||||
{
|
||||
auditId: 'aud-003',
|
||||
action: 'approved',
|
||||
actor: 'security@example.com',
|
||||
timestamp: '2025-01-20T09:30:00Z',
|
||||
previousStatus: 'pending_review',
|
||||
newStatus: 'approved',
|
||||
},
|
||||
],
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2025-01-19T16:00:00Z',
|
||||
updatedBy: 'security@example.com',
|
||||
updatedAt: '2025-01-20T09:30:00Z',
|
||||
};
|
||||
|
||||
export const exceptionExpired: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-004',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-expired-exception',
|
||||
displayName: 'Test Expired Exception',
|
||||
status: 'expired',
|
||||
severity: 'low',
|
||||
scope: {
|
||||
type: 'tenant',
|
||||
tenantId: 'tenant-test',
|
||||
},
|
||||
justification: {
|
||||
text: 'Legacy system exception.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2024-06-01T00:00:00Z',
|
||||
endDate: '2024-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'admin@example.com',
|
||||
createdAt: '2024-05-15T08:00:00Z',
|
||||
};
|
||||
|
||||
export const exceptionRejected: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
exceptionId: 'exc-test-005',
|
||||
tenantId: 'tenant-test',
|
||||
name: 'test-rejected-exception',
|
||||
displayName: 'Test Rejected Exception',
|
||||
description: 'Rejected due to insufficient justification',
|
||||
status: 'rejected',
|
||||
severity: 'critical',
|
||||
scope: {
|
||||
type: 'global',
|
||||
},
|
||||
justification: {
|
||||
text: 'We need this exception.',
|
||||
},
|
||||
timebox: {
|
||||
startDate: '2025-01-01T00:00:00Z',
|
||||
endDate: '2025-12-31T23:59:59Z',
|
||||
},
|
||||
createdBy: 'dev@example.com',
|
||||
createdAt: '2024-12-20T10:00:00Z',
|
||||
};
|
||||
|
||||
export const allTestExceptions: Exception[] = [
|
||||
exceptionDraft,
|
||||
exceptionPendingReview,
|
||||
exceptionApproved,
|
||||
exceptionExpired,
|
||||
exceptionRejected,
|
||||
];
|
||||
|
||||
export const testExceptionStats: ExceptionStats = {
|
||||
total: 5,
|
||||
byStatus: {
|
||||
draft: 1,
|
||||
pending_review: 1,
|
||||
approved: 1,
|
||||
rejected: 1,
|
||||
expired: 1,
|
||||
revoked: 0,
|
||||
},
|
||||
bySeverity: {
|
||||
critical: 2,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 1,
|
||||
},
|
||||
expiringWithin7Days: 1,
|
||||
pendingApproval: 1,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"status": "OK",
|
||||
"timestamp": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
{
|
||||
"status": "OK",
|
||||
"timestamp": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"status": "OK",
|
||||
"dependencies": {
|
||||
"api": "UNKNOWN",
|
||||
"telemetry": "DISABLED"
|
||||
},
|
||||
"timestamp": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
{
|
||||
"status": "OK",
|
||||
"dependencies": {
|
||||
"api": "UNKNOWN",
|
||||
"telemetry": "DISABLED"
|
||||
},
|
||||
"timestamp": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.0.0",
|
||||
"commit": "local",
|
||||
"builtAt": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
{
|
||||
"version": "0.0.0",
|
||||
"commit": "local",
|
||||
"builtAt": "1970-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user