feat(ui): adopt policy components on decisioning surfaces [SPRINT-019]

Wire PolicyEvaluatePanel, RemediationHint, and PolicyPackEditor into
policy-decisioning gates page and policy-studio editor within the
canonical /ops/policy shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 19:25:20 +02:00
parent 7fbf04ab1e
commit 1660a9138e
6 changed files with 463 additions and 1 deletions

View File

@@ -0,0 +1,87 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260308_019_FE_orphan_policy_component_adoption
// Task: FE-OPC-004 - Verify policy-widget revival
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { PolicyDecisioningGatesPageComponent } from './policy-decisioning-gates-page.component';
describe('PolicyDecisioningGatesPageComponent (policy widget adoption)', () => {
let component: PolicyDecisioningGatesPageComponent;
let fixture: ComponentFixture<PolicyDecisioningGatesPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PolicyDecisioningGatesPageComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
root: { queryParams: {} },
paramMap: { get: () => null },
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyDecisioningGatesPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should compute policyEvaluateResult from gate results', () => {
const result = component.policyEvaluateResult();
expect(result).toBeTruthy();
expect(result!.decision).toBeDefined();
expect(['allow', 'warn', 'block']).toContain(result!.decision);
});
it('should derive gates array in policyEvaluateResult', () => {
const result = component.policyEvaluateResult();
expect(result!.gates).toBeDefined();
expect(result!.gates!.length).toBeGreaterThan(0);
for (const gate of result!.gates!) {
expect(gate.gate_id).toBeTruthy();
expect(gate.gate_type).toBeTruthy();
expect(typeof gate.passed).toBe('boolean');
}
});
it('should derive remediation hints for non-passing gates', () => {
const result = component.policyEvaluateResult();
expect(result!.remediation).toBeDefined();
// There should be remediation for WARN/BLOCK gates only
const nonPassingGates = component.gateResults().filter(
(g) => g.state === 'BLOCK' || g.state === 'WARN'
);
expect(result!.remediation!.length).toBe(nonPassingGates.length);
});
it('should set decision to warn when gates have WARN state but no BLOCK', () => {
// In global scope, reachability is WARN, not BLOCK
const result = component.policyEvaluateResult();
// Global scope has WARN gates but no BLOCK
expect(result!.decision).toBe('warn');
});
it('should render the shared evaluation panel in template', () => {
const el: HTMLElement = fixture.nativeElement;
const panelHost = el.querySelector('[data-testid="policy-evaluate-panel-host"]');
expect(panelHost).toBeTruthy();
});
it('should render stella-policy-evaluate-panel element', () => {
const el: HTMLElement = fixture.nativeElement;
const panel = el.querySelector('stella-policy-evaluate-panel');
expect(panel).toBeTruthy();
});
});

View File

@@ -23,6 +23,10 @@ import {
import {
buildContextRouteParams,
} from '../../shared/ui/context-route-state/context-route-state';
import {
PolicyEvaluatePanelComponent,
} from '../../shared/components/policy/policy-evaluate-panel.component';
import type { PolicyEvaluateResponse } from '../../core/api/policy-interop.models';
type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval';
@@ -33,6 +37,7 @@ type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval';
RouterLink,
GateSummaryPanelComponent,
GateExplainDrawerComponent,
PolicyEvaluatePanelComponent,
],
template: `
<section class="policy-gates-page" data-testid="policy-gates-page">
@@ -132,6 +137,19 @@ type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval';
</aside>
</div>
<!-- Policy Evaluation Summary (shared widget) -->
@if (policyEvaluateResult()) {
<section class="evaluation-panel" data-testid="policy-evaluate-panel-host">
<article class="details-card">
<h3>Policy evaluation</h3>
<stella-policy-evaluate-panel
[result]="policyEvaluateResult()"
(retryEvaluate)="onRetryEvaluate()"
/>
</article>
</section>
}
<section class="details-panel">
<article class="details-card">
<h3>Blocking reasons</h3>
@@ -274,6 +292,11 @@ type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval';
text-decoration: none;
}
.evaluation-panel {
display: grid;
gap: 1rem;
}
.details-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -528,6 +551,49 @@ export class PolicyDecisioningGatesPageComponent {
};
});
/**
* Derive a PolicyEvaluateResponse from the current gate results
* so the shared PolicyEvaluatePanelComponent can render a structured
* decision banner, gate table, and remediation hints.
*/
readonly policyEvaluateResult = computed<PolicyEvaluateResponse | null>(() => {
const gates = this.gateResults();
if (gates.length === 0) {
return null;
}
const hasBlock = gates.some((g) => g.state === 'BLOCK');
const hasWarn = gates.some((g) => g.state === 'WARN');
const decision: 'allow' | 'warn' | 'block' = hasBlock
? 'block'
: hasWarn
? 'warn'
: 'allow';
return {
decision,
gates: gates.map((g) => ({
gate_id: g.id,
gate_type: g.gateType ?? 'StandardGate',
passed: g.state === 'PASS',
reason: g.reason,
})),
remediation: gates
.filter((g) => g.state === 'BLOCK' || g.state === 'WARN')
.map((g) => ({
code: g.id,
title: g.reason ?? g.name,
severity: g.state === 'BLOCK' ? 'high' as const : 'medium' as const,
actions: [
{
type: 'review',
description: `Open the explain drawer for ${g.name} to inspect the rule chain.`,
},
],
})),
};
});
readonly blockingReasons = computed<readonly string[]>(() =>
this.gateResults()
.filter((gate) => gate.state === 'BLOCK' || gate.state === 'WARN')
@@ -568,6 +634,11 @@ export class PolicyDecisioningGatesPageComponent {
});
}
onRetryEvaluate(): void {
// Evaluation data is currently derived from the gate results.
// A retry would re-fetch gate data from the backend in a live implementation.
}
returnToSource(): void {
const returnTo = this.returnTo();
if (!returnTo) {

View File

@@ -0,0 +1,105 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260308_019_FE_orphan_policy_component_adoption
// Task: FE-OPC-004 - Verify policy-widget revival (PolicyPackEditor adoption)
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { PolicyEditorComponent } from './policy-editor.component';
import { MonacoLoaderService } from './monaco-loader.service';
import { PolicyApiService } from '../services/policy-api.service';
describe('PolicyEditorComponent (PolicyPackEditor adoption)', () => {
let component: PolicyEditorComponent;
let fixture: ComponentFixture<PolicyEditorComponent>;
const mockPack = {
id: 'test-pack-1',
name: 'Test Pack',
version: '1.0.0',
status: 'draft',
digest: 'sha256:abc123',
description: 'Test policy pack',
content: 'package test',
createdBy: 'admin',
modifiedAt: '2026-03-08T00:00:00Z',
tags: ['security', 'baseline'],
metadata: { author: 'admin', reviewers: ['reviewer1', 'reviewer2'] },
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PolicyEditorComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: { get: (key: string) => key === 'packId' ? 'test-pack-1' : null },
queryParamMap: { get: () => null },
},
},
},
{
provide: MonacoLoaderService,
useValue: {
load: () => new Promise(() => {
// Never resolves - we are testing the pack editor sidebar, not Monaco
}),
},
},
{
provide: PolicyApiService,
useValue: {
getPack: () => of(mockPack),
lint: () => of({ valid: true, errors: [], warnings: [], info: [] }),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should derive packDocument from loaded pack', () => {
expect((component as any).packDocument).toBeTruthy();
expect((component as any).packDocument.metadata.name).toBe('Test Pack');
expect((component as any).packDocument.metadata.version).toBe('1.0.0');
expect((component as any).packDocument.apiVersion).toBe('policy.stellaops.io/v2');
expect((component as any).packDocument.kind).toBe('PolicyPack');
});
it('should render visual-pack-editor-host when packDocument exists', () => {
const el: HTMLElement = fixture.nativeElement;
const host = el.querySelector('[data-testid="visual-pack-editor-host"]');
expect(host).toBeTruthy();
});
it('should render stella-policy-pack-editor element', () => {
const el: HTMLElement = fixture.nativeElement;
const editor = el.querySelector('stella-policy-pack-editor');
expect(editor).toBeTruthy();
});
it('should update packDocument when onPackDocumentChanged is called', () => {
const updatedDoc = {
apiVersion: 'policy.stellaops.io/v2',
kind: 'PolicyPack',
metadata: { name: 'Updated Pack', version: '2.0.0' },
spec: {
settings: { default_action: 'block' as const },
gates: [],
},
};
component.onPackDocumentChanged(updatedDoc);
expect((component as any).packDocument.metadata.name).toBe('Updated Pack');
});
});

View File

@@ -23,6 +23,10 @@ import {
} from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
import { MonacoLoaderService } from './monaco-loader.service';
import {
PolicyPackEditorComponent,
} from '../../../shared/components/policy/policy-pack-editor.component';
import type { PolicyPackDocument } from '../../../core/api/policy-interop.models';
type MonacoNamespace = typeof import('monaco-editor');
@@ -37,7 +41,7 @@ interface ChecklistItem {
@Component({
selector: 'app-policy-editor',
imports: [CommonModule],
imports: [CommonModule, PolicyPackEditorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="policy-editor" [attr.aria-busy]="loadingPack">
@@ -165,6 +169,20 @@ interface ChecklistItem {
</dl>
</div>
}
<!-- Visual gate configuration (shared PolicyPackEditorComponent) -->
@if (packDocument) {
<div class="sidebar-card" data-testid="visual-pack-editor-host">
<header class="sidebar-card__header">
<h3>Visual gate config</h3>
<p>Edit gates and settings without leaving the DSL editor.</p>
</header>
<stella-policy-pack-editor
[policyPack]="packDocument"
(policyChanged)="onPackDocumentChanged($event)"
/>
</div>
}
</aside>
</div>
</section>
@@ -547,6 +565,7 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
private editorHost?: ElementRef<HTMLDivElement>;
protected pack?: PolicyPack;
protected packDocument?: PolicyPackDocument;
protected diagnostics: PolicyDiagnostic[] = [];
protected checklist: ChecklistItem[] = [];
protected linting = false;
@@ -612,11 +631,21 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.model.setValue(this.pack.content ?? '');
}
/**
* Handle visual pack editor changes.
* The shared PolicyPackEditorComponent emits updated documents
* when gates, settings, or rules are modified visually.
*/
onPackDocumentChanged(updatedDoc: PolicyPackDocument): void {
this.packDocument = updatedDoc;
}
private loadPack(packId: string, version?: string): void {
this.loadingPack = true;
this.policyApi.getPack(packId, version).subscribe({
next: (pack) => {
this.pack = pack;
this.packDocument = this.derivePackDocument(pack);
this.loadingPack = false;
this.content$.next(pack.content ?? '');
this.updateChecklist();
@@ -803,4 +832,34 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.checklist = items;
}
/**
* Derive a PolicyPackDocument from the loaded PolicyPack so the shared
* visual gate editor can render. Returns undefined if the pack lacks
* enough metadata to populate the document model.
*/
private derivePackDocument(pack: PolicyPack): PolicyPackDocument | undefined {
if (!pack) {
return undefined;
}
return {
apiVersion: 'policy.stellaops.io/v2',
kind: 'PolicyPack',
metadata: {
name: pack.name,
version: pack.version,
description: pack.description,
digest: pack.digest,
},
spec: {
settings: {
default_action: 'allow',
deterministic_mode: true,
},
gates: [],
rules: [],
},
};
}
}