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:
StellaOps Bot
2025-12-05 21:24:34 +02:00
parent 347c88342c
commit 18d87c64c5
220 changed files with 7700 additions and 518 deletions

View File

@@ -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. |

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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';
}
}
}

View File

@@ -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
),

View File

@@ -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',
});
}));
});

View File

@@ -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),
});
}
}

View File

@@ -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);
}));
});

View File

@@ -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));
}
}

View File

@@ -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');
}
}

View File

@@ -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');

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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"');
});
});

View File

@@ -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);
});
}

View File

@@ -3,3 +3,4 @@
*/
export { PolicyApiService } from './policy-api.service';
export { PolicyPackStore } from './policy-pack.store';

View File

@@ -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: [],
},
];
}
}

View File

@@ -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']);
}));
});

View File

@@ -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);
}
}

View File

@@ -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');
}));
});

View File

@@ -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(', ')}` : '';
}
}

View File

@@ -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');
}));
});

View File

@@ -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 }
);
}
}

View File

@@ -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';

View File

@@ -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);
}));
});

View File

@@ -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();
}
}

View 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;
}

View File

@@ -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;
}