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:
+
+ - Release seals, promotions, and approvals
+ - Policy changes and activations
+ - VEX decisions and consensus votes
+ - Integration configuration changes
+ - Identity and access management
+
+
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
+
+ Signed in as
+ {{ auth.identity()?.name ?? 'Unknown' }}
+ @if (auth.tenantId(); as tenant) {
+ {{ tenant }}
+ }
+
+
+ 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
-
+
}
}
@@ -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.
+
+
+ }
+