Sprint 4 (partial) + Sprint 6 (partial): Empty states, route cleanup, decision modal, topology skip

Sprint 4:
  S4-T04: Empty state guidance for sparse pages
    - Unknowns: graceful "No unknown components" with "Scan an Image" link
      (replaces API error message)
    - Evidence Capsules: enhanced with "Create your first release" guidance
  S4-T05: Canonical route cleanup
    - All /administration/* routes now redirect to /setup/* canonical
    - /admin/* redirects to /setup instead of /administration
    - 8 admin-only routes migrated to /setup/* (identity-providers, offline,
      configuration-pane, security-data, workflows, ai-preferences)
    - Sidebar nav config updated to /setup/identity-providers
    - Settings routes updated to /setup/* targets

Sprint 6:
  S6-T03: Record Decision as modal overlay
    - Both decision-drawer and decision-drawer-enhanced converted from
      sliding drawer to centered fixed-position modal (z-index 1000)
    - Semi-transparent backdrop, click-outside-to-close, scrollable body
    - Max width 480/520px, max height 90vh
  S6-T04: Topology wizard skip agent step
    - "Skip — assign agent later" button when no agents available
    - Deploy Agent guidance with CLI command
    - Next button shows "Next (without agent)" when skipping
    - Done summary shows "None (skipped)" for agent
    - agentSkipped signal in wizard service

Angular build: 0 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 14:35:14 +02:00
parent b97bffc430
commit 66d53f1505
11 changed files with 636 additions and 344 deletions

View File

@@ -198,10 +198,49 @@ export const routes: Routes = [
},
{
path: 'administration',
title: 'Administration',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSetupGuard],
data: { breadcrumb: 'Administration' },
loadChildren: () => import('./routes/administration.routes').then((m) => m.ADMINISTRATION_ROUTES),
children: [
{ path: '', redirectTo: '/setup', pathMatch: 'full' },
{ path: 'identity-access', redirectTo: preserveAppRedirect('/setup/identity-access'), pathMatch: 'full' },
{ path: 'identity-access/:page', redirectTo: preserveAppRedirect('/setup/identity-access'), pathMatch: 'full' },
{ path: 'profile', redirectTo: '/console/profile', pathMatch: 'full' },
{ path: 'admin', redirectTo: preserveAppRedirect('/setup/identity-access'), pathMatch: 'full' },
{ path: 'admin/:page', redirectTo: preserveAppRedirect('/setup/identity-access'), pathMatch: 'full' },
{ path: 'tenant-branding', redirectTo: preserveAppRedirect('/setup/tenant-branding'), pathMatch: 'full' },
{ path: 'notifications', redirectTo: preserveAppRedirect('/setup/notifications'), pathMatch: 'full' },
{ path: 'usage', redirectTo: preserveAppRedirect('/setup/usage'), pathMatch: 'full' },
{ path: 'policy-governance', redirectTo: preserveAppRedirect('/ops/policy/governance'), pathMatch: 'full' },
{ path: 'policy-governance/exceptions', redirectTo: preserveAppRedirect('/ops/policy/vex/exceptions'), pathMatch: 'full' },
{ path: 'policy-governance/exceptions/:id', redirectTo: preserveAppRedirect('/ops/policy/vex/exceptions/:id'), pathMatch: 'full' },
{ path: 'policy-governance/:page', redirectTo: preserveAppRedirect('/ops/policy/governance/:page'), pathMatch: 'full' },
{ path: 'policy-governance/:page/:child', redirectTo: preserveAppRedirect('/ops/policy/governance/:page/:child'), pathMatch: 'full' },
{ path: 'policy', redirectTo: preserveAppRedirect('/ops/policy/governance'), pathMatch: 'full' },
{ path: 'policy/packs', redirectTo: preserveAppRedirect('/ops/policy/packs'), pathMatch: 'full' },
{ path: 'policy/exceptions', redirectTo: preserveAppRedirect('/ops/policy/vex/exceptions'), pathMatch: 'full' },
{ path: 'policy/exceptions/:id', redirectTo: preserveAppRedirect('/ops/policy/vex/exceptions/:id'), pathMatch: 'full' },
{ path: 'policy/packs/:packId', redirectTo: preserveAppRedirect('/ops/policy/packs/:packId'), pathMatch: 'full' },
{ path: 'policy/packs/:packId/:page', redirectTo: preserveAppRedirect('/ops/policy/packs/:packId/:page'), pathMatch: 'full' },
{ path: 'policy/packs/:packId/explain/:runId', redirectTo: preserveAppRedirect('/ops/policy/packs/:packId/explain/:runId'), pathMatch: 'full' },
{ path: 'policy/governance', redirectTo: preserveAppRedirect('/ops/policy/governance'), pathMatch: 'full' },
{ path: 'policy/governance/:page', redirectTo: preserveAppRedirect('/ops/policy/governance/:page'), pathMatch: 'full' },
{ path: 'policy/governance/:page/:child', redirectTo: preserveAppRedirect('/ops/policy/governance/:page/:child'), pathMatch: 'full' },
{ path: 'audit', redirectTo: preserveAppRedirect('/evidence/audit-log'), pathMatch: 'full' },
{ path: 'audit/:page', redirectTo: preserveAppRedirect('/evidence/audit-log/:page'), pathMatch: 'full' },
{ path: 'audit/:page/:child', redirectTo: preserveAppRedirect('/evidence/audit-log/:page/:child'), pathMatch: 'full' },
{ path: 'trust-signing', redirectTo: preserveAppRedirect('/setup/trust-signing'), pathMatch: 'full' },
{ path: 'trust', redirectTo: preserveAppRedirect('/setup/trust-signing'), pathMatch: 'full' },
{ path: 'trust/issuers', redirectTo: preserveAppRedirect('/setup/trust-signing/issuers'), pathMatch: 'full' },
{ path: 'trust/:page/:child', redirectTo: preserveAppRedirect('/setup/trust-signing/:page/:child'), pathMatch: 'full' },
{ path: 'trust/:page', redirectTo: preserveAppRedirect('/setup/trust-signing/:page'), pathMatch: 'full' },
{ path: 'identity-providers', redirectTo: preserveAppRedirect('/setup/identity-providers'), pathMatch: 'full' },
{ path: 'system', redirectTo: preserveAppRedirect('/setup/system'), pathMatch: 'full' },
{ path: 'offline', redirectTo: preserveAppRedirect('/setup/offline'), pathMatch: 'full' },
{ path: 'configuration-pane', redirectTo: preserveAppRedirect('/setup/configuration-pane'), pathMatch: 'full' },
{ path: 'security-data', redirectTo: preserveAppRedirect('/setup/security-data'), pathMatch: 'full' },
{ path: 'security-data/trivy', redirectTo: preserveAppRedirect('/setup/security-data/trivy'), pathMatch: 'full' },
{ path: 'workflows', redirectTo: preserveAppRedirect('/setup/workflows'), pathMatch: 'full' },
{ path: 'ai-preferences', redirectTo: preserveAppRedirect('/setup/ai-preferences'), pathMatch: 'full' },
{ path: '**', redirectTo: '/setup' },
],
},
{
path: 'console-admin',
@@ -213,7 +252,7 @@ export const routes: Routes = [
{
path: 'admin',
children: [
{ path: '', redirectTo: '/administration', pathMatch: 'full' },
{ path: '', redirectTo: '/setup', pathMatch: 'full' },
{ path: 'notifications', redirectTo: preserveAppRedirect('/setup/notifications'), pathMatch: 'full' },
{ path: 'notifications/:page', redirectTo: preserveAppRedirect('/setup/notifications/:page'), pathMatch: 'full' },
{ path: 'trust', redirectTo: preserveAppRedirect('/setup/trust-signing'), pathMatch: 'full' },
@@ -230,7 +269,7 @@ export const routes: Routes = [
pathMatch: 'full',
},
{ path: 'registries', redirectTo: preserveAppRedirect('/setup/integrations'), pathMatch: 'full' },
{ path: '**', redirectTo: '/administration' },
{ path: '**', redirectTo: '/setup' },
],
},
{

View File

@@ -637,7 +637,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'identity-providers',
label: 'Identity Providers',
route: '/administration/identity-providers',
route: '/setup/identity-providers',
icon: 'id-card',
requiredScopes: ['ui.admin'],
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',

View File

@@ -17,7 +17,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import {
EvidencePackSummary,
EvidencePackQuery,
@@ -28,7 +28,7 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
@Component({
selector: 'stellaops-evidence-pack-list',
imports: [CommonModule, FormsModule, ErrorStateComponent, FilterBarComponent],
imports: [CommonModule, FormsModule, RouterModule, ErrorStateComponent, FilterBarComponent],
template: `
<div class="evidence-pack-list">
<!-- Header with filters -->
@@ -71,7 +71,9 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<p>No decision capsules found</p>
<h3 class="empty-title">No decision capsules found</h3>
<p class="empty-guidance">Decision capsules are created when releases are sealed. Create your first release to generate evidence.</p>
<a class="empty-action-link" routerLink="/releases/versions/new">Create a Release &rarr;</a>
</div>
} @else {
<div class="pack-grid">
@@ -212,6 +214,37 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
margin-bottom: 1rem;
}
.empty-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.empty-guidance {
margin: 0 0 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 36ch;
}
.empty-action-link {
display: inline-block;
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-sm);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.empty-action-link:hover {
background: var(--color-brand-primary);
color: #fff;
}
.retry-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border-primary);

View File

@@ -48,7 +48,7 @@ interface SloRow {
</table>
<footer class="links">
<a routerLink="/administration/system">Open System SLO Monitoring</a>
<a routerLink="/setup/system">Open System SLO Monitoring</a>
<a routerLink="/releases/approvals">Open impacted approvals</a>
</footer>
</div>

View File

@@ -367,9 +367,24 @@ import {
</button>
}
</div>
@if (!wizard.selectedAgent() && !wizard.agentSkipped()) {
<div class="skip-agent-link">
<button type="button" class="btn-link" (click)="skipAgent()">
Skip -- assign agent later
</button>
</div>
}
} @else {
<div class="empty-state">
<p>No agents found. You can onboard an agent first.</p>
<div class="no-agents-guidance">
<p>No agents deployed yet. You can:</p>
<ul>
<li>Skip this step and assign an agent later from the Topology page</li>
<li>Deploy an agent using: <code>stella agent install --target {{ wizard.createdTarget()?.id ?? '&lt;targetId&gt;' }} --host &lt;ssh-host&gt;</code></li>
</ul>
<button type="button" class="btn btn--secondary btn--sm" (click)="skipAgent()">
Skip -- assign agent later
</button>
<a routerLink="/ops/agents/onboard" class="btn btn--secondary btn--sm">
Onboard Agent
</a>
@@ -382,6 +397,13 @@ import {
Agent "{{ wizard.selectedAgent()?.displayName }}" selected.
</div>
}
@if (wizard.agentSkipped()) {
<div class="skip-notice">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Agent assignment skipped. You can assign an agent later from the Topology page.
</div>
}
</section>
}
@@ -557,7 +579,7 @@ import {
</div>
<div class="done-summary__item">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<span>Agent: {{ wizard.selectedAgent()?.displayName ?? 'N/A' }}</span>
<span>Agent: {{ wizard.selectedAgent()?.displayName ?? (wizard.agentSkipped() ? 'None (skipped)' : 'N/A') }}</span>
</div>
<div class="done-summary__item">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
@@ -595,7 +617,7 @@ import {
[disabled]="!wizard.canGoNext()"
(click)="onNext()"
>
{{ wizard.currentStep() === 'validate' ? 'Finish' : 'Next' }}
{{ wizard.currentStep() === 'validate' ? 'Finish' : (wizard.currentStep() === 'agent' && wizard.agentSkipped() && !wizard.selectedAgent() ? 'Next (without agent)' : 'Next') }}
</button>
</footer>
}
@@ -826,6 +848,74 @@ import {
}
}
.skip-agent-link {
margin-top: 1rem;
text-align: center;
}
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
font-size: 0.82rem;
text-decoration: underline;
padding: 0;
&:hover {
color: var(--color-brand-primary-hover);
}
}
.no-agents-guidance {
padding: 1.5rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.82rem;
color: var(--color-text-secondary);
p {
margin: 0 0 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
ul {
margin: 0 0 1rem;
padding-left: 1.25rem;
}
li {
margin-bottom: 0.4rem;
}
code {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
background: var(--color-surface-tertiary);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.btn {
margin-right: 0.5rem;
}
}
.skip-notice {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 1rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.1));
color: var(--color-status-warning, #f59e0b);
font-size: 0.78rem;
border: 1px solid rgba(245, 158, 11, 0.3);
}
/* Option List / Cards */
.option-list {
display: grid;
@@ -1424,7 +1514,13 @@ export class TopologyWizardComponent implements OnInit, OnDestroy {
}
// --- Agent ---
skipAgent(): void {
this.wizard.agentSkipped.set(true);
this.wizard.selectedAgent.set(null);
}
selectAgent(agent: Agent): void {
this.wizard.agentSkipped.set(false);
this.wizard.selectedAgent.set(agent);
const targetId = this.wizard.createdTarget()?.id;
if (targetId) {

View File

@@ -88,6 +88,7 @@ export class TopologyWizardService {
readonly createdEnvironment = signal<Environment | null>(null);
readonly createdTarget = signal<Target | null>(null);
readonly selectedAgent = signal<Agent | null>(null);
readonly agentSkipped = signal(false);
readonly resolvedBindings = signal<ResolvedBindings | null>(null);
readonly readinessReport = signal<ReadinessReport | null>(null);
readonly loading = signal(false);
@@ -104,7 +105,7 @@ export class TopologyWizardService {
case 'environment': return this.createdEnvironment() !== null;
case 'stage-order': return true;
case 'target': return this.createdTarget() !== null;
case 'agent': return this.selectedAgent() !== null;
case 'agent': return this.selectedAgent() !== null || this.agentSkipped();
case 'infrastructure': return true;
case 'validate': return this.readinessReport()?.isReady === true;
default: return false;
@@ -142,6 +143,7 @@ export class TopologyWizardService {
this.createdEnvironment.set(null);
this.createdTarget.set(null);
this.selectedAgent.set(null);
this.agentSkipped.set(false);
this.resolvedBindings.set(null);
this.readinessReport.set(null);
this.error.set(null);

View File

@@ -83,13 +83,13 @@ export const SETTINGS_ROUTES: Routes = [
{
path: 'admin',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin'),
redirectTo: redirectToCanonical('/setup/identity-access'),
pathMatch: 'full' as const,
},
{
path: 'admin/:page',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin/:page'),
redirectTo: redirectToCanonical('/setup/identity-access'),
pathMatch: 'full' as const,
},
{
@@ -113,19 +113,19 @@ export const SETTINGS_ROUTES: Routes = [
{
path: 'identity-providers',
title: 'Identity Providers',
redirectTo: redirectToCanonical('/administration/identity-providers'),
redirectTo: redirectToCanonical('/setup/identity-providers'),
pathMatch: 'full' as const,
},
{
path: 'system',
title: 'System',
redirectTo: redirectToCanonical('/administration/system'),
redirectTo: redirectToCanonical('/setup/system'),
pathMatch: 'full' as const,
},
{
path: 'security-data',
title: 'Security Data',
redirectTo: redirectToCanonical('/administration/security-data'),
redirectTo: redirectToCanonical('/setup/security-data'),
pathMatch: 'full' as const,
},
@@ -153,7 +153,7 @@ export const SETTINGS_ROUTES: Routes = [
{
path: 'offline',
title: 'Offline Settings',
redirectTo: redirectToCanonical('/administration/offline'),
redirectTo: redirectToCanonical('/setup/offline'),
pathMatch: 'full' as const,
},
{

View File

@@ -71,178 +71,180 @@ export interface ApprovalResponse {
standalone: true,
imports: [CommonModule, FormsModule, PlaybookSuggestionComponent],
template: `
<aside class="decision-drawer" [class.open]="isOpen" role="dialog" aria-labelledby="drawer-title">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close drawer">
&times;
</button>
</header>
<!-- OM-FE-003: Playbook Suggestions Section -->
<stellaops-playbook-suggestion
[context]="playbookContext()"
[startCollapsed]="false"
(suggestionSelected)="applyPlaybookSuggestion($event)"
/>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<!-- T018: TTL Picker for Exceptions -->
@if (showTtlPicker()) {
<section class="ttl-section">
<h4>Exception Time-to-Live</h4>
<div class="ttl-picker">
@for (opt of ttlOptions; track opt) {
<label class="ttl-option">
<input type="radio" name="ttl"
[value]="opt.days"
[checked]="formData().exceptionTtlDays === opt.days"
(change)="setTtlDays(opt.days)">
<span>{{ opt.label }}</span>
</label>
}
@if (showCustomTtl()) {
<div class="custom-ttl">
<input type="date"
[min]="minExpiryDate"
[max]="maxExpiryDate"
[ngModel]="customExpiryDate()"
(ngModelChange)="setCustomExpiry($event)"
aria-label="Custom expiry date">
</div>
}
</div>
@if (formData().exceptionTtlDays) {
<p class="ttl-note">
Expires: {{ computedExpiryDate() | date:'mediumDate' }}
</p>
}
</section>
}
<!-- T019: Policy Reference Display -->
<section class="policy-section">
<h4>Policy Reference</h4>
<div class="policy-display">
<input type="text"
[ngModel]="formData().policyReference"
(ngModelChange)="setPolicyReference($event)"
[readonly]="!isAdmin"
[placeholder]="defaultPolicyRef"
class="policy-input"
aria-label="Policy reference">
@if (isAdmin) {
<button class="btn-icon" (click)="resetPolicyRef()" title="Reset to default">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
}
</div>
<p class="policy-note">
<a [href]="policyDocUrl" target="_blank" rel="noopener">View policy documentation</a>
</p>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
<footer>
<button class="btn btn-secondary" (click)="close.emit()" [disabled]="isSubmitting()">
Cancel
</button>
<!-- T020: Sign-and-Apply Flow -->
<button class="btn btn-primary"
[disabled]="!isValid() || isSubmitting()"
(click)="signAndApply()">
@if (isSubmitting()) {
<span class="spinner"></span>
}
{{ isSubmitting() ? 'Signing...' : 'Sign & Apply' }}
</button>
</footer>
</aside>
<!-- Backdrop -->
<!-- Backdrop + Modal Overlay -->
@if (isOpen) {
<div class="backdrop" (click)="close.emit()" aria-hidden="true"></div>
<div class="decision-overlay" (click)="close.emit()" aria-hidden="true">
<div class="decision-modal" role="dialog" aria-labelledby="drawer-title" (click)="$event.stopPropagation()">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close dialog">
&times;
</button>
</header>
<div class="modal-body">
<!-- OM-FE-003: Playbook Suggestions Section -->
<stellaops-playbook-suggestion
[context]="playbookContext()"
[startCollapsed]="false"
(suggestionSelected)="applyPlaybookSuggestion($event)"
/>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<!-- T018: TTL Picker for Exceptions -->
@if (showTtlPicker()) {
<section class="ttl-section">
<h4>Exception Time-to-Live</h4>
<div class="ttl-picker">
@for (opt of ttlOptions; track opt) {
<label class="ttl-option">
<input type="radio" name="ttl"
[value]="opt.days"
[checked]="formData().exceptionTtlDays === opt.days"
(change)="setTtlDays(opt.days)">
<span>{{ opt.label }}</span>
</label>
}
@if (showCustomTtl()) {
<div class="custom-ttl">
<input type="date"
[min]="minExpiryDate"
[max]="maxExpiryDate"
[ngModel]="customExpiryDate()"
(ngModelChange)="setCustomExpiry($event)"
aria-label="Custom expiry date">
</div>
}
</div>
@if (formData().exceptionTtlDays) {
<p class="ttl-note">
Expires: {{ computedExpiryDate() | date:'mediumDate' }}
</p>
}
</section>
}
<!-- T019: Policy Reference Display -->
<section class="policy-section">
<h4>Policy Reference</h4>
<div class="policy-display">
<input type="text"
[ngModel]="formData().policyReference"
(ngModelChange)="setPolicyReference($event)"
[readonly]="!isAdmin"
[placeholder]="defaultPolicyRef"
class="policy-input"
aria-label="Policy reference">
@if (isAdmin) {
<button class="btn-icon" (click)="resetPolicyRef()" title="Reset to default">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
}
</div>
<p class="policy-note">
<a [href]="policyDocUrl" target="_blank" rel="noopener">View policy documentation</a>
</p>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
</div>
<footer>
<button class="btn btn-secondary" (click)="close.emit()" [disabled]="isSubmitting()">
Cancel
</button>
<!-- T020: Sign-and-Apply Flow -->
<button class="btn btn-primary"
[disabled]="!isValid() || isSubmitting()"
(click)="signAndApply()">
@if (isSubmitting()) {
<span class="spinner"></span>
}
{{ isSubmitting() ? 'Signing...' : 'Sign & Apply' }}
</button>
</footer>
</div>
</div>
}
<!-- T021: Undo Toast -->
@@ -255,30 +257,35 @@ export interface ApprovalResponse {
}
`,
styles: [`
.decision-drawer {
.decision-overlay {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 380px;
background: var(--color-surface-primary);
border-left: 1px solid var(--color-border-primary);
box-shadow: -4px 0 16px rgba(0,0,0,0.1);
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background: rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 101;
overflow-y: auto;
align-items: center;
justify-content: center;
}
.decision-drawer.open { transform: translateX(0); }
.decision-modal {
width: 520px;
max-width: 95vw;
max-height: 90vh;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
.modal-body {
overflow-y: auto;
max-height: calc(90vh - 120px);
flex: 1;
}
header {
@@ -287,9 +294,9 @@ export interface ApprovalResponse {
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
position: sticky;
top: 0;
background: var(--color-surface-primary);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
flex-shrink: 0;
}
h3 { margin: 0; font-size: var(--font-size-lg); }
@@ -429,15 +436,14 @@ export interface ApprovalResponse {
.hash { font-family: ui-monospace, monospace; font-size: var(--font-size-xs); word-break: break-all; }
footer {
margin-top: auto;
padding: 16px;
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid var(--color-border-primary);
position: sticky;
bottom: 0;
background: var(--color-surface-primary);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
flex-shrink: 0;
}
.btn {

View File

@@ -29,140 +29,146 @@ export interface AlertSummary {
selector: 'app-decision-drawer',
imports: [FormsModule],
template: `
<aside class="decision-drawer" [class.open]="isOpen" role="dialog" aria-labelledby="drawer-title">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close drawer">
&times;
</button>
</header>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
<footer>
<button class="btn btn-secondary" (click)="close.emit()">
Cancel
</button>
<button class="btn btn-primary"
[disabled]="!isValid()"
(click)="submitDecision()">
Record Decision
</button>
</footer>
</aside>
<!-- Backdrop -->
@if (isOpen) {
<div class="backdrop" (click)="close.emit()" aria-hidden="true"></div>
<div class="decision-overlay" (click)="close.emit()" aria-hidden="true">
<div class="decision-modal" role="dialog" aria-labelledby="drawer-title" (click)="$event.stopPropagation()">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close dialog">
&times;
</button>
</header>
<div class="modal-body">
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
</div>
<footer>
<button class="btn btn-secondary" (click)="close.emit()">
Cancel
</button>
<button class="btn btn-primary"
[disabled]="!isValid()"
(click)="submitDecision()">
Record Decision
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.decision-drawer {
.decision-overlay {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 360px;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.decision-modal {
width: 480px;
max-width: 95vw;
max-height: 90vh;
background: var(--color-surface-primary);
border-left: 1px solid var(--color-border-primary);
box-shadow: -4px 0 16px rgba(0,0,0,0.1);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 101;
}
.decision-drawer.open {
transform: translateX(0);
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
.modal-body {
overflow-y: auto;
max-height: calc(90vh - 120px);
flex: 1;
}
header {

View File

@@ -32,12 +32,15 @@ import {
</header>
@if (error()) {
<section class="unknowns-dashboard__error" role="alert">
<div>
<h2>Unknowns data is unavailable</h2>
<p>{{ error() }}</p>
</div>
<button type="button" (click)="refresh()">Retry</button>
<section class="unknowns-dashboard__empty-state" role="status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="empty-icon">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<h2>No unknown components detected</h2>
<p>The scanner will identify unknowns during the next image scan.</p>
<a routerLink="/security/scan" class="empty-action-link">Scan an Image &rarr;</a>
</section>
}
@@ -166,6 +169,50 @@ import {
background: var(--color-surface-primary);
color: var(--color-text-secondary);
}
.unknowns-dashboard__empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary, #f9fafb);
text-align: center;
}
.unknowns-dashboard__empty-state .empty-icon {
width: 48px;
height: 48px;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 1rem;
}
.unknowns-dashboard__empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.unknowns-dashboard__empty-state p {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.empty-action-link {
display: inline-block;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-brand-primary, #3b82f6);
border: 1px solid var(--color-brand-primary, #3b82f6);
border-radius: var(--radius-md);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.empty-action-link:hover {
background: var(--color-brand-primary, #3b82f6);
color: #fff;
}
`]
})
export class UnknownsDashboardComponent implements OnInit {

View File

@@ -67,4 +67,67 @@ export const SETUP_ROUTES: Routes = [
loadChildren: () =>
import('../features/trust-admin/trust-admin.routes').then((m) => m.trustAdminRoutes),
},
{
path: 'identity-providers',
title: 'Identity Providers',
data: { breadcrumb: 'Identity Providers' },
loadComponent: () =>
import('../features/settings/identity-providers/identity-providers-settings-page.component').then(
(m) => m.IdentityProvidersSettingsPageComponent,
),
},
{
path: 'offline',
title: 'Offline Settings',
data: { breadcrumb: 'Offline Settings' },
loadComponent: () =>
import('../features/offline-kit/components/offline-dashboard.component').then(
(m) => m.OfflineDashboardComponent,
),
},
{
path: 'configuration-pane',
title: 'Configuration',
data: { breadcrumb: 'Configuration' },
loadChildren: () =>
import('../features/configuration-pane/configuration-pane.routes').then(
(m) => m.CONFIGURATION_PANE_ROUTES,
),
},
{
path: 'security-data',
title: 'Security Data',
data: { breadcrumb: 'Security Data' },
loadComponent: () =>
import('../features/settings/security-data/security-data-settings-page.component').then(
(m) => m.SecurityDataSettingsPageComponent,
),
},
{
path: 'security-data/trivy',
title: 'Trivy DB Settings',
data: { breadcrumb: 'Trivy DB' },
loadComponent: () =>
import('../features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent,
),
},
{
path: 'workflows',
title: 'Workflows',
data: { breadcrumb: 'Workflows' },
loadChildren: () =>
import('../features/release-orchestrator/workflows/workflows.routes').then(
(m) => m.WORKFLOW_ROUTES,
),
},
{
path: 'ai-preferences',
title: 'AI Preferences',
data: { breadcrumb: 'AI Preferences' },
loadComponent: () =>
import('../features/settings/ai-preferences-workbench.component').then(
(m) => m.AiPreferencesWorkbenchComponent,
),
},
];