Ship 7 remaining journey fixes: Harbor data, scan timeout, permissions,

flicker, pack creation, export tooltip, audit guidance

Sprint A: Harbor fixture now returns realistic search results (7 repos)
  and artifact digests (3 versions with tags). Release creation wizard
  Step 2 now shows actual images to select.

Sprint B: Scan polling caps at 60 polls (3 min). Shows timeout banner
  with guidance link to Scheduled Jobs and "Keep Waiting" button.

Sprint C: /console/profile route now renders InsufficientPermissions
  component instead of 404. Shows user/tenant, guidance, and nav links.
  Catches all 24 guard redirect dead-ends.

Sprint D: Event stream chip no longer flickers DEGRADED during context
  reloads. Loading state treated as connected (transient, not error).

Sprint E: Policy Packs empty state now has inline Create Pack form.
  Calls existing PolicyApiService.createPack() backend endpoint.

Sprint F: Diagnostics Export button shows disabled tooltip "Run a
  diagnostic check first" when no results available.

Sprint G: Audit Log shows guidance text when all module counts are 0.
  Lists automatically captured event types. Confirms audit is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 23:00:20 +02:00
parent d80acadcd7
commit ea5942fa1b
9 changed files with 362 additions and 13 deletions

View File

@@ -424,6 +424,14 @@ export const routes: Routes = [
path: 'setup-wizard',
loadChildren: () => import('./features/setup-wizard/setup-wizard.routes').then((m) => m.setupWizardRoutes),
},
{
path: 'console/profile',
title: 'Insufficient Permissions',
loadComponent: () =>
import('./features/console/insufficient-permissions.component').then(
(m) => m.InsufficientPermissionsComponent
),
},
{
path: '**',
title: 'Page Not Found',

View File

@@ -53,4 +53,57 @@ describe('AuditLogDashboardComponent', () => {
expect(exportLink.getAttribute('href')).toBe('/evidence/exports');
});
it('does not show empty guidance when events exist', () => {
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
fixture.detectChanges();
fixture.detectChanges();
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
expect(guidance).toBeNull();
});
});
describe('AuditLogDashboardComponent (zero counts)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AuditLogDashboardComponent],
providers: [
provideRouter([]),
{
provide: AuditLogClient,
useValue: {
getStatsSummary: () => of({
totalEvents: 0,
byModule: {
policy: 0,
authority: 0,
vex: 0,
integrations: 0,
jobengine: 0,
scanner: 0,
attestor: 0,
sbom: 0,
scheduler: 0,
},
}),
getEvents: () => of({ items: [] }),
getAnomalyAlerts: () => of([]),
acknowledgeAnomaly: () => of(void 0),
},
},
],
});
});
it('shows empty guidance when all module counts are zero', () => {
const fixture = TestBed.createComponent(AuditLogDashboardComponent);
fixture.detectChanges();
fixture.detectChanges();
const guidance = fixture.debugElement.query(By.css('.audit-log__empty-guidance'));
expect(guidance).toBeTruthy();
expect(guidance.nativeElement.textContent).toContain('Audit events will appear as the platform is used');
expect(guidance.nativeElement.textContent).toContain('audit capture is active');
});
});

View File

@@ -1,5 +1,5 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client';
@@ -35,6 +35,20 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
</section>
}
@if (allCountsZero()) {
<div class="audit-log__empty-guidance">
<p>Audit events will appear as the platform is used. Events are captured automatically for:</p>
<ul>
<li>Release seals, promotions, and approvals</li>
<li>Policy changes and activations</li>
<li>VEX decisions and consensus votes</li>
<li>Integration configuration changes</li>
<li>Identity and access management</li>
</ul>
<p class="audit-log__status-note">The Evidence rail indicator shows <strong>ON</strong> — audit capture is active.</p>
</div>
}
@if (anomalies().length > 0) {
<section class="anomaly-alerts">
<h2>Anomaly Alerts</h2>
@@ -187,6 +201,18 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
.badge.action.delete { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.badge.action.promote { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.resource { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audit-log__empty-guidance {
margin: 0 0 2rem;
padding: 0.75rem 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.5;
border-left: 3px solid var(--color-border-primary);
}
.audit-log__empty-guidance p { margin: 0 0 0.5rem; }
.audit-log__empty-guidance ul { margin: 0 0 0.5rem; padding-left: 1.25rem; }
.audit-log__empty-guidance li { margin-bottom: 0.25rem; }
.audit-log__status-note { margin-bottom: 0 !important; font-size: 0.85rem; }
`]
})
export class AuditLogDashboardComponent implements OnInit {
@@ -197,6 +223,12 @@ export class AuditLogDashboardComponent implements OnInit {
readonly anomalies = signal<AuditAnomalyAlert[]>([]);
readonly moduleStats = signal<Array<{ module: AuditModule; count: number }>>([]);
readonly allCountsZero = computed(() => {
const s = this.stats();
if (!s) return false;
return s.totalEvents === 0 && this.moduleStats().every(entry => entry.count === 0);
});
ngOnInit(): void {
this.loadData();
}

View File

@@ -0,0 +1,119 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
@Component({
selector: 'app-insufficient-permissions',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="insufficient-permissions">
<div class="insufficient-permissions-content">
<div class="error-code">403</div>
<h1>Insufficient Permissions</h1>
<div class="user-info">
<span class="user-label">Signed in as</span>
<strong>{{ auth.identity()?.name ?? 'Unknown' }}</strong>
@if (auth.tenantId(); as tenant) {
<span class="tenant-chip">{{ tenant }}</span>
}
</div>
<p>
You don't have the required permissions to access this page.
Contact your administrator to request additional access.
</p>
<div class="actions">
<a routerLink="/mission-control/board" class="btn btn-primary">Go to Dashboard</a>
<a routerLink="/setup/identity-access" class="btn btn-secondary">Identity & Access</a>
</div>
</div>
</div>
`,
styles: [`
.insufficient-permissions {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.insufficient-permissions-content {
text-align: center;
max-width: 480px;
}
.error-code {
font-size: 6rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-muted, #4b5563);
line-height: 1;
margin-bottom: 0.5rem;
}
h1 {
margin: 0 0 0.75rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
.user-info {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.user-label {
color: var(--color-text-muted, #6b7280);
}
.tenant-chip {
padding: 0.15rem 0.5rem;
border-radius: var(--radius-sm, 6px);
background: var(--color-surface-tertiary, #374151);
color: var(--color-text-heading);
font-size: 0.8rem;
}
p {
color: var(--color-text-secondary);
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.btn {
padding: 0.6rem 1.25rem;
border-radius: var(--radius-sm, 6px);
font-size: 0.9rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--color-brand-primary);
color: var(--color-text-heading);
}
.btn-secondary {
background: var(--color-surface-tertiary, #374151);
color: var(--color-text-heading);
}
`],
})
export class InsufficientPermissionsComponent {
protected readonly auth = inject(AuthSessionStore);
}

View File

@@ -79,10 +79,16 @@ type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse';
</div>
<div class="dialog-footer">
<button class="btn btn-outline" (click)="copyToClipboard()">
<button class="btn btn-outline"
[disabled]="!hasResults()"
[title]="hasResults() ? 'Copy report to clipboard' : 'Run a diagnostic check first'"
(click)="copyToClipboard()">
{{ copyLabel() }}
</button>
<button class="btn btn-primary" (click)="download()">
<button class="btn btn-primary"
[disabled]="!hasResults()"
[title]="hasResults() ? 'Export results' : 'Run a diagnostic check first'"
(click)="download()">
Download
</button>
</div>
@@ -285,6 +291,12 @@ type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse';
background: var(--color-brand-secondary);
}
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
`]
})
export class ExportDialogComponent {
@@ -299,6 +311,10 @@ export class ExportDialogComponent {
private copied = signal(false);
hasResults(): boolean {
return this.report?.results?.length > 0;
}
onClose(): void {
this.closeDialog.emit();
}

View File

@@ -1,14 +1,16 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
import { RouterLink } from '@angular/router';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyPackStore } from '../services/policy-pack.store';
@Component({
selector: 'app-policy-workspace',
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workspace" [attr.aria-busy]="loading">
@@ -102,9 +104,23 @@ import { PolicyPackStore } from '../services/policy-pack.store';
Policy packs define the rules that govern release decisions.
Create a pack to start defining your organization's release policy.
</p>
<a routerLink="/ops/policy/overview" class="workspace__empty-link">
Learn about policy packs
</a>
<div class="workspace__create-form">
<h4>Create your first policy pack</h4>
<label>
Pack name *
<input type="text" [ngModel]="newPackName()" (ngModelChange)="newPackName.set($event)" placeholder="e.g., Release Security Baseline" />
</label>
<label>
Description
<input type="text" [ngModel]="newPackDesc()" (ngModelChange)="newPackDesc.set($event)" placeholder="Optional description" />
</label>
@if (createError(); as err) {
<p class="error-text">{{ err }}</p>
}
<button class="btn-primary" (click)="createPack()" [disabled]="!newPackName().trim() || creating()">
@if (creating()) { Creating... } @else { Create Pack }
</button>
</div>
</div>
}
}
@@ -144,6 +160,15 @@ import { PolicyPackStore } from '../services/policy-pack.store';
.workspace__empty p { margin: 0 0 1rem; color: var(--color-text-muted); max-width: 480px; margin-inline: auto; }
.workspace__empty-link { display: inline-block; color: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; text-decoration: none; }
.workspace__empty-link:hover { background: var(--color-brand-primary); color: var(--color-text-inverse); }
.workspace__create-form { display: grid; gap: 0.75rem; max-width: 400px; margin: 1.5rem auto 0; text-align: left; }
.workspace__create-form h4 { margin: 0; text-align: center; color: var(--color-text-heading); }
.workspace__create-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: var(--color-text-muted); }
.workspace__create-form input { padding: 0.45rem 0.6rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.95rem; }
.workspace__create-form input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(var(--color-brand-primary-rgb, 99, 102, 241), 0.15); }
.workspace__create-form .btn-primary { background: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); color: var(--color-text-inverse); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; cursor: pointer; font-size: 0.95rem; }
.workspace__create-form .btn-primary:hover:not(:disabled) { opacity: 0.9; }
.workspace__create-form .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.workspace__create-form .error-text { margin: 0; color: var(--color-status-error, #ef4444); font-size: 0.85rem; }
`,
]
})
@@ -161,7 +186,13 @@ export class PolicyWorkspaceComponent {
protected scopeHint = '';
protected refreshing = false;
protected readonly newPackName = signal('');
protected readonly newPackDesc = signal('');
protected readonly creating = signal(false);
protected readonly createError = signal<string | null>(null);
private readonly packStore = inject(PolicyPackStore);
private readonly policyApi = inject(PolicyApiService);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
constructor() {
@@ -186,6 +217,35 @@ export class PolicyWorkspaceComponent {
});
}
createPack(): void {
const name = this.newPackName().trim();
if (!name) return;
this.creating.set(true);
this.createError.set(null);
this.policyApi
.createPack({
name,
description: this.newPackDesc().trim(),
content: '',
})
.subscribe({
next: () => {
this.newPackName.set('');
this.newPackDesc.set('');
this.creating.set(false);
this.refresh();
},
error: (err) => {
this.createError.set(
err?.error?.message ?? err?.message ?? 'Failed to create policy pack. Please try again.'
);
this.creating.set(false);
},
});
}
private applyScopes(): void {
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;

View File

@@ -11,7 +11,7 @@ import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, takeWhile, tap } from 'rxjs';
import { timer, switchMap, takeWhile, tap, take, finalize } from 'rxjs';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
@@ -202,6 +202,14 @@ interface ScanStatusResponse {
</div>
}
@if (scanTimedOut()) {
<div class="timeout-banner">
<p>Scan is taking longer than expected. The scanner may be processing a queue.</p>
<p>Check <a routerLink="/ops/operations/jobengine">Scheduled Jobs</a> for status.</p>
<button type="button" class="btn-ghost" (click)="resumePolling()">Keep Waiting</button>
</div>
}
<footer class="form-actions">
<button type="button" class="btn-ghost" (click)="resetForm()">
Scan Another Image
@@ -457,6 +465,33 @@ interface ScanStatusResponse {
color: var(--color-status-error-text);
}
.timeout-banner {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.82rem;
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--color-status-warning-text, #b08800) 30%, transparent);
background: color-mix(in srgb, var(--color-status-warning-text, #b08800) 8%, transparent);
color: var(--color-status-warning-text, #b08800);
}
.timeout-banner p {
margin: 0;
}
.timeout-banner a {
color: inherit;
text-decoration: underline;
}
.timeout-banner .btn-ghost {
align-self: flex-start;
margin-top: 0.25rem;
}
.findings-link {
margin-left: auto;
text-decoration: none;
@@ -487,6 +522,7 @@ export class ScanSubmitComponent implements OnDestroy {
/** Scan tracking state */
readonly scanId = signal<string | null>(null);
readonly scanStatus = signal<string>('queued');
readonly scanTimedOut = signal(false);
private pollSubscription: { unsubscribe: () => void } | null = null;
/** Derive image name (strip tag/digest for triage link) */
@@ -558,6 +594,7 @@ export class ScanSubmitComponent implements OnDestroy {
this.stopPolling();
this.scanId.set(null);
this.scanStatus.set('queued');
this.scanTimedOut.set(false);
this.submitError.set(null);
this.imageRef.set('');
this.forceRescan = false;
@@ -565,6 +602,11 @@ export class ScanSubmitComponent implements OnDestroy {
this.metadataEntries = [];
}
resumePolling(): void {
this.scanTimedOut.set(false);
this.startPolling(this.scanId()!);
}
ngOnDestroy(): void {
this.stopPolling();
}
@@ -573,6 +615,7 @@ export class ScanSubmitComponent implements OnDestroy {
this.stopPolling();
this.pollSubscription = timer(0, 3000).pipe(
take(60),
switchMap(() =>
this.http.get<ScanStatusResponse>(`/api/v1/scans/${encodeURIComponent(scanId)}`)
),
@@ -587,6 +630,12 @@ export class ScanSubmitComponent implements OnDestroy {
true,
),
takeUntilDestroyed(this.destroyRef),
finalize(() => {
const status = this.scanStatus();
if (status === 'pending' || status === 'queued' || status === 'scanning') {
this.scanTimedOut.set(true);
}
}),
).subscribe();
}

View File

@@ -60,11 +60,11 @@ import { PlatformContextStore } from '../../core/context/platform-context.store'
export class LiveEventStreamChipComponent {
private readonly context = inject(PlatformContextStore);
/**
* Flicker suppression: loading() is a transient state during context refresh,
* not an error. Only show 'degraded' when an actual error is present.
*/
readonly status = computed<'connected' | 'degraded'>(() => {
if (this.context.loading()) {
return 'degraded';
}
return this.context.error() ? 'degraded' : 'connected';
});
}