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:
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 →</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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '<targetId>' }} --host <ssh-host></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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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 {
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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 {
|
||||
|
||||
@@ -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 →</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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user