From ea5942fa1b35bbc4580002eec3593eedcc8f88a4 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 16 Mar 2026 23:00:20 +0200 Subject: [PATCH] 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) --- .../integration-fixtures/harbor/default.conf | 12 ++ src/Web/StellaOps.Web/src/app/app.routes.ts | 8 ++ .../audit-log-dashboard.component.spec.ts | 53 ++++++++ .../audit-log-dashboard.component.ts | 34 ++++- .../insufficient-permissions.component.ts | 119 ++++++++++++++++++ .../export-dialog/export-dialog.component.ts | 20 ++- .../workspace/policy-workspace.component.ts | 70 ++++++++++- .../features/scanner/scan-submit.component.ts | 51 +++++++- .../live-event-stream-chip.component.ts | 8 +- 9 files changed, 362 insertions(+), 13 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/features/console/insufficient-permissions.component.ts diff --git a/devops/compose/fixtures/integration-fixtures/harbor/default.conf b/devops/compose/fixtures/integration-fixtures/harbor/default.conf index 328bc799e..dd36946bc 100644 --- a/devops/compose/fixtures/integration-fixtures/harbor/default.conf +++ b/devops/compose/fixtures/integration-fixtures/harbor/default.conf @@ -8,6 +8,18 @@ server { return 200 '{"status":"healthy","components":[{"name":"core","status":"healthy"},{"name":"jobservice","status":"healthy"},{"name":"registry","status":"healthy"}]}'; } + location ~ ^/api/v2\.0/search { + default_type application/json; + add_header X-Harbor-Version 2.10.0 always; + return 200 '{"repository":[{"repository_name":"stellaops/platform","project_name":"stellaops","project_public":true,"pull_count":142,"artifact_count":8},{"repository_name":"stellaops/scanner","project_name":"stellaops","project_public":true,"pull_count":89,"artifact_count":5},{"repository_name":"stellaops/console","project_name":"stellaops","project_public":true,"pull_count":203,"artifact_count":12},{"repository_name":"stellaops/gateway","project_name":"stellaops","project_public":true,"pull_count":178,"artifact_count":9},{"repository_name":"stellaops/concelier","project_name":"stellaops","project_public":true,"pull_count":67,"artifact_count":4},{"repository_name":"stellaops/jobengine","project_name":"stellaops","project_public":true,"pull_count":95,"artifact_count":6},{"repository_name":"stellaops/timeline","project_name":"stellaops","project_public":true,"pull_count":54,"artifact_count":3}]}'; + } + + location ~ ^/api/v2\.0/projects/.*/repositories/.*/artifacts { + default_type application/json; + add_header X-Harbor-Version 2.10.0 always; + return 200 '[{"digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","tags":[{"name":"dev","push_time":"2026-03-16T10:30:00.000Z","pull_time":"2026-03-16T14:22:00.000Z"},{"name":"latest","push_time":"2026-03-16T10:30:00.000Z","pull_time":"2026-03-16T15:01:00.000Z"}],"push_time":"2026-03-16T10:30:00.000Z","size":28456789,"type":"IMAGE","scan_overview":{"application/vnd.security.vulnerability.report; version=1.1":{"severity":"Medium","complete_percent":100}}},{"digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","tags":[{"name":"v1.0.0","push_time":"2026-03-15T08:00:00.000Z","pull_time":"2026-03-15T12:45:00.000Z"}],"push_time":"2026-03-15T08:00:00.000Z","size":31234567,"type":"IMAGE","scan_overview":{"application/vnd.security.vulnerability.report; version=1.1":{"severity":"Low","complete_percent":100}}},{"digest":"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","tags":[{"name":"v0.9.0","push_time":"2026-03-10T15:00:00.000Z","pull_time":"2026-03-12T09:30:00.000Z"}],"push_time":"2026-03-10T15:00:00.000Z","size":27891234,"type":"IMAGE"}]'; + } + location / { default_type text/plain; return 200 'Stella Ops QA Harbor fixture'; diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 065a10145..e85367f64 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts index 8e5e9fdf8..9d452be22 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.spec.ts @@ -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'); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index 270a14530..dcb80f41a 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -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 '. } + @if (allCountsZero()) { +
+

Audit events will appear as the platform is used. Events are captured automatically for:

+ +

The Evidence rail indicator shows ON — audit capture is active.

+
+ } + @if (anomalies().length > 0) {

Anomaly Alerts

@@ -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([]); readonly moduleStats = signal>([]); + 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(); } diff --git a/src/Web/StellaOps.Web/src/app/features/console/insufficient-permissions.component.ts b/src/Web/StellaOps.Web/src/app/features/console/insufficient-permissions.component.ts new file mode 100644 index 000000000..8cd0893c0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/console/insufficient-permissions.component.ts @@ -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: ` +
+
+
403
+

Insufficient Permissions

+ +

+ You don't have the required permissions to access this page. + Contact your administrator to request additional access. +

+ +
+
+ `, + 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); +} diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/export-dialog/export-dialog.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/components/export-dialog/export-dialog.component.ts index c026e8dfe..3244a775d 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/export-dialog/export-dialog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/export-dialog/export-dialog.component.ts @@ -79,10 +79,16 @@ type ExportFormat = 'json' | 'markdown' | 'text' | 'dsse'; @@ -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(); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts index a00516800..212714e04 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts @@ -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: `
@@ -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.

- - Learn about policy packs - +
+

Create your first policy pack

+ + + @if (createError(); as err) { +

{{ err }}

+ } + +
} } @@ -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(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; diff --git a/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts index b5a12fcff..394849385 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts @@ -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 { } + @if (scanTimedOut()) { +
+

Scan is taking longer than expected. The scanner may be processing a queue.

+

Check Scheduled Jobs for status.

+ +
+ } +