feat: add PolicyPackSelectorComponent with tests and integration
- Implemented PolicyPackSelectorComponent for selecting policy packs. - Added unit tests for component behavior, including API success and error handling. - Introduced monaco-workers type declarations for editor workers. - Created acceptance tests for guardrails with stubs for AT1–AT10. - Established SCA Failure Catalogue Fixtures for regression testing. - Developed plugin determinism harness with stubs for PL1–PL10. - Added scripts for evidence upload and verification processes.
This commit is contained in:
@@ -11,3 +11,14 @@
|
||||
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
|
||||
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
|
||||
| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. |
|
||||
| UI-POLICY-20-001 | DONE (2025-12-05) | Policy Studio Monaco editor with DSL highlighting, lint markers, and compliance checklist shipped. |
|
||||
| UI-POLICY-20-002 | DONE (2025-12-05) | Simulation panel with deterministic diff rendering shipped (`/policy-studio/packs/:packId/simulate`). |
|
||||
| UI-POLICY-20-003 | DONE (2025-12-05) | Approvals workflow UI delivered with submit/review actions, two-person badge, and deterministic log. |
|
||||
| UI-POLICY-20-004 | DONE (2025-12-05) | Policy run dashboards delivered with filters, exports, heatmap, and daily deltas. |
|
||||
| UI-POLICY-23-000 | DONE (2025-12-05) | Added Policy Studio nav dropdown with pack selector and persisted selection. |
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
|
||||
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
|
||||
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
|
||||
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
|
||||
30
src/Web/StellaOps.Web/package-lock.json
generated
30
src/Web/StellaOps.Web/package-lock.json
generated
@@ -16,8 +16,10 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"monaco-editor": "0.52.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11207,6 +11209,16 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
@@ -13921,6 +13933,12 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz",
|
||||
"integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
@@ -18778,13 +18796,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"dev": true,
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
|
||||
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"monaco-editor": "0.52.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,22 +11,60 @@
|
||||
</section>
|
||||
<header class="app-header">
|
||||
<div class="app-brand">StellaOps Dashboard</div>
|
||||
<nav class="app-nav">
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<nav class="app-nav">
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<a routerLink="/notify" routerLinkActive="active">
|
||||
Notify
|
||||
</a>
|
||||
<a routerLink="/risk" routerLinkActive="active">
|
||||
Risk
|
||||
</a>
|
||||
<div class="nav-group" routerLinkActive="active">
|
||||
<span>Policy Studio</span>
|
||||
<div class="nav-group__menu">
|
||||
<app-policy-pack-selector (packSelected)="onPackSelected($event)"></app-policy-pack-selector>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'editor']"
|
||||
[class.nav-disabled]="!canAuthor"
|
||||
[attr.aria-disabled]="!canAuthor"
|
||||
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
||||
>
|
||||
Editor
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'simulate']"
|
||||
[class.nav-disabled]="!canSimulate"
|
||||
[attr.aria-disabled]="!canSimulate"
|
||||
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
||||
>
|
||||
Simulate
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'approvals']"
|
||||
[class.nav-disabled]="!canReview"
|
||||
[attr.aria-disabled]="!canReview"
|
||||
[title]="canReview ? '' : 'Requires policy:review scope'"
|
||||
>
|
||||
Approvals
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'dashboard']"
|
||||
[class.nav-disabled]="!canView"
|
||||
[attr.aria-disabled]="!canView"
|
||||
[title]="canView ? '' : 'Requires policy:read scope'"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a routerLink="/welcome" routerLinkActive="active">
|
||||
Welcome
|
||||
</a>
|
||||
|
||||
@@ -11,22 +11,33 @@ import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthService, AUTH_SERVICE } from './core/auth';
|
||||
import { PolicyPackSelectorComponent } from './shared/components/policy-pack-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, PolicyPackSelectorComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {
|
||||
export class AppComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly config = inject(AppConfigService);
|
||||
|
||||
private readonly packStorageKey = 'policy-studio:selected-pack';
|
||||
|
||||
protected selectedPack = this.loadStoredPack();
|
||||
protected canView = computed(() => this.authService.canViewPolicies?.() ?? false);
|
||||
protected canAuthor = computed(() => this.authService.canAuthorPolicies?.() ?? false);
|
||||
protected canSimulate = computed(() => this.authService.canSimulatePolicies?.() ?? false);
|
||||
protected canReview = computed(() => this.authService.canReviewPolicies?.() ?? false);
|
||||
|
||||
readonly status = this.sessionStore.status;
|
||||
readonly identity = this.sessionStore.identity;
|
||||
readonly subjectHint = this.sessionStore.subjectHint;
|
||||
@@ -64,7 +75,25 @@ export class AppComponent {
|
||||
void this.auth.beginLogin(returnUrl);
|
||||
}
|
||||
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
}
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
|
||||
onPackSelected(packId: string): void {
|
||||
this.selectedPack = packId;
|
||||
try {
|
||||
localStorage.setItem(this.packStorageKey, packId);
|
||||
} catch {
|
||||
/* ignore storage errors to stay offline-safe */
|
||||
}
|
||||
}
|
||||
|
||||
private loadStoredPack(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.packStorageKey);
|
||||
return stored || 'pack-1';
|
||||
} catch {
|
||||
return 'pack-1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import {
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
} from './core/auth';
|
||||
import {
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
requirePolicyAuthorGuard,
|
||||
requirePolicySimulatorGuard,
|
||||
requirePolicyReviewerGuard,
|
||||
requirePolicyApproverGuard,
|
||||
requirePolicyViewerGuard,
|
||||
} from './core/auth';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -59,10 +64,74 @@ export const routes: Routes = [
|
||||
import('./features/orchestrator/orchestrator-quotas.component').then(
|
||||
(m) => m.OrchestratorQuotasComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/workspace/policy-workspace.component').then(
|
||||
(m) => m.PolicyWorkspaceComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/editor',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/editor/policy-editor.component').then(
|
||||
(m) => m.PolicyEditorComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/yaml',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/yaml/policy-yaml-editor.component').then(
|
||||
(m) => m.PolicyYamlEditorComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/simulate',
|
||||
canMatch: [requirePolicySimulatorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/simulation/policy-simulation.component').then(
|
||||
(m) => m.PolicySimulationComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/approvals',
|
||||
canMatch: [requirePolicyReviewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/approvals/policy-approvals.component').then(
|
||||
(m) => m.PolicyApprovalsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/rules',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/rule-builder/policy-rule-builder.component').then(
|
||||
(m) => m.PolicyRuleBuilderComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/explain/:runId',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/explain/policy-explain.component').then(
|
||||
(m) => m.PolicyExplainComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/dashboard',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/dashboard/policy-dashboard.component').then(
|
||||
(m) => m.PolicyDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
|
||||
(m) => m.TrivyDbSettingsPageComponent
|
||||
),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AUTH_SERVICE } from '../../../core/auth';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyApprovalsComponent } from './policy-approvals.component';
|
||||
|
||||
describe('PolicyApprovalsComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyApprovalsComponent>;
|
||||
let component: PolicyApprovalsComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
let auth: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', [
|
||||
'getApprovalWorkflow',
|
||||
'submitForReview',
|
||||
'addReview',
|
||||
]);
|
||||
|
||||
api.getApprovalWorkflow.and.returnValue(
|
||||
of({
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'in_review',
|
||||
submittedAt: '2025-12-05T00:00:00Z',
|
||||
submittedBy: 'user-a',
|
||||
reviews: [
|
||||
{
|
||||
reviewerId: 'user-b',
|
||||
reviewerName: 'Reviewer B',
|
||||
decision: 'approve',
|
||||
comment: 'Looks good',
|
||||
reviewedAt: '2025-12-05T01:00:00Z',
|
||||
},
|
||||
{
|
||||
reviewerId: 'user-c',
|
||||
reviewerName: 'Reviewer C',
|
||||
decision: 'request_changes',
|
||||
comment: 'Need more tests',
|
||||
reviewedAt: '2025-12-05T01:30:00Z',
|
||||
},
|
||||
],
|
||||
requiredApprovers: 2,
|
||||
currentApprovers: 1,
|
||||
}) as any
|
||||
);
|
||||
|
||||
api.submitForReview.and.returnValue(of({}) as any);
|
||||
api.addReview.and.returnValue(of({}) as any);
|
||||
|
||||
auth = {
|
||||
canApprovePolicies: () => true,
|
||||
canReviewPolicies: () => true,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyApprovalsComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{ provide: AUTH_SERVICE, useValue: auth },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({ version: '1.0.0' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyApprovalsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
});
|
||||
|
||||
it('sorts reviews newest first', () => {
|
||||
const reviews = component.sortedReviews;
|
||||
expect(reviews[0].reviewerId).toBe('user-c');
|
||||
expect(reviews[1].reviewerId).toBe('user-b');
|
||||
});
|
||||
|
||||
it('calls addReview with decision', fakeAsync(() => {
|
||||
component.reviewForm.setValue({ comment: 'Approve now' });
|
||||
component.onReview('approve');
|
||||
tick();
|
||||
expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
|
||||
decision: 'approve',
|
||||
comment: 'Approve now',
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
|
||||
import {
|
||||
type ApprovalReview,
|
||||
type ApprovalWorkflow,
|
||||
type PolicySubmissionRequest,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-approvals',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="approvals" aria-busy="{{ loading }}">
|
||||
<header class="approvals__header">
|
||||
<div>
|
||||
<p class="approvals__eyebrow">Policy Studio · Approvals</p>
|
||||
<h1>Submit, review, approve</h1>
|
||||
<p class="approvals__lede">
|
||||
Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="approvals__meta" *ngIf="workflow">
|
||||
<span class="pill" [class.pill--approved]="workflow.status === 'approved'" [class.pill--pending]="workflow.status !== 'approved'">
|
||||
{{ workflow.status | titlecase }}
|
||||
</span>
|
||||
<span class="approvals__count">Required approvers: {{ workflow.requiredApprovers }}</span>
|
||||
<span class="approvals__count">Current: {{ workflow.currentApprovers }}</span>
|
||||
<span class="approvals__badge" [class.approvals__badge--ready]="isReadyToApprove" [class.approvals__badge--missing]="!isReadyToApprove">
|
||||
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="approvals__grid" *ngIf="workflow">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Submit for review</h3>
|
||||
<p>Attach context so reviewers can reproduce.</p>
|
||||
</header>
|
||||
<form [formGroup]="submitForm" (ngSubmit)="onSubmit()" class="stack">
|
||||
<label class="field">
|
||||
<span>Message to reviewers</span>
|
||||
<textarea rows="3" formControlName="message" placeholder="What changed, evidence, risks"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Coverage results link (optional)</span>
|
||||
<input formControlName="coverageResults" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Simulation diff reference (optional)</span>
|
||||
<input formControlName="simulationDiff" placeholder="Run ID or artifact path" />
|
||||
</label>
|
||||
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">{{ submitting ? 'Submitting…' : 'Submit for review' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Review & approve</h3>
|
||||
<p>Deterministic ordering by reviewedAt.</p>
|
||||
</header>
|
||||
<form [formGroup]="reviewForm" (ngSubmit)="onReview('approve')" class="stack">
|
||||
<label class="field">
|
||||
<span>Comment</span>
|
||||
<textarea rows="2" formControlName="comment" placeholder="Decision rationale"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('approve')">Approve</button>
|
||||
<button class="btn btn--warn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('reject')">Reject</button>
|
||||
<button class="btn btn--ghost" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('request_changes')">Request changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="timeline" *ngIf="workflow">
|
||||
<header class="timeline__header">
|
||||
<h3>Approvals log</h3>
|
||||
<p>Sorted newest → oldest; stable per timestamp + reviewer id.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let review of sortedReviews">
|
||||
<div class="dot" [attr.data-decision]="review.decision"></div>
|
||||
<div class="timeline__body">
|
||||
<div class="timeline__headline">
|
||||
<strong>{{ review.decision | titlecase }}</strong>
|
||||
<span>by {{ review.reviewerName }} ({{ review.reviewerId }})</span>
|
||||
<span class="timeline__time">{{ review.reviewedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
<p class="timeline__comment">{{ review.comment }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: linear-gradient(145deg, #0f172a 0%, #0b1224 60%, #0a0f1f 100%);
|
||||
color: #e5e7eb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.approvals {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.approvals__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.approvals__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.approvals__lede {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.approvals__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill--approved {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pill--pending {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.approvals__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem;
|
||||
font-family: 'Monaco','Consolas', monospace;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
color: #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover { background: #1d4ed8; }
|
||||
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.btn--warn { background: #f97316; border-color: #f97316; color: #0b1224; }
|
||||
|
||||
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
|
||||
|
||||
.timeline {
|
||||
margin-top: 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.timeline__header h3 { margin: 0; color: #f8fafc; }
|
||||
.timeline__header p { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
|
||||
ol { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.75rem; }
|
||||
|
||||
li { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; }
|
||||
|
||||
.dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #1f2937;
|
||||
}
|
||||
|
||||
.dot[data-decision='approve'] { background: #22c55e; border-color: #22c55e; }
|
||||
.dot[data-decision='reject'] { background: #ef4444; border-color: #ef4444; }
|
||||
.dot[data-decision='request_changes'] { background: #f59e0b; border-color: #f59e0b; }
|
||||
|
||||
.timeline__headline { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.timeline__headline span { color: #94a3b8; }
|
||||
.timeline__time { font-size: 0.9rem; }
|
||||
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
|
||||
|
||||
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyApprovalsComponent {
|
||||
protected workflow?: ApprovalWorkflow;
|
||||
protected loading = false;
|
||||
protected submitting = false;
|
||||
protected reviewing = false;
|
||||
|
||||
protected readonly submitForm = this.fb.group({
|
||||
message: ['', [Validators.required, Validators.minLength(5)]],
|
||||
coverageResults: [''],
|
||||
simulationDiff: [''],
|
||||
});
|
||||
|
||||
protected readonly reviewForm = this.fb.group({
|
||||
comment: ['', [Validators.required, Validators.minLength(3)]],
|
||||
});
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
get sortedReviews(): ApprovalReview[] {
|
||||
if (!this.workflow?.reviews) return [];
|
||||
return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
|
||||
}
|
||||
|
||||
get isReadyToApprove(): boolean {
|
||||
if (!this.workflow) return false;
|
||||
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (!packId || this.submitForm.invalid) return;
|
||||
|
||||
const payload: PolicySubmissionRequest = {
|
||||
policyId: packId,
|
||||
version: version ?? 'latest',
|
||||
message: this.submitForm.value.message ?? '',
|
||||
coverageResults: this.submitForm.value.coverageResults ?? undefined,
|
||||
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
|
||||
};
|
||||
|
||||
this.submitting = true;
|
||||
this.policyApi
|
||||
.submitForReview(payload)
|
||||
.pipe(finalize(() => (this.submitting = false)))
|
||||
.subscribe({
|
||||
next: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
|
||||
if (!this.workflow || this.reviewForm.invalid) return;
|
||||
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
|
||||
if (decision !== 'approve' && !this.auth.canReviewPolicies?.()) return;
|
||||
|
||||
this.reviewing = true;
|
||||
this.policyApi
|
||||
.addReview(this.workflow.policyId, this.workflow.policyVersion, {
|
||||
decision,
|
||||
comment: this.reviewForm.value.comment ?? '',
|
||||
})
|
||||
.pipe(finalize(() => (this.reviewing = false)))
|
||||
.subscribe({
|
||||
next: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (!packId) return;
|
||||
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.getApprovalWorkflow(packId, version ?? 'latest')
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (wf) => (this.workflow = wf),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyDashboardComponent } from './policy-dashboard.component';
|
||||
|
||||
describe('PolicyDashboardComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyDashboardComponent>;
|
||||
let component: PolicyDashboardComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getRunDashboard']);
|
||||
|
||||
api.getRunDashboard.and.returnValue(
|
||||
of({
|
||||
policyId: 'pack-1',
|
||||
runs: [
|
||||
{
|
||||
runId: 'run-b',
|
||||
policyVersion: '1.0.1',
|
||||
startedAt: '2025-12-05T02:00:00Z',
|
||||
completedAt: '2025-12-05T02:10:00Z',
|
||||
status: 'completed',
|
||||
findingsCount: 10,
|
||||
changedCount: 2,
|
||||
},
|
||||
{
|
||||
runId: 'run-a',
|
||||
policyVersion: '1.0.0',
|
||||
startedAt: '2025-12-04T02:00:00Z',
|
||||
completedAt: '2025-12-04T02:10:00Z',
|
||||
status: 'completed',
|
||||
findingsCount: 12,
|
||||
changedCount: 1,
|
||||
},
|
||||
],
|
||||
ruleHeatmap: [
|
||||
{ ruleName: 'rule-x', hitCount: 30, lastHit: '2025-12-05', averageLatencyMs: 12 },
|
||||
{ ruleName: 'rule-y', hitCount: 10, lastHit: '2025-12-05', averageLatencyMs: 9 },
|
||||
],
|
||||
vexWinsByDay: [],
|
||||
suppressionsByDay: [],
|
||||
}) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyDashboardComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts runs descending by completedAt', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
expect(component.sortedRuns[0].runId).toBe('run-b');
|
||||
}));
|
||||
|
||||
it('computes bar widths relative to max hits', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
const width = component.barWidth(15);
|
||||
expect(width).toBeCloseTo(50);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import {
|
||||
PolicyRunDashboard,
|
||||
RuleHeatmapEntry,
|
||||
TimeSeriesEntry,
|
||||
} from '../models/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="dash" aria-busy="{{ loading }}">
|
||||
<header class="dash__header">
|
||||
<div>
|
||||
<p class="dash__eyebrow">Policy Studio · Runs</p>
|
||||
<h1>Run dashboards</h1>
|
||||
<p class="dash__lede">Heatmap, VEX wins, and suppressions; deterministic ordering.</p>
|
||||
</div>
|
||||
<div class="dash__meta" *ngIf="dashboard">
|
||||
<span class="pill">Runs: {{ dashboard.runs.length }}</span>
|
||||
<span class="pill">Rules: {{ dashboard.ruleHeatmap.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dash__grid" *ngIf="dashboard">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Recent runs</h3>
|
||||
<p>Sorted by completedAt desc, stable tie-breaker runId.</p>
|
||||
</header>
|
||||
<form class="filters" [formGroup]="filterForm" (ngSubmit)="onFilter()">
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input type="date" formControlName="start" />
|
||||
</label>
|
||||
<label>
|
||||
<span>End</span>
|
||||
<input type="date" formControlName="end" />
|
||||
</label>
|
||||
<button type="submit" class="btn">Apply</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="onExport('json')">Export JSON</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="onExport('csv')">Export CSV</button>
|
||||
</form>
|
||||
<ul class="run-list">
|
||||
<li *ngFor="let run of sortedRuns">
|
||||
<div>
|
||||
<strong>{{ run.policyVersion }}</strong>
|
||||
<span class="muted">· {{ run.status | titlecase }}</span>
|
||||
</div>
|
||||
<div class="muted">{{ run.completedAt | date:'medium' }}</div>
|
||||
<div class="muted">Findings: {{ run.findingsCount }} · Changed: {{ run.changedCount }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Rule heatmap (top 8)</h3>
|
||||
<p>Highest hit count first.</p>
|
||||
</header>
|
||||
<ul class="heatmap">
|
||||
<li *ngFor="let rule of topRules">
|
||||
<span class="heatmap__name">{{ rule.ruleName }}</span>
|
||||
<div class="heatmap__bar">
|
||||
<span class="heatmap__fill" [style.width.%]="barWidth(rule.hitCount)"></span>
|
||||
<span class="heatmap__value">{{ rule.hitCount }}</span>
|
||||
</div>
|
||||
<span class="heatmap__latency">avg {{ rule.averageLatencyMs }} ms</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Daily deltas</h3>
|
||||
<p>VEX wins and suppressions.</p>
|
||||
</header>
|
||||
<div class="chips">
|
||||
<span class="chip" *ngFor="let v of dailyWins">{{ v.date }} · {{ v.value }} wins</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip chip--muted" *ngFor="let s of dailySuppressions">{{ s.date }} · {{ s.value }} suppressions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.dash { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.dash__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.dash__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.dash__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.dash__meta { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.pill { border: 1px solid #334155; padding: 0.35rem 0.7rem; border-radius: 999px; }
|
||||
.dash__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }
|
||||
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 10px 30px rgba(0,0,0,0.25); }
|
||||
.card h3 { margin: 0; color: #f8fafc; }
|
||||
.card p { margin: 0.15rem 0 0; color: #94a3b8; }
|
||||
.run-list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
|
||||
.run-list li { padding: 0.5rem 0.4rem; border-bottom: 1px solid #1f2937; }
|
||||
.run-list li:last-child { border-bottom: none; }
|
||||
.muted { color: #94a3b8; font-size: 0.9rem; }
|
||||
.heatmap { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.65rem; }
|
||||
.heatmap__name { color: #e5e7eb; font-weight: 600; }
|
||||
.heatmap__bar { display: flex; align-items: center; gap: 0.4rem; background: #111827; border-radius: 999px; overflow: hidden; padding: 0.2rem; }
|
||||
.heatmap__fill { display: block; height: 10px; background: linear-gradient(90deg, #22d3ee, #2563eb); border-radius: 999px; }
|
||||
.heatmap__value { color: #cbd5e1; font-size: 0.85rem; }
|
||||
.heatmap__latency { color: #94a3b8; font-size: 0.85rem; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.chip { border: 1px solid #334155; border-radius: 999px; padding: 0.25rem 0.6rem; background: #0b162e; }
|
||||
.chip--muted { opacity: 0.8; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyDashboardComponent {
|
||||
protected dashboard?: PolicyRunDashboard;
|
||||
protected loading = false;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
protected readonly filterForm = this.fb.group({
|
||||
start: [''],
|
||||
end: [''],
|
||||
});
|
||||
|
||||
get sortedRuns() {
|
||||
if (!this.dashboard) return [];
|
||||
return [...this.dashboard.runs].sort((a, b) =>
|
||||
b.completedAt.localeCompare(a.completedAt) || b.runId.localeCompare(a.runId)
|
||||
);
|
||||
}
|
||||
|
||||
get topRules(): RuleHeatmapEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return [...this.dashboard.ruleHeatmap].sort((a, b) => b.hitCount - a.hitCount).slice(0, 8);
|
||||
}
|
||||
|
||||
get dailyWins(): TimeSeriesEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return this.sortedSeries(this.dashboard.vexWinsByDay);
|
||||
}
|
||||
|
||||
get dailySuppressions(): TimeSeriesEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return this.sortedSeries(this.dashboard.suppressionsByDay);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.getRunDashboard(packId)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (dash) => (this.dashboard = dash),
|
||||
});
|
||||
}
|
||||
|
||||
onFilter(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
this.loading = true;
|
||||
const startDate = this.filterForm.value.start || undefined;
|
||||
const endDate = this.filterForm.value.end || undefined;
|
||||
this.policyApi
|
||||
.getRunDashboard(packId, {
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (dash) => (this.dashboard = dash),
|
||||
});
|
||||
}
|
||||
|
||||
onExport(format: 'json' | 'csv'): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
|
||||
this.policyApi
|
||||
.exportResults(packId, format, {
|
||||
startDate: this.filterForm.value.start || undefined,
|
||||
endDate: this.filterForm.value.end || undefined,
|
||||
})
|
||||
.subscribe((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `policy-run-${packId}.${format}`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
barWidth(hitCount: number): number {
|
||||
const max = this.topRules[0]?.hitCount || 1;
|
||||
return Math.min(100, (hitCount / max) * 100);
|
||||
}
|
||||
|
||||
private sortedSeries(series: readonly TimeSeriesEntry[]): TimeSeriesEntry[] {
|
||||
return [...series].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
|
||||
import {
|
||||
defineStellaDslTheme,
|
||||
registerStellaDslLanguage,
|
||||
} from './stella-dsl.language';
|
||||
import { registerStellaDslCompletions } from './stella-dsl.completions';
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MonacoLoaderService {
|
||||
private monacoPromise?: Promise<MonacoNamespace>;
|
||||
|
||||
/**
|
||||
* Lazily loads Monaco editor with Stella DSL language + completions configured.
|
||||
* Result is cached so multiple consumers reuse the same Monaco namespace.
|
||||
*/
|
||||
load(): Promise<MonacoNamespace> {
|
||||
if (this.monacoPromise) {
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
this.monacoPromise = import(
|
||||
/* webpackChunkName: "monaco-editor" */
|
||||
'monaco-editor/esm/vs/editor/editor.api'
|
||||
).then((monaco) => {
|
||||
this.configureWorkers(monaco);
|
||||
registerStellaDslLanguage(monaco);
|
||||
defineStellaDslTheme(monaco);
|
||||
registerStellaDslCompletions(monaco);
|
||||
return monaco;
|
||||
});
|
||||
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Monaco web workers for language services.
|
||||
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
||||
*/
|
||||
private configureWorkers(monaco: MonacoNamespace): void {
|
||||
const workerByLabel: Record<string, () => Worker> = {
|
||||
json: () => new jsonWorker(),
|
||||
css: () => new cssWorker(),
|
||||
scss: () => new cssWorker(),
|
||||
less: () => new cssWorker(),
|
||||
html: () => new htmlWorker(),
|
||||
handlebars: () => new htmlWorker(),
|
||||
razor: () => new htmlWorker(),
|
||||
javascript: () => new tsWorker(),
|
||||
typescript: () => new tsWorker(),
|
||||
default: () => new editorWorker(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - MonacoEnvironment lives on global scope
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string): Worker {
|
||||
const factory = workerByLabel[label] ?? workerByLabel.default;
|
||||
return factory();
|
||||
},
|
||||
};
|
||||
|
||||
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
|
||||
monaco.editor.setTheme('vs-dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import { PolicyEditorComponent } from './policy-editor.component';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { MonacoLoaderService } from './monaco-loader.service';
|
||||
|
||||
describe('PolicyEditorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyEditorComponent>;
|
||||
let component: PolicyEditorComponent;
|
||||
let policyApi: jasmine.SpyObj<PolicyApiService>;
|
||||
let monacoLoader: MonacoLoaderStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
policyApi = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
|
||||
monacoLoader = new MonacoLoaderStub();
|
||||
|
||||
policyApi.getPack.and.returnValue(
|
||||
of({
|
||||
id: 'pack-1',
|
||||
name: 'Demo Policy',
|
||||
description: 'Example policy for tests',
|
||||
syntax: 'stella-dsl@1',
|
||||
content: 'package "demo" { allow = true }',
|
||||
version: '1.0.0',
|
||||
status: 'draft',
|
||||
metadata: { author: 'tester', tags: ['demo'] },
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-02T00:00:00Z',
|
||||
createdBy: 'tester',
|
||||
modifiedBy: 'tester',
|
||||
tags: ['demo', 'lint'],
|
||||
digest: 'sha256:abc',
|
||||
})
|
||||
);
|
||||
|
||||
policyApi.lint.and.returnValue(
|
||||
of({ valid: true, errors: [], warnings: [], info: [] }) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyEditorComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: policyApi },
|
||||
{ provide: MonacoLoaderService, useValue: monacoLoader },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyEditorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('loads pack content into the editor model', fakeAsync(() => {
|
||||
tick();
|
||||
expect(monacoLoader.model?.getValue()).toContain('package "demo"');
|
||||
}));
|
||||
|
||||
it('applies lint diagnostics as Monaco markers', fakeAsync(() => {
|
||||
const lintResult = {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
severity: 'error' as const,
|
||||
code: 'E100',
|
||||
message: 'Missing rule header',
|
||||
line: 2,
|
||||
column: 3,
|
||||
source: 'policy-lint',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
info: [],
|
||||
};
|
||||
|
||||
policyApi.lint.and.returnValue(of(lintResult) as any);
|
||||
|
||||
component.triggerLint();
|
||||
tick();
|
||||
|
||||
expect(monacoLoader.lastMarkers.length).toBe(1);
|
||||
expect(monacoLoader.lastMarkers[0].message).toContain('Missing rule header');
|
||||
}));
|
||||
});
|
||||
|
||||
class MonacoLoaderStub {
|
||||
model: FakeModel = new FakeModel('');
|
||||
editor: FakeEditor = new FakeEditor(this.model);
|
||||
lastMarkers: Monaco.editor.IMarkerData[] = [];
|
||||
|
||||
load = jasmine.createSpy('load').and.callFake(async () => {
|
||||
const self = this;
|
||||
return {
|
||||
editor: {
|
||||
createModel: (value: string) => {
|
||||
this.model = new FakeModel(value);
|
||||
this.editor = new FakeEditor(this.model);
|
||||
return this.model as unknown as Monaco.editor.ITextModel;
|
||||
},
|
||||
create: () => this.editor as unknown as Monaco.editor.IStandaloneCodeEditor,
|
||||
setModelMarkers: (
|
||||
_model: Monaco.editor.ITextModel,
|
||||
_owner: string,
|
||||
markers: Monaco.editor.IMarkerData[]
|
||||
) => {
|
||||
self.lastMarkers = markers;
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: {
|
||||
Error: 8,
|
||||
Warning: 4,
|
||||
Info: 2,
|
||||
},
|
||||
} as unknown as MonacoNamespace;
|
||||
});
|
||||
}
|
||||
|
||||
class FakeModel {
|
||||
private value: string;
|
||||
|
||||
constructor(initial: string) {
|
||||
this.value = initial;
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
setValue(v: string): void {
|
||||
this.value = v;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEditor {
|
||||
private listeners: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly model: FakeModel) {}
|
||||
|
||||
onDidChangeModelContent(cb: () => void): { dispose: () => void } {
|
||||
this.listeners.push(cb);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getModel(): FakeModel {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
@@ -0,0 +1,767 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { BehaviorSubject, Subscription, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import {
|
||||
type PolicyDiagnostic,
|
||||
type PolicyLintResult,
|
||||
type PolicyPack,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { MonacoLoaderService } from './monaco-loader.service';
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
|
||||
type ChecklistStatus = 'pass' | 'warn' | 'fail';
|
||||
|
||||
interface ChecklistItem {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly status: ChecklistStatus;
|
||||
readonly hint: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="policy-editor" aria-busy="{{ loadingPack }}">
|
||||
<header class="policy-editor__header">
|
||||
<div class="policy-editor__title">
|
||||
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
|
||||
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
|
||||
<p class="policy-editor__subtitle" *ngIf="pack">
|
||||
Version {{ pack.version }} · Status: {{ pack.status | titlecase }} ·
|
||||
Digest: {{ pack.digest || 'pending' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="policy-editor__meta" *ngIf="pack">
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Updated</span>
|
||||
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Authors</span>
|
||||
<span>{{ pack.metadata?.author || pack.createdBy }}</span>
|
||||
</div>
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Tags</span>
|
||||
<span>{{ pack.tags?.length || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="policy-editor__layout">
|
||||
<section class="policy-editor__main" aria-label="Policy editor">
|
||||
<div class="editor-shell" [class.editor-shell--loading]="loadingEditor">
|
||||
<div #editorHost class="editor-shell__surface" aria-label="Stella DSL editor"></div>
|
||||
<div class="editor-shell__overlay" *ngIf="loadingEditor">
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="policy-editor__toolbar">
|
||||
<div class="toolbar__left">
|
||||
<button type="button" class="btn" (click)="triggerLint()" [disabled]="linting">
|
||||
{{ linting ? 'Linting…' : 'Lint now' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="resetToPack()" [disabled]="linting || loadingPack">
|
||||
Reset to last saved
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar__right">
|
||||
<span class="status-pill" [class.status-pill--success]="diagnostics.length === 0" [class.status-pill--warn]="diagnostics.length > 0">
|
||||
{{ diagnostics.length === 0 ? 'No diagnostics' : diagnostics.length + ' diagnostic(s)' }}
|
||||
</span>
|
||||
<span class="toolbar__timestamp" *ngIf="lastLintAt">Last lint: {{ lastLintAt }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="diagnostics" *ngIf="diagnostics.length">
|
||||
<header class="diagnostics__header">
|
||||
<h3>Inline diagnostics</h3>
|
||||
<p>Errors and warnings are sorted deterministically by line and column.</p>
|
||||
</header>
|
||||
<ul class="diagnostics__list">
|
||||
<li *ngFor="let diag of diagnostics" class="diagnostics__item">
|
||||
<span class="diagnostics__severity" [attr.data-severity]="diag.severity">
|
||||
{{ diag.severity | titlecase }}
|
||||
</span>
|
||||
<span class="diagnostics__message">{{ diag.message }}</span>
|
||||
<span class="diagnostics__location">Line {{ diag.line }} · Col {{ diag.column }}</span>
|
||||
<span class="diagnostics__code">{{ diag.code }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="policy-editor__sidebar" aria-label="Compliance checklist">
|
||||
<div class="sidebar-card">
|
||||
<header class="sidebar-card__header">
|
||||
<h3>Compliance checklist</h3>
|
||||
<p>Must stay green before submit/review.</p>
|
||||
</header>
|
||||
<ul class="checklist">
|
||||
<li *ngFor="let item of checklist" class="checklist__item">
|
||||
<span class="checklist__status" [attr.data-status]="item.status"></span>
|
||||
<div class="checklist__body">
|
||||
<span class="checklist__label">{{ item.label }}</span>
|
||||
<span class="checklist__hint">{{ item.hint }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-card" *ngIf="pack">
|
||||
<header class="sidebar-card__header">
|
||||
<h3>Metadata</h3>
|
||||
<p>Context used by review & simulation.</p>
|
||||
</header>
|
||||
<dl class="meta">
|
||||
<div class="meta__row">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ pack.description || 'Not provided' }}</dd>
|
||||
</div>
|
||||
<div class="meta__row">
|
||||
<dt>Tags</dt>
|
||||
<dd>{{ pack.tags?.length ? pack.tags.join(', ') : 'None' }}</dd>
|
||||
</div>
|
||||
<div class="meta__row">
|
||||
<dt>Reviewers</dt>
|
||||
<dd>{{ pack.metadata?.reviewers?.length ? pack.metadata?.reviewers?.join(', ') : 'Unassigned' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.policy-editor {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-editor__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.policy-editor__title h1 {
|
||||
margin: 0.1rem 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.policy-editor__eyebrow {
|
||||
margin: 0;
|
||||
color: #93c5fd;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-editor__subtitle {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.policy-editor__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.policy-editor__meta-item {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.policy-editor__meta-label {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.policy-editor__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) 340px;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.policy-editor__main {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
position: relative;
|
||||
min-height: 540px;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #0b1021;
|
||||
}
|
||||
|
||||
.editor-shell__surface {
|
||||
height: 540px;
|
||||
}
|
||||
|
||||
.editor-shell__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(11, 17, 33, 0.9));
|
||||
}
|
||||
|
||||
.editor-shell--loading .editor-shell__surface {
|
||||
filter: blur(1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #1f2937, #111827, #1f2937);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.2s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton--bar {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-editor__toolbar {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.toolbar__left,
|
||||
.toolbar__right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #1e40af;
|
||||
border-color: #1e40af;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #374151;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-pill--warn {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.toolbar__timestamp {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
margin-top: 1.25rem;
|
||||
background: #0b1224;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics__header h3 {
|
||||
margin: 0;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.diagnostics__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.diagnostics__list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diagnostics__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.diagnostics__severity {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='error'] {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='warning'] {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='info'] {
|
||||
border-color: #38bdf8;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.diagnostics__message {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.diagnostics__location,
|
||||
.diagnostics__code {
|
||||
color: #9ca3af;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.policy-editor__sidebar {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-card__header h3 {
|
||||
margin: 0;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.sidebar-card__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.checklist__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: #0b1224;
|
||||
}
|
||||
|
||||
.checklist__status {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1f2937;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='pass'] {
|
||||
border-color: #22c55e;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='warn'] {
|
||||
border-color: #f59e0b;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='fail'] {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.checklist__label {
|
||||
display: block;
|
||||
color: #e5e7eb;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.checklist__hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.meta__row {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.meta__row dt {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta__row dd {
|
||||
margin: 0.05rem 0 0;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.policy-editor__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@ViewChild('editorHost', { static: false })
|
||||
private editorHost?: ElementRef<HTMLDivElement>;
|
||||
|
||||
protected pack?: PolicyPack;
|
||||
protected diagnostics: PolicyDiagnostic[] = [];
|
||||
protected checklist: ChecklistItem[] = [];
|
||||
protected linting = false;
|
||||
protected loadingPack = true;
|
||||
protected loadingEditor = true;
|
||||
protected lastLintAt?: string;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly monacoLoader = inject(MonacoLoaderService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
private monaco?: MonacoNamespace;
|
||||
private model?: Monaco.editor.ITextModel;
|
||||
private editor?: Monaco.editor.IStandaloneCodeEditor;
|
||||
private readonly content$ = new BehaviorSubject<string>('');
|
||||
private readonly subscriptions = new Subscription();
|
||||
|
||||
ngOnInit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
|
||||
if (!packId) {
|
||||
this.loadingPack = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadPack(packId, version ?? undefined);
|
||||
|
||||
this.subscriptions.add(
|
||||
this.content$
|
||||
.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe((content) => this.runLint(content))
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initialiseEditor();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.unsubscribe();
|
||||
if (this.model && this.monaco) {
|
||||
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', []);
|
||||
this.model.dispose();
|
||||
}
|
||||
this.editor?.dispose();
|
||||
}
|
||||
|
||||
triggerLint(): void {
|
||||
this.runLint(this.content$.value);
|
||||
}
|
||||
|
||||
resetToPack(): void {
|
||||
if (!this.pack || !this.model) return;
|
||||
this.model.setValue(this.pack.content ?? '');
|
||||
}
|
||||
|
||||
private loadPack(packId: string, version?: string): void {
|
||||
this.loadingPack = true;
|
||||
this.policyApi.getPack(packId, version).subscribe({
|
||||
next: (pack) => {
|
||||
this.pack = pack;
|
||||
this.loadingPack = false;
|
||||
this.content$.next(pack.content ?? '');
|
||||
this.updateChecklist();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingPack = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private initialiseEditor(): void {
|
||||
const host = this.editorHost?.nativeElement;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.monacoLoader
|
||||
.load()
|
||||
.then((monaco) => {
|
||||
this.monaco = monaco;
|
||||
this.model = monaco.editor.createModel(
|
||||
this.content$.value,
|
||||
'stella-dsl'
|
||||
);
|
||||
|
||||
this.editor = monaco.editor.create(host, {
|
||||
model: this.model,
|
||||
language: 'stella-dsl',
|
||||
theme: 'stella-dsl-dark',
|
||||
fontSize: 14,
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: 'boundary',
|
||||
ariaLabel: 'Policy DSL editor',
|
||||
});
|
||||
|
||||
this.subscriptions.add(
|
||||
this.editor.onDidChangeModelContent(() => {
|
||||
const value = this.model?.getValue() ?? '';
|
||||
this.content$.next(value);
|
||||
})
|
||||
);
|
||||
|
||||
this.loadingEditor = false;
|
||||
this.cdr.markForCheck();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loadingEditor = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private runLint(content: string): void {
|
||||
if (!content || !this.model || !this.monaco) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.linting = true;
|
||||
this.policyApi.lint(content).subscribe({
|
||||
next: (lint) => {
|
||||
this.applyDiagnostics(lint);
|
||||
this.lastLintAt = new Date().toISOString();
|
||||
this.updateChecklist(lint);
|
||||
},
|
||||
error: () => {
|
||||
this.diagnostics = [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'lint/failed',
|
||||
message: 'Lint request failed. Please retry.',
|
||||
line: 1,
|
||||
column: 1,
|
||||
source: 'policy-lint',
|
||||
},
|
||||
];
|
||||
this.applyMarkers(this.diagnostics);
|
||||
},
|
||||
complete: () => {
|
||||
this.linting = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private applyDiagnostics(lint: PolicyLintResult): void {
|
||||
const sorted = [...lint.errors, ...lint.warnings, ...lint.info].sort((a, b) => {
|
||||
if (a.line === b.line) return a.column - b.column;
|
||||
return a.line - b.line;
|
||||
});
|
||||
|
||||
this.diagnostics = sorted;
|
||||
this.applyMarkers(sorted);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private applyMarkers(diagnostics: PolicyDiagnostic[]): void {
|
||||
if (!this.monaco || !this.model) return;
|
||||
|
||||
const markers: Monaco.editor.IMarkerData[] = diagnostics.map((diag) => ({
|
||||
startLineNumber: diag.line,
|
||||
startColumn: diag.column,
|
||||
endLineNumber: diag.endLine ?? diag.line,
|
||||
endColumn: diag.endColumn ?? diag.column + 1,
|
||||
message: diag.message,
|
||||
code: diag.code,
|
||||
severity: this.mapSeverity(diag.severity),
|
||||
source: diag.source,
|
||||
}));
|
||||
|
||||
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', markers);
|
||||
}
|
||||
|
||||
private mapSeverity(severity: PolicyDiagnostic['severity']): Monaco.MarkerSeverity {
|
||||
if (!this.monaco) return 0 as unknown as Monaco.MarkerSeverity;
|
||||
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return this.monaco.MarkerSeverity.Error;
|
||||
case 'warning':
|
||||
return this.monaco.MarkerSeverity.Warning;
|
||||
default:
|
||||
return this.monaco.MarkerSeverity.Info;
|
||||
}
|
||||
}
|
||||
|
||||
private updateChecklist(lint?: PolicyLintResult): void {
|
||||
const lintErrors = lint?.errors?.length ?? 0;
|
||||
const lintWarnings = lint?.warnings?.length ?? 0;
|
||||
const descriptionOk = !!this.pack?.description?.trim();
|
||||
const tagsCount = this.pack?.tags?.length ?? 0;
|
||||
const reviewers = this.pack?.metadata?.reviewers?.length ?? 0;
|
||||
|
||||
const items: ChecklistItem[] = [
|
||||
{
|
||||
id: 'lint-clean',
|
||||
label: 'Lint clean (blocking errors)',
|
||||
status: lintErrors === 0 ? 'pass' : 'fail',
|
||||
hint: lintErrors === 0 ? 'No blocking errors detected' : `${lintErrors} error(s) to fix`,
|
||||
},
|
||||
{
|
||||
id: 'lint-warn',
|
||||
label: 'Warnings reviewed',
|
||||
status: lintWarnings === 0 ? 'pass' : 'warn',
|
||||
hint: lintWarnings === 0 ? 'No warnings' : `${lintWarnings} warning(s) to review`,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Description present',
|
||||
status: descriptionOk ? 'pass' : 'fail',
|
||||
hint: descriptionOk ? 'Ready for reviewers' : 'Add a concise description',
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
label: 'At least two tags',
|
||||
status: tagsCount >= 2 ? 'pass' : tagsCount === 1 ? 'warn' : 'fail',
|
||||
hint: tagsCount >= 2 ? 'Tag coverage OK' : 'Add two or more tags for routing',
|
||||
},
|
||||
{
|
||||
id: 'reviewers',
|
||||
label: 'Two-person review assigned',
|
||||
status: reviewers >= 2 ? 'pass' : reviewers === 1 ? 'warn' : 'fail',
|
||||
hint: reviewers >= 2 ? 'Dual approval ready' : 'Add at least two reviewers',
|
||||
},
|
||||
];
|
||||
|
||||
this.checklist = items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyExplainComponent } from './policy-explain.component';
|
||||
|
||||
describe('PolicyExplainComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyExplainComponent>;
|
||||
let component: PolicyExplainComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getSimulationResult']);
|
||||
|
||||
api.getSimulationResult.and.returnValue(
|
||||
of({
|
||||
runId: 'run-1',
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'completed',
|
||||
summary: { totalFindings: 1, byStatus: {}, bySeverity: {}, ruleHits: [], vexWins: 0, suppressions: 0 },
|
||||
findings: [
|
||||
{
|
||||
componentPurl: 'pkg:npm/a@1',
|
||||
advisoryId: 'ADV-1',
|
||||
status: 'new',
|
||||
severity: { band: 'high' },
|
||||
matchedRules: [],
|
||||
annotations: {},
|
||||
},
|
||||
],
|
||||
explainTrace: [
|
||||
{ step: 1, ruleName: 'rule-a', priority: 10, matched: true, inputs: {}, outputs: {} },
|
||||
],
|
||||
executedAt: '',
|
||||
durationMs: 0,
|
||||
}) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyExplainComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ runId: 'run-1' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyExplainComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders explain trace from API', () => {
|
||||
expect(component['result']?.explainTrace?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { SimulationResult } from '../models/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-explain',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="expl" aria-busy="{{ loading }}">
|
||||
<header class="expl__header" *ngIf="result">
|
||||
<div>
|
||||
<p class="expl__eyebrow">Policy Studio · Explain</p>
|
||||
<h1>Run {{ result.runId }}</h1>
|
||||
<p class="expl__lede">Policy {{ result.policyId }} · Version {{ result.policyVersion }}</p>
|
||||
</div>
|
||||
<div class="expl__meta">
|
||||
<button type="button" (click)="exportJson()">Export JSON</button>
|
||||
<button type="button" disabled title="PDF export pending backend">Export PDF</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div *ngIf="result" class="expl__grid">
|
||||
<section class="card">
|
||||
<header>
|
||||
<h3>Explain trace</h3>
|
||||
<p>Deterministic order by step.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let e of result.explainTrace">
|
||||
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
|
||||
<span>Matched: {{ e.matched }}</span>
|
||||
<span>Priority: {{ e.priority }}</span>
|
||||
<div class="expl__json">
|
||||
<pre><code>{{ formatJson(e.outputs) }}</code></pre>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header>
|
||||
<h3>Findings snapshot</h3>
|
||||
<p>{{ result.findings.length }} findings sorted deterministically.</p>
|
||||
</header>
|
||||
<ul>
|
||||
<li *ngFor="let f of sortedFindings">
|
||||
{{ f.componentPurl }} · {{ f.advisoryId }} · {{ f.status }} · {{ f.severity.band | titlecase }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.expl { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.expl__header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.expl__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.expl__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.expl__meta { display: flex; gap: 0.5rem; }
|
||||
.expl__grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
ol { margin: 0.5rem 0 0; padding-left: 1.25rem; }
|
||||
li { margin-bottom: 0.6rem; }
|
||||
.expl__json pre { margin: 0.35rem 0 0; background: #0b1224; border: 1px solid #1f2937; border-radius: 8px; padding: 0.6rem; max-height: 240px; overflow: auto; }
|
||||
@media (max-width: 1024px) { .expl__grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyExplainComponent {
|
||||
protected loading = true;
|
||||
protected result?: SimulationResult;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(PolicyApiService);
|
||||
|
||||
constructor() {
|
||||
const runId = this.route.snapshot.paramMap.get('runId');
|
||||
if (runId) {
|
||||
this.api.getSimulationResult(runId).subscribe((res) => {
|
||||
this.result = res;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected get sortedFindings() {
|
||||
if (!this.result) return [];
|
||||
return [...this.result.findings].sort((a, b) =>
|
||||
a.status.localeCompare(b.status) || a.componentPurl.localeCompare(b.componentPurl)
|
||||
);
|
||||
}
|
||||
|
||||
protected formatJson(obj: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(obj, Object.keys(obj as Record<string, unknown>).sort(), 2);
|
||||
} catch {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
protected exportJson(): void {
|
||||
if (!this.result) return;
|
||||
const blob = new Blob([JSON.stringify(this.result, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `policy-explain-${this.result.runId}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
|
||||
// Editor (Monaco language definition)
|
||||
export * from './editor';
|
||||
export * from './editor/monaco-loader.service';
|
||||
export * from './editor/policy-editor.component';
|
||||
export * from './simulation/policy-simulation.component';
|
||||
export * from './approvals/policy-approvals.component';
|
||||
export * from './dashboard/policy-dashboard.component';
|
||||
export * from './workspace/policy-workspace.component';
|
||||
export * from './yaml/policy-yaml-editor.component';
|
||||
export * from './rule-builder/policy-rule-builder.component';
|
||||
|
||||
// Models
|
||||
export * from './models';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
|
||||
import { PolicyRuleBuilderComponent } from './policy-rule-builder.component';
|
||||
|
||||
describe('PolicyRuleBuilderComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyRuleBuilderComponent>;
|
||||
let component: PolicyRuleBuilderComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyRuleBuilderComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-xyz' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyRuleBuilderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('sorts exceptions deterministically in preview JSON', () => {
|
||||
component.form.patchValue({ exceptions: 'b, a' });
|
||||
const preview = component.previewJson();
|
||||
expect(preview).toContain('"exceptions": [\n "a",\n "b"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-rule-builder',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="rb" aria-busy="false">
|
||||
<header class="rb__header">
|
||||
<div>
|
||||
<p class="rb__eyebrow">Policy Studio · Rule Builder</p>
|
||||
<h1>Guided rule construction</h1>
|
||||
<p class="rb__lede">Deterministic preview JSON updates as you edit.</p>
|
||||
</div>
|
||||
<div class="rb__meta">
|
||||
<span>Pack: {{ packId }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rb__grid">
|
||||
<form [formGroup]="form" class="rb__form">
|
||||
<label>
|
||||
<span>Source preference</span>
|
||||
<select formControlName="source">
|
||||
<option value="nvd">NVD</option>
|
||||
<option value="vendor">Vendor first</option>
|
||||
<option value="kev">Known Exploited (KEV)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Severity floor (min)</span>
|
||||
<input type="number" min="0" max="10" step="0.1" formControlName="severityMin" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>VEX precedence</span>
|
||||
<select formControlName="vexPrecedence">
|
||||
<option value="evidence">Evidence-first</option>
|
||||
<option value="vex">VEX-first</option>
|
||||
<option value="blend">Blended</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Exceptions (comma-separated PURLs)</span>
|
||||
<input formControlName="exceptions" placeholder="pkg:npm/lodash@4.17.21, pkg:pypi/django@4.2.7" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Quiet provenance (mute noisy sources)</span>
|
||||
<select formControlName="quiet">
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low-priority feeds</option>
|
||||
<option value="all">Mute all noisy feeds</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<aside class="rb__preview" aria-label="Rule preview">
|
||||
<header>
|
||||
<h3>Preview JSON</h3>
|
||||
<p>Sorted keys for deterministic diffs.</p>
|
||||
</header>
|
||||
<pre><code>{{ previewJson() }}</code></pre>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.rb { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.rb__header { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.rb__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.rb__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.rb__grid { display: grid; grid-template-columns: minmax(0, 1.1fr) 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.rb__form { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.65rem; }
|
||||
label { display: grid; gap: 0.25rem; color: #cbd5e1; }
|
||||
input, select { background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 8px; padding: 0.5rem; }
|
||||
.rb__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
|
||||
@media (max-width: 1024px) { .rb__grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyRuleBuilderComponent {
|
||||
protected packId?: string;
|
||||
protected readonly form = this.fb.nonNullable.group({
|
||||
source: 'nvd',
|
||||
severityMin: 4,
|
||||
vexPrecedence: 'vex',
|
||||
exceptions: '',
|
||||
quiet: 'none',
|
||||
});
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
constructor() {
|
||||
this.packId = this.route.snapshot.paramMap.get('packId') || undefined;
|
||||
}
|
||||
|
||||
protected previewJson = computed(() => {
|
||||
const value = this.form.getRawValue();
|
||||
const exceptions = value.exceptions
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
const json = {
|
||||
sourcePreference: value.source,
|
||||
severityFloor: Number(value.severityMin),
|
||||
vexPrecedence: value.vexPrecedence,
|
||||
exceptions,
|
||||
quiet: value.quiet,
|
||||
};
|
||||
|
||||
return JSON.stringify(json, Object.keys(json).sort(), 2);
|
||||
});
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export { PolicyApiService } from './policy-api.service';
|
||||
export { PolicyPackStore } from './policy-pack.store';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, catchError, finalize, of } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyApiService } from './policy-api.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PolicyPackStore {
|
||||
private readonly api = inject(PolicyApiService);
|
||||
private readonly packs$ = new BehaviorSubject<PolicyPackSummary[] | null>(null);
|
||||
private loading = false;
|
||||
|
||||
getPacks(): Observable<PolicyPackSummary[]> {
|
||||
if (!this.packs$.value && !this.loading) {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
return this.packs$.pipe(filter((p): p is PolicyPackSummary[] => Array.isArray(p)));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
private fetch(): void {
|
||||
this.loading = true;
|
||||
this.api
|
||||
.listPacks({ limit: 50 })
|
||||
.pipe(
|
||||
catchError(() => of(this.fallbackPacks())),
|
||||
finalize(() => (this.loading = false))
|
||||
)
|
||||
.subscribe((packs) => this.packs$.next(packs));
|
||||
}
|
||||
|
||||
private fallbackPacks(): PolicyPackSummary[] {
|
||||
return [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Core Policy Pack',
|
||||
description: '',
|
||||
version: 'latest',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicySimulationComponent } from './policy-simulation.component';
|
||||
|
||||
describe('PolicySimulationComponent', () => {
|
||||
let fixture: ComponentFixture<PolicySimulationComponent>;
|
||||
let component: PolicySimulationComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['simulate']);
|
||||
|
||||
api.simulate.and.returnValue(
|
||||
of({
|
||||
runId: 'run-1',
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'completed',
|
||||
summary: {
|
||||
totalFindings: 2,
|
||||
byStatus: {},
|
||||
bySeverity: {},
|
||||
ruleHits: [],
|
||||
vexWins: 1,
|
||||
suppressions: 0,
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
componentPurl: 'pkg:npm/a@1',
|
||||
advisoryId: 'ADV-1',
|
||||
status: 'new',
|
||||
severity: { band: 'high', score: 8 },
|
||||
matchedRules: ['r1'],
|
||||
annotations: {},
|
||||
},
|
||||
{
|
||||
componentPurl: 'pkg:npm/b@1',
|
||||
advisoryId: 'ADV-0',
|
||||
status: 'new',
|
||||
severity: { band: 'critical', score: 9.5 },
|
||||
matchedRules: ['r2'],
|
||||
annotations: {},
|
||||
},
|
||||
],
|
||||
diff: {
|
||||
added: [
|
||||
{ componentPurl: 'pkg:npm/a@1', advisoryId: 'ADV-1', reason: '' },
|
||||
{ componentPurl: 'pkg:npm/b@1', advisoryId: 'ADV-0', reason: '' },
|
||||
],
|
||||
removed: [],
|
||||
changed: [],
|
||||
statusDeltas: {},
|
||||
severityDeltas: {},
|
||||
},
|
||||
explainTrace: [],
|
||||
executedAt: '2025-12-05T00:00:00Z',
|
||||
durationMs: 1200,
|
||||
})
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicySimulationComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicySimulationComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts findings deterministically (status, severity, component)', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
component.onRun();
|
||||
tick();
|
||||
|
||||
const findings = component.sortedFindings;
|
||||
expect(findings[0].advisoryId).toBe('ADV-0');
|
||||
expect(findings[1].advisoryId).toBe('ADV-1');
|
||||
}));
|
||||
|
||||
it('sorts diff entries by component then advisory', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
component.onRun();
|
||||
tick();
|
||||
|
||||
const diff = component['result']?.diff;
|
||||
expect(diff?.added.map((d) => d.advisoryId)).toEqual(['ADV-0', 'ADV-1']);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
type SimulationDiff,
|
||||
type SimulationResult,
|
||||
type SimulationRequest,
|
||||
ExplainEntry,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-simulation',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="sim" aria-busy="{{ loading }}">
|
||||
<header class="sim__header">
|
||||
<div>
|
||||
<p class="sim__eyebrow">Policy Studio · Simulation</p>
|
||||
<h1>What-if analysis</h1>
|
||||
<p class="sim__lede">
|
||||
Run deterministic diffs against the active policy to preview impact before promote.
|
||||
</p>
|
||||
</div>
|
||||
<div class="sim__meta">
|
||||
<span class="pill" [class.pill--ok]="result?.status === 'completed'" [class.pill--warn]="result?.status === 'failed'">
|
||||
{{ result ? result.status : 'Idle' | titlecase }}
|
||||
</span>
|
||||
<span class="sim__timestamp" *ngIf="result">Completed at {{ result.executedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sim__layout">
|
||||
<form class="sim__form" [formGroup]="form" (ngSubmit)="onRun()" aria-label="Simulation input">
|
||||
<label class="field">
|
||||
<span>Components (purl, comma-separated)</span>
|
||||
<textarea formControlName="components" rows="3" placeholder="pkg:pypi/django@4.2.7, pkg:npm/lodash@4.17.21"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>SBOM</span>
|
||||
<select formControlName="sbomId">
|
||||
<option value="">-- optional --</option>
|
||||
<option *ngFor="let s of sboms" [value]="s">{{ s }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Advisories (IDs, comma-separated)</span>
|
||||
<input formControlName="advisories" placeholder="CVE-2025-0001, GHSA-1234" list="adv-suggest" />
|
||||
<datalist id="adv-suggest">
|
||||
<option *ngFor="let adv of advisoryOptions" [value]="adv"></option>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Environment label (optional)</span>
|
||||
<input formControlName="environment" placeholder="staging-us-east" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Include explain trace</span>
|
||||
<input type="checkbox" formControlName="includeExplain" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Diff against active policy</span>
|
||||
<input type="checkbox" formControlName="diffAgainstActive" />
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn" [disabled]="loading || form.invalid">
|
||||
{{ loading ? 'Running…' : 'Run simulation' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<aside class="sim__summary" *ngIf="result">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Summary</h3>
|
||||
<p>Deterministic ordering by status → severity → component.</p>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Total findings</dt>
|
||||
<dd>{{ result.summary.totalFindings }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>VEX wins</dt>
|
||||
<dd>{{ result.summary.vexWins }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Suppressions</dt>
|
||||
<dd>{{ result.summary.suppressions }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="chips">
|
||||
<span class="chip" *ngFor="let s of severityBands">{{ s.label }}: {{ s.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" *ngIf="result.diff">
|
||||
<header>
|
||||
<h3>Diff</h3>
|
||||
<p>Added/removed/changed findings vs active policy.</p>
|
||||
</header>
|
||||
<div class="diff-grid">
|
||||
<div class="diff-grid__column">
|
||||
<h4>Added ({{ result.diff.added.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.added">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="diff-grid__column">
|
||||
<h4>Removed ({{ result.diff.removed.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.removed">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="diff-grid__column">
|
||||
<h4>Changed ({{ result.diff.changed.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.changed">{{ item.componentPurl }} · {{ item.advisoryId }} ({{ item.reason }})</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" *ngIf="result.summary.ruleHits?.length">
|
||||
<header>
|
||||
<h3>Rule hit summary</h3>
|
||||
<p>Top rules by hit count.</p>
|
||||
</header>
|
||||
<ul class="rule-list">
|
||||
<li *ngFor="let hit of result.summary.ruleHits | slice:0:8">
|
||||
<span class="rule-list__name">{{ hit.ruleName }}</span>
|
||||
<span class="rule-list__count">{{ hit.hitCount }} hits</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="table" *ngIf="result">
|
||||
<header class="table__header">
|
||||
<h3>Deterministic findings table</h3>
|
||||
<p>Sorted: status → severity → component → advisory.</p>
|
||||
</header>
|
||||
<div class="table__scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Severity</th>
|
||||
<th>Component</th>
|
||||
<th>Advisory</th>
|
||||
<th>Rules</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let f of sortedFindings">
|
||||
<td>{{ f.status }}</td>
|
||||
<td>{{ f.severity.band | titlecase }} {{ f.severity.score || '' }}</td>
|
||||
<td>{{ f.componentPurl }}</td>
|
||||
<td>{{ f.advisoryId }}</td>
|
||||
<td>{{ f.matchedRules.join(', ') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="explain" *ngIf="explainTrace.length">
|
||||
<header class="table__header">
|
||||
<h3>Explain trace</h3>
|
||||
<p>Deterministic order by step.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let e of explainTrace">
|
||||
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
|
||||
<span>Matched: {{ e.matched }}</span>
|
||||
<span>Priority: {{ e.priority }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 25%),
|
||||
radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.08), transparent 22%),
|
||||
#0b1224;
|
||||
min-height: 100vh;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.sim {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.sim__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sim__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sim__lede {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.sim__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid #334155;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pill--warn {
|
||||
border-color: #f97316;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.sim__timestamp {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sim__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sim__form {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input[type='text'],
|
||||
select,
|
||||
input[type='checkbox'] {
|
||||
width: 100%;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
justify-self: start;
|
||||
background: linear-gradient(120deg, #2563eb, #22d3ee);
|
||||
border: 1px solid #2563eb;
|
||||
color: #0b1224;
|
||||
font-weight: 700;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.25);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 36px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sim__summary {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0.6rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diff-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.diff-grid h4 {
|
||||
margin: 0 0 0.2rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.diff-grid ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 1.2rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table__header h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.table__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table__scroll {
|
||||
overflow: auto;
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #0b1224;
|
||||
color: #cbd5e1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sim__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicySimulationComponent {
|
||||
protected loading = false;
|
||||
protected result?: SimulationResult;
|
||||
protected explainTrace: ExplainEntry[] = [];
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
components: [''],
|
||||
advisories: [''],
|
||||
environment: [''],
|
||||
includeExplain: [false],
|
||||
diffAgainstActive: [true],
|
||||
sbomId: [''],
|
||||
});
|
||||
|
||||
protected readonly sboms = ['sbom-dev-001', 'sbom-prod-2024-11', 'sbom-preprod-05'];
|
||||
protected readonly advisoryOptions = ['CVE-2025-0001', 'GHSA-1234', 'CVE-2024-9999'];
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
|
||||
get severityBands() {
|
||||
if (!this.result) return [];
|
||||
const order: Array<{ key: string; label: string }> = [
|
||||
{ key: 'critical', label: 'Critical' },
|
||||
{ key: 'high', label: 'High' },
|
||||
{ key: 'medium', label: 'Medium' },
|
||||
{ key: 'low', label: 'Low' },
|
||||
{ key: 'none', label: 'None' },
|
||||
];
|
||||
|
||||
return order.map(({ key, label }) => ({
|
||||
label,
|
||||
count: this.result?.summary.bySeverity?.[key] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
get sortedFindings() {
|
||||
if (!this.result) return [];
|
||||
return [...this.result.findings].sort((a, b) => {
|
||||
if (a.status === b.status) {
|
||||
if (a.severity.band === b.severity.band) {
|
||||
if (a.componentPurl === b.componentPurl) {
|
||||
return a.advisoryId.localeCompare(b.advisoryId);
|
||||
}
|
||||
return a.componentPurl.localeCompare(b.componentPurl);
|
||||
}
|
||||
const order = ['critical', 'high', 'medium', 'low', 'none'];
|
||||
return order.indexOf(a.severity.band) - order.indexOf(b.severity.band);
|
||||
}
|
||||
return a.status.localeCompare(b.status);
|
||||
});
|
||||
}
|
||||
|
||||
onRun(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
|
||||
const request: SimulationRequest = {
|
||||
policyId: packId,
|
||||
scope: {
|
||||
components: this.splitList(this.form.value.components),
|
||||
advisories: this.splitList(this.form.value.advisories),
|
||||
sbomId: this.form.value.sbomId || undefined,
|
||||
environment: this.form.value.environment || undefined,
|
||||
},
|
||||
inputs: {},
|
||||
options: {
|
||||
includeExplainTrace: this.form.value.includeExplain ?? false,
|
||||
diffAgainstActive: this.form.value.diffAgainstActive ?? true,
|
||||
},
|
||||
};
|
||||
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.simulate(request)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.result = this.sortDiff(res);
|
||||
this.explainTrace = res.explainTrace ?? [];
|
||||
this.form.markAsPristine();
|
||||
},
|
||||
error: () => {
|
||||
this.result = undefined;
|
||||
this.explainTrace = [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private sortDiff(result: SimulationResult): SimulationResult {
|
||||
const sortFinding = (items: SimulationDiff['added']) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (a.componentPurl === b.componentPurl) {
|
||||
return a.advisoryId.localeCompare(b.advisoryId);
|
||||
}
|
||||
return a.componentPurl.localeCompare(b.componentPurl);
|
||||
});
|
||||
|
||||
const diff: SimulationDiff | undefined = result.diff
|
||||
? {
|
||||
added: sortFinding(result.diff.added),
|
||||
removed: sortFinding(result.diff.removed),
|
||||
changed: sortFinding(result.diff.changed),
|
||||
statusDeltas: result.diff.statusDeltas,
|
||||
severityDeltas: result.diff.severityDeltas,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { ...result, diff };
|
||||
}
|
||||
|
||||
private splitList(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
import { PolicyWorkspaceComponent } from './policy-workspace.component';
|
||||
|
||||
describe('PolicyWorkspaceComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyWorkspaceComponent>;
|
||||
let component: PolicyWorkspaceComponent;
|
||||
let store: jasmine.SpyObj<PolicyPackStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
|
||||
store.getPacks.and.returnValue(
|
||||
of([
|
||||
{
|
||||
id: 'pack-b',
|
||||
name: 'B pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-05T00:00:00Z',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: 'pack-a',
|
||||
name: 'A pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '2025-11-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-04T00:00:00Z',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterLink, PolicyWorkspaceComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyWorkspaceComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts packs by modifiedAt desc then id', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
expect(component['packs'][0].id).toBe('pack-b');
|
||||
expect(component['packs'][1].id).toBe('pack-a');
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-workspace',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="workspace" aria-busy="{{ loading }}">
|
||||
<header class="workspace__header">
|
||||
<div>
|
||||
<p class="workspace__eyebrow">Policy Studio · Workspace</p>
|
||||
<h1>Policy packs</h1>
|
||||
<p class="workspace__lede">Deterministic list sorted by modified date desc, tie-breaker id.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace__grid">
|
||||
<article class="pack-card" *ngFor="let pack of packs">
|
||||
<header class="pack-card__head">
|
||||
<div>
|
||||
<p class="pack-card__eyebrow">{{ pack.status | titlecase }}</p>
|
||||
<h2>{{ pack.name }}</h2>
|
||||
<p class="pack-card__desc">{{ pack.description || 'No description provided.' }}</p>
|
||||
</div>
|
||||
<div class="pack-card__meta">
|
||||
<span>v{{ pack.version }}</span>
|
||||
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="pack-card__tags">
|
||||
<li *ngFor="let tag of pack.tags">{{ tag }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="pack-card__actions">
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
|
||||
[class.action-disabled]="!canAuthor"
|
||||
[attr.aria-disabled]="!canAuthor"
|
||||
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
|
||||
[class.action-disabled]="!canSimulate"
|
||||
[attr.aria-disabled]="!canSimulate"
|
||||
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
||||
>
|
||||
Simulate
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
|
||||
[class.action-disabled]="!canReview"
|
||||
[attr.aria-disabled]="!canReview"
|
||||
[title]="canReview ? '' : 'Requires policy:review scope'"
|
||||
>
|
||||
Approvals
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
|
||||
[class.action-disabled]="!canView"
|
||||
[attr.aria-disabled]="!canView"
|
||||
[title]="canView ? '' : 'Requires policy:read scope'"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl class="pack-card__detail">
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ pack.createdAt | date: 'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authors</dt>
|
||||
<dd>{{ pack.createdBy || 'unknown' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Owner</dt>
|
||||
<dd>{{ pack.modifiedBy || 'unknown' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.workspace { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.workspace__header { margin-bottom: 1rem; }
|
||||
.workspace__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.workspace__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
||||
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
||||
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
||||
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.pack-card__tags li { padding: 0.2rem 0.45rem; border: 1px solid #1f2937; border-radius: 999px; background: #0b162e; }
|
||||
.pack-card__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.pack-card__actions a { color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 0.35rem 0.6rem; text-decoration: none; }
|
||||
.pack-card__actions a:hover { border-color: #22d3ee; }
|
||||
.pack-card__detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.35rem 1rem; margin: 0; }
|
||||
dt { color: #94a3b8; font-size: 0.85rem; margin: 0; }
|
||||
dd { margin: 0; color: #e5e7eb; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyWorkspaceComponent {
|
||||
protected loading = false;
|
||||
protected packs: PolicyPackSummary[] = [];
|
||||
protected canAuthor = false;
|
||||
protected canSimulate = false;
|
||||
protected canReview = false;
|
||||
protected canView = false;
|
||||
protected scopeHint = '';
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
constructor() {
|
||||
this.loading = true;
|
||||
this.applyScopes();
|
||||
this.packStore.getPacks().subscribe((packs) => {
|
||||
this.packs = [...packs].sort((a, b) =>
|
||||
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
|
||||
);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private applyScopes(): void {
|
||||
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
|
||||
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
|
||||
this.canReview = this.auth.canReviewPolicies?.() ?? false;
|
||||
this.canView = this.auth.canViewPolicies?.() ?? false;
|
||||
const missing: string[] = [];
|
||||
if (!this.canView) missing.push('policy:read');
|
||||
if (!this.canAuthor) missing.push('policy:author');
|
||||
if (!this.canSimulate) missing.push('policy:simulate');
|
||||
if (!this.canReview) missing.push('policy:review');
|
||||
this.scopeHint = missing.length ? `Missing scopes: ${missing.join(', ')}` : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyYamlEditorComponent } from './policy-yaml-editor.component';
|
||||
|
||||
describe('PolicyYamlEditorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyYamlEditorComponent>;
|
||||
let component: PolicyYamlEditorComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
|
||||
|
||||
api.getPack.and.returnValue(
|
||||
of({
|
||||
id: 'pack-1',
|
||||
name: 'Pack One',
|
||||
description: 'Demo pack',
|
||||
syntax: 'yaml',
|
||||
content: '',
|
||||
version: '1.0.0',
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: ['demo'],
|
||||
digest: '',
|
||||
})
|
||||
);
|
||||
|
||||
api.lint.and.returnValue(of({ valid: true, errors: [], warnings: [], info: [] }) as any);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, FormsModule, PolicyYamlEditorComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyYamlEditorComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('builds canonical YAML with sorted keys', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick(500);
|
||||
expect(component.canonicalYaml).toContain('id');
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
|
||||
import { BehaviorSubject, combineLatest, of } from 'rxjs';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyPack } from '../models/policy.models';
|
||||
|
||||
interface YamlDiagnostic {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-yaml-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="yaml" aria-busy="{{ loading }}">
|
||||
<header class="yaml__header">
|
||||
<div>
|
||||
<p class="yaml__eyebrow">Policy Studio · YAML</p>
|
||||
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
|
||||
<p class="yaml__lede" *ngIf="pack">
|
||||
Version {{ pack.version }} · Status: {{ pack.status | titlecase }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="yaml__meta" *ngIf="pack">
|
||||
<span>Updated {{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
<span>Tags: {{ pack.tags.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="yaml__layout">
|
||||
<div class="yaml__editor">
|
||||
<label for="yaml-input">YAML</label>
|
||||
<textarea
|
||||
id="yaml-input"
|
||||
[(ngModel)]="yamlContent"
|
||||
(ngModelChange)="onContentChange($event)"
|
||||
rows="18"
|
||||
aria-label="Policy YAML editor"
|
||||
></textarea>
|
||||
<div class="toolbar">
|
||||
<span class="pill" [class.pill--ok]="!yamlDiagnostics.length && !lintDiagnostics.length">
|
||||
{{ yamlDiagnostics.length + lintDiagnostics.length }} issue(s)
|
||||
</span>
|
||||
<button type="button" (click)="resetToPack()" [disabled]="loading">Reset</button>
|
||||
</div>
|
||||
<ul class="diag" *ngIf="yamlDiagnostics.length || lintDiagnostics.length">
|
||||
<li *ngFor="let d of yamlDiagnostics">YAML: {{ d.message }}</li>
|
||||
<li *ngFor="let d of lintDiagnostics">Lint: {{ d.message }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside class="yaml__preview" aria-label="Canonical preview">
|
||||
<header>
|
||||
<h3>Canonical YAML</h3>
|
||||
<p>Sorted keys; stable ordering for diff-friendly review.</p>
|
||||
</header>
|
||||
<pre><code>{{ canonicalYaml }}</code></pre>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.yaml { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.yaml__header { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.yaml__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.yaml__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.yaml__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; }
|
||||
.yaml__layout { display: grid; grid-template-columns: minmax(0, 1.4fr) 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.yaml__editor { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.5rem; }
|
||||
label { color: #cbd5e1; font-weight: 600; }
|
||||
textarea { width: 100%; background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; font-family: 'Monaco','Consolas', monospace; }
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
button { background: #2563eb; border: 1px solid #2563eb; color: #e5e7eb; border-radius: 8px; padding: 0.4rem 0.8rem; cursor: pointer; }
|
||||
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.pill { border: 1px solid #334155; padding: 0.3rem 0.6rem; border-radius: 999px; }
|
||||
.pill--ok { border-color: #22c55e; color: #22c55e; }
|
||||
.diag { list-style: none; margin: 0; padding: 0; color: #f87171; }
|
||||
.yaml__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
|
||||
@media (max-width: 1024px) { .yaml__layout { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyYamlEditorComponent {
|
||||
protected pack?: PolicyPack;
|
||||
protected yamlContent = '';
|
||||
protected canonicalYaml = '';
|
||||
protected yamlDiagnostics: YamlDiagnostic[] = [];
|
||||
protected lintDiagnostics: YamlDiagnostic[] = [];
|
||||
protected loading = true;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(PolicyApiService);
|
||||
|
||||
private readonly content$ = new BehaviorSubject<string>('');
|
||||
|
||||
constructor() {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (packId) {
|
||||
this.api.getPack(packId, version ?? undefined).subscribe((p) => {
|
||||
this.pack = p;
|
||||
this.yamlContent = this.buildInitialYaml(p);
|
||||
this.loading = false;
|
||||
this.onContentChange(this.yamlContent);
|
||||
});
|
||||
}
|
||||
|
||||
combineLatest([
|
||||
this.content$.pipe(debounceTime(400), distinctUntilChanged()),
|
||||
])
|
||||
.pipe(
|
||||
map(([content]) => content),
|
||||
switchMap((content) => this.validateAndCanonicalize(content))
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
onContentChange(content: string): void {
|
||||
this.content$.next(content);
|
||||
}
|
||||
|
||||
resetToPack(): void {
|
||||
if (!this.pack) return;
|
||||
this.yamlContent = this.buildInitialYaml(this.pack);
|
||||
this.onContentChange(this.yamlContent);
|
||||
}
|
||||
|
||||
private validateAndCanonicalize(content: string) {
|
||||
try {
|
||||
const parsed = YAML.parse(content);
|
||||
this.yamlDiagnostics = [];
|
||||
this.canonicalYaml = YAML.stringify(parsed, { sortMapEntries: true });
|
||||
} catch (err: any) {
|
||||
this.yamlDiagnostics = [{ message: err.message || 'Invalid YAML' }];
|
||||
this.canonicalYaml = '';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
this.lintDiagnostics = [];
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.api.lint(content).pipe(
|
||||
map((lint) => {
|
||||
this.lintDiagnostics = [...lint.errors, ...lint.warnings, ...lint.info].map((d) => ({
|
||||
message: `${d.severity}: ${d.message} (line ${d.line})`,
|
||||
}));
|
||||
return lint;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private buildInitialYaml(pack: PolicyPack): string {
|
||||
return YAML.stringify(
|
||||
{
|
||||
policy: {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
version: pack.version,
|
||||
status: pack.status,
|
||||
description: pack.description,
|
||||
tags: pack.tags,
|
||||
metadata: pack.metadata,
|
||||
},
|
||||
},
|
||||
{ sortMapEntries: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
|
||||
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
|
||||
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
|
||||
export { PolicyPackSelectorComponent } from './policy-pack-selector.component';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { PolicyPackSelectorComponent } from './policy-pack-selector.component';
|
||||
|
||||
describe('PolicyPackSelectorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyPackSelectorComponent>;
|
||||
let component: PolicyPackSelectorComponent;
|
||||
let store: jasmine.SpyObj<PolicyPackStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
|
||||
});
|
||||
|
||||
it('emits first pack id when API succeeds', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(
|
||||
of([
|
||||
{
|
||||
id: 'pack-42',
|
||||
name: 'Test Pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyPackSelectorComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
const spy = jasmine.createSpy('packSelected');
|
||||
component.packSelected.subscribe(spy);
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('pack-42');
|
||||
}));
|
||||
|
||||
it('falls back to pack-1 on API error', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyPackSelectorComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
const spy = jasmine.createSpy('packSelected');
|
||||
component.packSelected.subscribe(spy);
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(component['packs'].length).toBe(0);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { PolicyPackSummary } from '../../features/policy-studio/models/policy.models';
|
||||
|
||||
/**
|
||||
* Policy pack selector for the nav dropdown.
|
||||
* Fetches packs from PolicyApiService with an offline-safe fallback list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-policy-pack-selector',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="pack-selector">
|
||||
<label for="pack-select">Policy pack</label>
|
||||
<select
|
||||
id="pack-select"
|
||||
(change)="onChange($event.target.value)"
|
||||
[disabled]="loading"
|
||||
[attr.aria-busy]="loading"
|
||||
>
|
||||
<option *ngFor="let pack of packs" [value]="pack.id">{{ pack.name }}</option>
|
||||
</select>
|
||||
<p class="hint" *ngIf="loading">Loading packs…</p>
|
||||
<p class="hint" *ngIf="!loading && packs.length === 0">No packs available.</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.pack-selector {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
label {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
select {
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyPackSelectorComponent implements OnInit, OnDestroy {
|
||||
@Output() packSelected = new EventEmitter<string>();
|
||||
|
||||
protected packs: PolicyPackSummary[] = [];
|
||||
protected loading = false;
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private sub?: Subscription;
|
||||
|
||||
onChange(value: string): void {
|
||||
this.packSelected.emit(value);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loading = true;
|
||||
this.sub = this.packStore.getPacks().subscribe((packs) => {
|
||||
this.packs = packs;
|
||||
this.loading = false;
|
||||
if (packs.length > 0) {
|
||||
this.packSelected.emit(packs[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
24
src/Web/StellaOps.Web/src/app/types/monaco-workers.d.ts
vendored
Normal file
24
src/Web/StellaOps.Web/src/app/types/monaco-workers.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'monaco-editor/esm/vs/editor/editor.worker?worker' {
|
||||
const EditorWorkerFactory: { new (): Worker };
|
||||
export default EditorWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/json/json.worker?worker' {
|
||||
const JsonWorkerFactory: { new (): Worker };
|
||||
export default JsonWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/css/css.worker?worker' {
|
||||
const CssWorkerFactory: { new (): Worker };
|
||||
export default CssWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/html/html.worker?worker' {
|
||||
const HtmlWorkerFactory: { new (): Worker };
|
||||
export default HtmlWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' {
|
||||
const TsWorkerFactory: { new (): Worker };
|
||||
export default TsWorkerFactory;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import './styles/tokens/motion';
|
||||
@import 'monaco-editor/min/vs/editor/editor.main.css';
|
||||
|
||||
/* Global motion helpers */
|
||||
.motion-fade-in {
|
||||
@@ -50,3 +51,54 @@
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
/* Quick nav styling for Policy Studio dropdown */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
background: #0b162e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-group__menu {
|
||||
position: absolute;
|
||||
top: 110%;
|
||||
left: 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem;
|
||||
display: none;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-group__menu,
|
||||
.nav-group:focus-within .nav-group__menu {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nav-group__menu a {
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-group__menu a:hover {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user