ui pack redo

This commit is contained in:
master
2026-02-20 07:36:18 +02:00
parent 7ca0113343
commit ca5e7888d6
122 changed files with 8508 additions and 1971 deletions

View File

@@ -92,6 +92,9 @@ This documentation set is intentionally consolidated and does not maintain compa
| Architecture: data flows | `technical/architecture/data-flows.md` | | Architecture: data flows | `technical/architecture/data-flows.md` |
| Architecture: schema mapping | `technical/architecture/schema-mapping.md` | | Architecture: schema mapping | `technical/architecture/schema-mapping.md` |
| Release Orchestration dossier | `modules/release-orchestrator/architecture.md` | | Release Orchestration dossier | `modules/release-orchestrator/architecture.md` |
| Telemetry federation architecture | `modules/telemetry/federation-architecture.md` |
| Telemetry federation runbook | `runbooks/federated-telemetry-operations.md` |
| Telemetry federation contracts | `contracts/federated-consent-v1.md`, `contracts/federated-telemetry-v1.md` |
### Development and operations ### Development and operations

View File

@@ -0,0 +1,90 @@
# Sprint 20260220-016 - FE Pack 19 Exceptions Conformity Gap
## Topic & Scope
- Close the remaining pack conformity gap after full `pack-01..pack-21` Playwright verification.
- Implement Pack 19 Exceptions screen semantics at canonical `Security & Risk` routes.
- Preserve existing triage workflows while separating them from the Pack 19 Exceptions surface.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: focused unit tests, Playwright pack-conformance pass, and updated diff ledger.
## Dependencies & Concurrency
- Depends on current canonical route map in `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts`.
- Depends on Pack source-of-truth docs in `docs/modules/ui/v2-rewire/pack-19.md` and `docs/modules/ui/v2-rewire/source-of-truth.md`.
- Safe concurrency: may run in parallel with non-security FE work if no edits touch `security-risk` routes/components.
## Documentation Prerequisites
- `docs/modules/ui/v2-rewire/pack-19.md`
- `docs/modules/ui/v2-rewire/source-of-truth.md`
- `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`
## Delivery Tracker
### S19-EX-01 - Replace Pack 19 Exceptions route surface
Status: TODO
Dependency: none
Owners: FE implementer
Task description:
- Replace `/security-risk/exceptions` route target so it renders a dedicated Exceptions screen aligned to Pack 19 section 19.10.
- Keep route canonical and maintain existing breadcrumb/title behavior under `Security & Risk`.
Completion criteria:
- [ ] `/security-risk/exceptions` no longer resolves to triage artifact UI.
- [ ] Exceptions list UI vocabulary reflects waiver/risk acceptance domain.
- [ ] Sidebar navigation label/path behavior remains stable for `Security & Risk`.
### S19-EX-02 - Add Exception detail workflow route
Status: TODO
Dependency: S19-EX-01
Owners: FE implementer
Task description:
- Implement dedicated Exception detail surface for `/security-risk/exceptions/:id`.
- Ensure drill-down links from Exceptions list use this route and preserve back navigation to Exceptions list.
Completion criteria:
- [ ] `/security-risk/exceptions/:id` resolves to an Exception detail view, not triage artifact detail.
- [ ] Exceptions list has deterministic navigation to detail.
- [ ] Detail view includes status, scope, expiry, approvals, and evidence pointers required by Pack 19 intent.
### S19-EX-03 - Test coverage and pack-conformance verification
Status: TODO
Dependency: S19-EX-01
Owners: FE implementer, QA
Task description:
- Add or update unit tests for the new Exceptions route wiring and core rendering assertions.
- Re-run pack-conformance Playwright sweep against `pack-01..pack-21` and ensure zero mismatches.
Completion criteria:
- [ ] Unit tests pass for new Exceptions route/component behavior.
- [ ] `tests/e2e/pack-conformance.scratch.spec.ts` passes with no mismatches.
- [ ] Test commands and outputs recorded in this sprint `Execution Log`.
### S19-EX-04 - Update pack difference ledger and close sprint
Status: TODO
Dependency: S19-EX-03
Owners: FE implementer, Documentation author
Task description:
- Update `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md` from `DIFF` to resolved state when implementation lands.
- Archive this sprint only after all tasks are `DONE`.
Completion criteria:
- [ ] Pack diff ledger updated to reflect resolved Pack 19 mismatch.
- [ ] All tasks in this sprint are `DONE`.
- [ ] Sprint moved to `docs-archived/implplan/` only after criteria are met.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-02-20 | Sprint created from full Pack conformity run. Result: 61 checks, 1 mismatch at Pack 19 Exceptions route. | Planning |
| 2026-02-20 | Reproduced mismatch with filtered run (`PACK_CONFORMANCE_FILTER='pack-19.*exceptions'`) to isolate route-level nonconformance. | QA |
## Decisions & Risks
- Decision: treat latest pack precedence as authoritative; Pack 19 section 19.10 governs Exceptions behavior.
- Decision: keep this sprint FE-scoped with route/component separation first; backend enrichment can layer without blocking route conformance.
- Risk: replacing current route target can break users relying on triage page at `/security-risk/exceptions`; mitigate by preserving triage under existing triage paths and adding redirects if needed.
- Risk: pack-conformance run is sensitive to dev proxy path capture for `/integrations` and `/platform`; mitigate by using clean proxy config during conformity runs.
- Evidence reference: `docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md`.
## Next Checkpoints
- 2026-02-21: route/component implementation complete and unit tests green.
- 2026-02-21: full Playwright pack-conformance rerun shows zero mismatches.
- 2026-02-21: sprint ready for archive review.

View File

@@ -135,3 +135,7 @@ src/Remediation/
- SPRINT_20260220_013: Matching, sources, policy - SPRINT_20260220_013: Matching, sources, policy
- SPRINT_20260220_014: UI components - SPRINT_20260220_014: UI components
- SPRINT_20260220_015: Documentation - SPRINT_20260220_015: Documentation
## Related Contracts
- `docs/contracts/remediation-pr-v1.md`

View File

@@ -29,9 +29,13 @@ Telemetry module captures deployment and operations guidance for the shared obse
- Sprint 23 console security sign-off (2025-10-27) added the `console-security.json` Grafana board and burn-rate alert pack—ensure environments import the updated dashboards/alerts referenced in `docs/updates/2025-10-27-console-security-signoff.md`. - Sprint 23 console security sign-off (2025-10-27) added the `console-security.json` Grafana board and burn-rate alert pack—ensure environments import the updated dashboards/alerts referenced in `docs/updates/2025-10-27-console-security-signoff.md`.
- Observability assets for this sprint: `operations/observability.md` and `operations/dashboards/telemetry-observability.json` (offline import). - Observability assets for this sprint: `operations/observability.md` and `operations/dashboards/telemetry-observability.json` (offline import).
## Related resources ## Related resources
- ./operations/collector.md - ./operations/collector.md
- ./operations/storage.md - ./operations/storage.md
- ./federation-architecture.md
- ../../contracts/federated-consent-v1.md
- ../../contracts/federated-telemetry-v1.md
- ../../runbooks/federated-telemetry-operations.md
## Backlog references ## Backlog references
- TELEMETRY-OBS-50-001 … 50-004 in ../../TASKS.md. - TELEMETRY-OBS-50-001 … 50-004 in ../../TASKS.md.

View File

@@ -0,0 +1,25 @@
# Pack Conformity Diff - 2026-02-20 (UTC)
## Scope
- Source packs reviewed: `docs/modules/ui/v2-rewire/pack-01.md` through `docs/modules/ui/v2-rewire/pack-21.md`.
- Effective precedence rule: higher pack number wins where behavior is refined in later packs.
- Conformity harness: `src/Web/StellaOps.Web/tests/e2e/pack-conformance.scratch.spec.ts`.
- UI run mode for clean routing: Angular dev server on `https://127.0.0.1:4410` with empty proxy config (no `/integrations` or `/platform` path capture).
## Evidence
- Command:
`npx ng serve --configuration development --port 4410 --host 127.0.0.1 --ssl --proxy-config proxy.playwright-empty.json`
- Command:
`PLAYWRIGHT_BASE_URL=https://127.0.0.1:4410 npx playwright test tests/e2e/pack-conformance.scratch.spec.ts`
- Result:
`61` canonical pack route checks executed, `60` conformant, `1` mismatch.
## Difference Ledger
| Status | Pack File | Pack Section | Canonical Route | Expected UI | Actual UI | Code Reference |
| --- | --- | --- | --- | --- | --- | --- |
| DIFF | `docs/modules/ui/v2-rewire/pack-19.md` | `19.10 Security screen - Exceptions` | `/security-risk/exceptions` | Dedicated "Exceptions" screen for waivers and risk acceptance | Route resolves to Vulnerability Triage artifact screen (`Vulnerability Triage`, `Artifact-first workflow with evidence and VEX-first decisioning`) | `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts:103`, `src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts:107`, `src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html:4` |
## Notes
- The remaining gap is functional, not naming-only.
- The mismatch is isolated to the Pack 19 Exceptions requirement.
- All other pack-derived canonical routes in the current matrix conform under the clean run mode above.

View File

@@ -116,6 +116,48 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Contains(listed!.Users, static user => user.Username == "alice"); Assert.Contains(listed!.Users, static user => user.Username == "alice");
} }
[Fact]
public async Task LegacyApiAlias_UsersListAndCreate_WorkForApiAdminPath()
{
var now = new DateTimeOffset(2026, 2, 20, 14, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
await using var app = await CreateApplicationAsync(timeProvider, sink, users);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "default",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityUsersRead, StellaOpsScopes.AuthorityUsersWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "default");
var createResponse = await client.PostAsJsonAsync(
"/api/admin/users",
new
{
username = "legacy-api-user",
email = "legacy@example.com",
displayName = "Legacy API User",
roles = new[] { "operator" }
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
Assert.NotNull(createResponse.Headers.Location);
Assert.StartsWith("/api/admin/users/", createResponse.Headers.Location!.ToString(), StringComparison.OrdinalIgnoreCase);
var listResponse = await client.GetAsync("/api/admin/users");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var payload = await listResponse.Content.ReadFromJsonAsync<UserListPayload>();
Assert.NotNull(payload);
Assert.Contains(payload!.Users, static user => user.Username == "legacy-api-user");
}
private static async Task<WebApplication> CreateApplicationAsync( private static async Task<WebApplication> CreateApplicationAsync(
FakeTimeProvider timeProvider, FakeTimeProvider timeProvider,
RecordingAuthEventSink sink, RecordingAuthEventSink sink,

View File

@@ -33,6 +33,13 @@ internal static class ConsoleAdminEndpointExtensions
adminGroup.AddEndpointFilter(new TenantHeaderFilter()); adminGroup.AddEndpointFilter(new TenantHeaderFilter());
adminGroup.AddEndpointFilter(new FreshAuthFilter()); adminGroup.AddEndpointFilter(new FreshAuthFilter());
var legacyApiGroup = app.MapGroup("/api/admin")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiAdmin))
.WithTags("Console Admin");
legacyApiGroup.AddEndpointFilter(new TenantHeaderFilter());
legacyApiGroup.AddEndpointFilter(new FreshAuthFilter());
// Tenants // Tenants
var tenantGroup = adminGroup.MapGroup("/tenants"); var tenantGroup = adminGroup.MapGroup("/tenants");
@@ -79,6 +86,17 @@ internal static class ConsoleAdminEndpointExtensions
.WithName("AdminCreateUser") .WithName("AdminCreateUser")
.WithSummary("Create a local user (does not apply to external IdP users)."); .WithSummary("Create a local user (does not apply to external IdP users).");
var legacyUserGroup = legacyApiGroup.MapGroup("/users");
legacyUserGroup.MapGet("", ListUsers)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityUsersRead))
.WithSummary("Legacy alias: list users for the specified tenant.");
legacyUserGroup.MapPost("", CreateUser)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityUsersWrite))
.RequireFreshAuth()
.WithSummary("Legacy alias: create a local user.");
userGroup.MapPatch("/{userId}", UpdateUser) userGroup.MapPatch("/{userId}", UpdateUser)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityUsersWrite)) .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityUsersWrite))
.RequireFreshAuth() .RequireFreshAuth()
@@ -388,7 +406,12 @@ internal static class ConsoleAdminEndpointExtensions
("user.id", createdSummary.Id)), ("user.id", createdSummary.Id)),
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return Results.Created($"/console/admin/users/{createdSummary.Id}", createdSummary); var requestPath = httpContext.Request.Path.Value ?? string.Empty;
var locationPrefix = requestPath.StartsWith("/api/admin", StringComparison.OrdinalIgnoreCase)
? "/api/admin/users"
: "/console/admin/users";
return Results.Created($"{locationPrefix}/{createdSummary.Id}", createdSummary);
} }
private static async Task<IResult> UpdateUser( private static async Task<IResult> UpdateUser(

View File

@@ -79,7 +79,8 @@ public sealed class PrivacyBudgetTrackerTests
var snapshot = tracker.GetSnapshot(); var snapshot = tracker.GetSnapshot();
Assert.Equal(2, snapshot.QueriesThisPeriod); Assert.Equal(2, snapshot.QueriesThisPeriod);
Assert.Equal(1, snapshot.SuppressedThisPeriod); Assert.Equal(1, snapshot.SuppressedThisPeriod);
Assert.True(snapshot.Exhausted); Assert.False(snapshot.Exhausted);
Assert.Equal(0.1, snapshot.Remaining, precision: 10);
} }
[Fact] [Fact]

View File

@@ -121,6 +121,9 @@ public sealed class TelemetryAggregatorTests
{ {
var (aggregator, budget) = CreateAggregator(kThreshold: 1, epsilon: 0.01); var (aggregator, budget) = CreateAggregator(kThreshold: 1, epsilon: 0.01);
// Pre-spend enough budget so the aggregation pass must suppress some buckets.
Assert.True(budget.TrySpend(0.0095));
// Create many CVE groups to exhaust the tiny budget // Create many CVE groups to exhaust the tiny budget
var facts = new List<TelemetryFact>(); var facts = new List<TelemetryFact>();
for (int i = 0; i < 100; i++) for (int i = 0; i < 100; i++)

View File

@@ -129,7 +129,7 @@ public sealed class PrivacyBudgetTracker : IPrivacyBudgetTracker
} }
} }
internal static double LaplacianNoise(double sensitivity, double epsilon, Random rng) public static double LaplacianNoise(double sensitivity, double epsilon, Random rng)
{ {
double u = rng.NextDouble() - 0.5; double u = rng.NextDouble() - 0.5;
return -(sensitivity / epsilon) * Math.Sign(u) * Math.Log(1 - 2 * Math.Abs(u)); return -(sensitivity / epsilon) * Math.Sign(u) * Math.Log(1 - 2 * Math.Abs(u));

View File

@@ -46,6 +46,11 @@ export const routes: Routes = [
(m) => m.DASHBOARD_ROUTES (m) => m.DASHBOARD_ROUTES
), ),
}, },
{
path: 'control-plane',
pathMatch: 'full',
redirectTo: '/',
},
// Domain 2: Release Control // Domain 2: Release Control
{ {
@@ -59,12 +64,12 @@ export const routes: Routes = [
), ),
}, },
// Domain 3: Security and Risk (formerly /security) // Domain 3: Security & Risk (formerly /security)
{ {
path: 'security-risk', path: 'security-risk',
title: 'Security and Risk', title: 'Security & Risk',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Security and Risk' }, data: { breadcrumb: 'Security & Risk' },
loadChildren: () => loadChildren: () =>
import('./routes/security-risk.routes').then( import('./routes/security-risk.routes').then(
(m) => m.SECURITY_RISK_ROUTES (m) => m.SECURITY_RISK_ROUTES
@@ -74,9 +79,9 @@ export const routes: Routes = [
// Domain 4: Evidence and Audit (formerly /evidence) // Domain 4: Evidence and Audit (formerly /evidence)
{ {
path: 'evidence-audit', path: 'evidence-audit',
title: 'Evidence and Audit', title: 'Evidence & Audit',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Evidence and Audit' }, data: { breadcrumb: 'Evidence & Audit' },
loadChildren: () => loadChildren: () =>
import('./routes/evidence-audit.routes').then( import('./routes/evidence-audit.routes').then(
(m) => m.EVIDENCE_AUDIT_ROUTES (m) => m.EVIDENCE_AUDIT_ROUTES
@@ -127,7 +132,7 @@ export const routes: Routes = [
{ {
path: 'environments', path: 'environments',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/release-control/environments', redirectTo: '/release-control/regions',
}, },
{ {
path: 'releases', path: 'releases',
@@ -140,7 +145,7 @@ export const routes: Routes = [
redirectTo: '/release-control/deployments', redirectTo: '/release-control/deployments',
}, },
// Security and Risk domain alias // Security & Risk domain alias
{ {
path: 'security', path: 'security',
pathMatch: 'full', pathMatch: 'full',
@@ -166,8 +171,13 @@ export const routes: Routes = [
// Platform Ops domain alias // Platform Ops domain alias
{ {
path: 'operations', path: 'operations',
pathMatch: 'full', title: 'Platform Ops',
redirectTo: '/platform-ops', canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Platform Ops' },
loadChildren: () =>
import('./routes/platform-ops.routes').then(
(m) => m.PLATFORM_OPS_ROUTES
),
}, },
// Administration domain alias — policy // Administration domain alias — policy
@@ -207,10 +217,8 @@ export const routes: Routes = [
// Administration domain alias — settings // Administration domain alias — settings
{ {
path: 'settings', path: 'settings',
title: 'Settings', pathMatch: 'full',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], redirectTo: '/administration',
loadChildren: () =>
import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
}, },
// ========================================================================== // ==========================================================================
@@ -242,6 +250,7 @@ export const routes: Routes = [
}, },
{ {
path: 'console/profile', path: 'console/profile',
title: 'Profile',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
loadComponent: () => loadComponent: () =>
import('./features/console/console-profile.component').then( import('./features/console/console-profile.component').then(

View File

@@ -182,7 +182,7 @@ export class SearchClient {
title: `job-${item.id.substring(0, 8)}`, title: `job-${item.id.substring(0, 8)}`,
subtitle: `${item.type} (${item.status})`, subtitle: `${item.type} (${item.status})`,
description: item.artifactRef, description: item.artifactRef,
route: `/orchestrator/jobs/${item.id}`, route: `/platform-ops/orchestrator/jobs/${item.id}`,
matchScore: 100, matchScore: 100,
})) }))
), ),

View File

@@ -127,7 +127,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>jobs', shortcut: '>jobs',
description: 'Navigate to job list', description: 'Navigate to job list',
icon: 'workflow', icon: 'workflow',
route: '/orchestrator/jobs', route: '/platform-ops/orchestrator/jobs',
keywords: ['jobs', 'orchestrator', 'list'], keywords: ['jobs', 'orchestrator', 'list'],
}, },
{ {

View File

@@ -155,19 +155,19 @@ export class MockAuthService implements AuthService {
// Orchestrator access methods (UI-ORCH-32-001) // Orchestrator access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean { canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_READ);
} }
canOperateOrchestrator(): boolean { canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
} }
canManageOrchestratorQuotas(): boolean { canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
} }
canInitiateBackfill(): boolean { canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
} }
// Policy Studio access methods (UI-POLICY-20-003) // Policy Studio access methods (UI-POLICY-20-003)
@@ -210,6 +210,17 @@ export class MockAuthService implements AuthService {
canAuditPolicies(): boolean { canAuditPolicies(): boolean {
return this.hasScope(StellaOpsScopes.POLICY_AUDIT); return this.hasScope(StellaOpsScopes.POLICY_AUDIT);
} }
private hasAdminPrivilege(): boolean {
const roleSet = new Set(
(this.user()?.roles ?? []).map((role) => role.trim().toLowerCase())
);
if (roleSet.has('admin')) {
return true;
}
return this.hasAnyScope([StellaOpsScopes.ADMIN, StellaOpsScopes.UI_ADMIN]);
}
} }
// Re-export scopes for convenience // Re-export scopes for convenience

View File

@@ -72,6 +72,44 @@ describe('AuthorityAuthAdapterService', () => {
service.logout(); service.logout();
expect(authorityAuth.logout).toHaveBeenCalled(); expect(authorityAuth.logout).toHaveBeenCalled();
}); });
it('grants orchestrator capabilities to admin role even without orch scopes', () => {
sessionStore.setSession(
buildSession({
tenantId: 'tenant-ops',
subject: 'admin-subject',
name: 'Admin User',
email: 'admin@example.com',
roles: ['admin'],
scopes: [StellaOpsScopes.UI_READ],
})
);
TestBed.flushEffects();
expect(service.canViewOrchestrator()).toBeTrue();
expect(service.canOperateOrchestrator()).toBeTrue();
expect(service.canManageOrchestratorQuotas()).toBeTrue();
expect(service.canInitiateBackfill()).toBeTrue();
});
it('keeps orchestrator restrictions for non-admin roles', () => {
sessionStore.setSession(
buildSession({
tenantId: 'tenant-ops',
subject: 'viewer-subject',
name: 'Viewer User',
email: 'viewer@example.com',
roles: ['viewer'],
scopes: [StellaOpsScopes.UI_READ],
})
);
TestBed.flushEffects();
expect(service.canViewOrchestrator()).toBeFalse();
expect(service.canOperateOrchestrator()).toBeFalse();
expect(service.canManageOrchestratorQuotas()).toBeFalse();
expect(service.canInitiateBackfill()).toBeFalse();
});
}); });
function buildSession(overrides: { function buildSession(overrides: {

View File

@@ -79,19 +79,19 @@ export class AuthorityAuthAdapterService implements AuthService {
} }
canViewOrchestrator(): boolean { canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_READ);
} }
canOperateOrchestrator(): boolean { canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
} }
canManageOrchestratorQuotas(): boolean { canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
} }
canInitiateBackfill(): boolean { canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL); return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
} }
canViewPolicies(): boolean { canViewPolicies(): boolean {
@@ -156,7 +156,7 @@ export class AuthorityAuthAdapterService implements AuthService {
const identity = session.identity; const identity = session.identity;
const id = identity.subject?.trim() || 'unknown-user'; const id = identity.subject?.trim() || 'unknown-user';
const name = identity.name?.trim() || id; const name = identity.name?.trim() || id;
const email = identity.email?.trim() || `${id}@unknown.local`; const email = identity.email?.trim() ?? '';
const roles = identity.roles ?? []; const roles = identity.roles ?? [];
return { return {
@@ -175,4 +175,15 @@ export class AuthorityAuthAdapterService implements AuthService {
KNOWN_SCOPE_SET.has(scope as StellaOpsScope) KNOWN_SCOPE_SET.has(scope as StellaOpsScope)
); );
} }
private hasAdminPrivilege(): boolean {
const roleSet = new Set(
(this.user()?.roles ?? []).map((role) => role.trim().toLowerCase())
);
if (roleSet.has('admin')) {
return true;
}
return this.hasAnyScope([StellaOpsScopes.ADMIN, StellaOpsScopes.UI_ADMIN]);
}
} }

View File

@@ -7,15 +7,13 @@ import { Routes } from '@angular/router';
export const ANALYTICS_ROUTES: Routes = [ export const ANALYTICS_ROUTES: Routes = [
{ {
path: '', path: '',
redirectTo: 'sbom-lake', redirectTo: '/security-risk/sbom-lake',
pathMatch: 'full', pathMatch: 'full',
data: { breadcrumb: 'Analytics' }, data: { breadcrumb: 'Analytics' },
}, },
{ {
path: 'sbom-lake', path: 'sbom-lake',
loadComponent: () => pathMatch: 'full',
import('./sbom-lake-page.component').then((m) => m.SbomLakePageComponent), redirectTo: '/security-risk/sbom-lake',
title: 'SBOM Lake',
data: { breadcrumb: 'SBOM Lake' },
}, },
]; ];

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core'; import { Component, ChangeDetectionStrategy, OnInit, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
@@ -6,6 +6,8 @@ import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client'; import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models'; import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
type DataIntegrityStatus = 'OK' | 'WARN' | 'FAIL';
/** /**
* ApprovalsInboxComponent - Approval decision cockpit. * ApprovalsInboxComponent - Approval decision cockpit.
* Wired to real APPROVAL_API for live data. * Wired to real APPROVAL_API for live data.
@@ -22,9 +24,23 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
Decide promotions with policy + reachability, backed by signed evidence. Decide promotions with policy + reachability, backed by signed evidence.
</p> </p>
</div> </div>
<a routerLink="/docs" class="btn btn--secondary">Docs &rarr;</a>
</header> </header>
@if (dataIntegrityBannerVisible()) {
<section class="data-integrity-banner" [class]="'data-integrity-banner data-integrity-banner--' + dataIntegrityStatus().toLowerCase()">
<div>
<p class="data-integrity-banner__title">
Data Integrity {{ dataIntegrityStatus() }}
</p>
<p class="data-integrity-banner__detail">{{ dataIntegritySummary() }}</p>
</div>
<div class="data-integrity-banner__actions">
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
<button type="button" (click)="dismissDataIntegrityBanner()">Dismiss</button>
</div>
</section>
}
<!-- Filters --> <!-- Filters -->
<div class="approvals__filters"> <div class="approvals__filters">
<div class="filter-group"> <div class="filter-group">
@@ -82,7 +98,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
@for (approval of approvals(); track approval.id) { @for (approval of approvals(); track approval.id) {
<div class="approval-card"> <div class="approval-card">
<div class="approval-card__header"> <div class="approval-card__header">
<a [routerLink]="['/releases', approval.releaseId]" class="approval-card__release"> <a [routerLink]="['/release-control/releases', approval.releaseId]" class="approval-card__release">
{{ approval.releaseName }} v{{ approval.releaseVersion }} {{ approval.releaseName }} v{{ approval.releaseVersion }}
</a> </a>
<span class="approval-card__flow">{{ approval.sourceEnvironment }} &rarr; {{ approval.targetEnvironment }}</span> <span class="approval-card__flow">{{ approval.sourceEnvironment }} &rarr; {{ approval.targetEnvironment }}</span>
@@ -112,7 +128,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
<button type="button" class="btn btn--success" (click)="approveRequest(approval.id)">Approve</button> <button type="button" class="btn btn--success" (click)="approveRequest(approval.id)">Approve</button>
<button type="button" class="btn btn--danger" (click)="rejectRequest(approval.id)">Reject</button> <button type="button" class="btn btn--danger" (click)="rejectRequest(approval.id)">Reject</button>
} }
<a [routerLink]="['/approvals', approval.id]" class="btn btn--secondary">View Details</a> <a [routerLink]="['/release-control/approvals', approval.id]" class="btn btn--secondary">View Details</a>
</div> </div>
</div> </div>
} @empty { } @empty {
@@ -154,6 +170,62 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
flex-wrap: wrap; flex-wrap: wrap;
} }
.data-integrity-banner {
margin-bottom: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 0.75rem;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.data-integrity-banner--warn {
background: var(--color-status-warning-bg);
border-color: var(--color-status-warning-text);
}
.data-integrity-banner--fail {
background: var(--color-status-error-bg);
border-color: var(--color-status-error-text);
}
.data-integrity-banner__title {
margin: 0;
font-size: 0.82rem;
font-weight: var(--font-weight-semibold);
}
.data-integrity-banner__detail {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.data-integrity-banner__actions {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.data-integrity-banner__actions a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
}
.data-integrity-banner__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: 0.25rem 0.55rem;
cursor: pointer;
font-size: 0.78rem;
}
.filter-group { .filter-group {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -443,6 +515,13 @@ export class ApprovalsInboxComponent implements OnInit {
private readonly api = inject(APPROVAL_API); private readonly api = inject(APPROVAL_API);
private readonly router = inject(Router); private readonly router = inject(Router);
readonly dataIntegrityStatus = signal<DataIntegrityStatus>('WARN');
readonly dataIntegritySummary = signal('NVD stale 3h | SBOM rescan FAILED | Runtime ingest lagging');
readonly dataIntegrityDismissed = signal(false);
readonly dataIntegrityBannerVisible = computed(
() => this.dataIntegrityStatus() !== 'OK' && !this.dataIntegrityDismissed()
);
readonly loading = signal(true); readonly loading = signal(true);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly approvals = signal<ApprovalRequest[]>([]); readonly approvals = signal<ApprovalRequest[]>([]);
@@ -467,9 +546,17 @@ export class ApprovalsInboxComponent implements OnInit {
searchQuery: string = ''; searchQuery: string = '';
ngOnInit(): void { ngOnInit(): void {
if (sessionStorage.getItem('approvals.data-integrity-banner-dismissed') === '1') {
this.dataIntegrityDismissed.set(true);
}
this.loadApprovals(); this.loadApprovals();
} }
dismissDataIntegrityBanner(): void {
this.dataIntegrityDismissed.set(true);
sessionStorage.setItem('approvals.data-integrity-banner-dismissed', '1');
}
onStatusChipClick(value: string): void { onStatusChipClick(value: string): void {
this.currentStatusFilter = value; this.currentStatusFilter = value;
this.loadApprovals(); this.loadApprovals();
@@ -492,12 +579,12 @@ export class ApprovalsInboxComponent implements OnInit {
approveRequest(id: string): void { approveRequest(id: string): void {
// Route to the detail page so the user can provide a decision reason // Route to the detail page so the user can provide a decision reason
// before the action fires. The detail page has the full Decision panel. // before the action fires. The detail page has the full Decision panel.
this.router.navigate(['/approvals', id]); this.router.navigate(['/release-control/approvals', id]);
} }
rejectRequest(id: string): void { rejectRequest(id: string): void {
// Route to the detail page so the user can provide a rejection reason. // Route to the detail page so the user can provide a rejection reason.
this.router.navigate(['/approvals', id]); this.router.navigate(['/release-control/approvals', id]);
} }
timeAgo(dateStr: string): string { timeAgo(dateStr: string): string {

View File

@@ -375,7 +375,9 @@ export class BundleVersionDetailComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.bundleId.set(this.route.snapshot.params['bundleId'] ?? ''); this.bundleId.set(this.route.snapshot.params['bundleId'] ?? '');
this.versionId.set(this.route.snapshot.params['version'] ?? ''); this.versionId.set(
this.route.snapshot.params['versionId'] ?? this.route.snapshot.params['version'] ?? ''
);
if (!this.bundleId() || !this.versionId()) { if (!this.bundleId() || !this.versionId()) {
this.loading.set(false); this.loading.set(false);

View File

@@ -30,6 +30,15 @@ export const BUNDLE_ROUTES: Routes = [
import('./bundle-builder.component').then((m) => m.BundleBuilderComponent), import('./bundle-builder.component').then((m) => m.BundleBuilderComponent),
}, },
// Bundle organizer for an existing bundle
{
path: ':bundleId/organizer',
title: 'Bundle Organizer',
data: { breadcrumb: 'Bundle Organizer' },
loadComponent: () =>
import('./bundle-builder.component').then((m) => m.BundleBuilderComponent),
},
// B4-02 — Bundle detail (with version history, config-contract, changelog) // B4-02 — Bundle detail (with version history, config-contract, changelog)
{ {
path: ':bundleId', path: ':bundleId',
@@ -41,10 +50,15 @@ export const BUNDLE_ROUTES: Routes = [
// B4-04/B4-06 — Bundle version detail (component selector, materialization) // B4-04/B4-06 — Bundle version detail (component selector, materialization)
{ {
path: ':bundleId/:version', path: ':bundleId/versions/:versionId',
title: 'Bundle Version', title: 'Bundle Version',
data: { breadcrumb: 'Bundle Version' }, data: { breadcrumb: 'Bundle Version' },
loadComponent: () => loadComponent: () =>
import('./bundle-version-detail.component').then((m) => m.BundleVersionDetailComponent), import('./bundle-version-detail.component').then((m) => m.BundleVersionDetailComponent),
}, },
{
path: ':bundleId/:version',
pathMatch: 'full',
redirectTo: ':bundleId/versions/:version',
},
]; ];

View File

@@ -1,9 +1,9 @@
<section class="console-profile"> <section class="console-profile">
<header class="console-profile__header"> <header class="console-profile__header">
<div> <div>
<h1>Console Session</h1> <h1>Profile</h1>
<p class="console-profile__subtitle"> <p class="console-profile__subtitle">
Session details sourced from Authority console endpoints. Identity and tenant profile details sourced from Authority.
</p> </p>
</div> </div>
<button <button
@@ -11,7 +11,7 @@
(click)="refresh()" (click)="refresh()"
[disabled]="loading()" [disabled]="loading()"
[attr.aria-busy]="loading()" [attr.aria-busy]="loading()"
> >
Refresh Refresh
</button> </button>
</header> </header>
@@ -24,34 +24,15 @@
@if (loading()) { @if (loading()) {
<div class="console-profile__loading"> <div class="console-profile__loading">
Loading console context Loading profile context...
</div> </div>
} }
@if (!loading()) { @if (!loading()) {
<section class="console-profile__card console-profile__callout">
<header>
<h2>Policy Studio roles & scopes</h2>
</header>
<ul>
<li><strong>Author</strong>: policy:read, policy:author, policy:simulate</li>
<li><strong>Reviewer</strong>: policy:read, policy:review, policy:simulate</li>
<li><strong>Approver</strong>: policy:read, policy:approve, policy:simulate</li>
<li><strong>Operator</strong>: policy:read, policy:operate, policy:simulate</li>
<li><strong>Audit</strong>: policy:read, policy:audit</li>
<li><strong>Admin</strong>: policy:author/review/approve/operate/audit/simulate/read (or admin)</li>
</ul>
<p class="console-profile__hint">
Use this list to verify your token covers the flows you need (editor, simulate, approvals, dashboard, audit exports).
</p>
<p class="console-profile__hint">
For e2e, load stub sessions from <code>testing/auth-fixtures.ts</code> (author/reviewer/approver/operator/audit) and seed <code>AuthSessionStore</code> before navigating.
</p>
</section>
@if (profile(); as profile) { @if (profile(); as profile) {
<section class="console-profile__card"> <section class="console-profile__card">
<header> <header>
<h2>User Profile</h2> <h2>Profile Details</h2>
<span class="tenant-chip"> <span class="tenant-chip">
Tenant Tenant
<strong>{{ profile.tenant }}</strong> <strong>{{ profile.tenant }}</strong>
@@ -59,28 +40,18 @@
</header> </header>
<dl> <dl>
<div> <div>
<dt>Display name</dt> <dt>Display Name</dt>
<dd>{{ profile.displayName || 'n/a' }}</dd> <dd>{{ profile.displayName || 'n/a' }}</dd>
</div> </div>
<div> <div>
<dt>Username</dt> <dt>Username</dt>
<dd>{{ profile.username || 'n/a' }}</dd> <dd>{{ profile.username || 'n/a' }}</dd>
</div> </div>
<div>
<dt>Subject</dt>
<dd>{{ profile.subjectId || 'n/a' }}</dd>
</div>
<div>
<dt>Session ID</dt>
<dd>{{ profile.sessionId || 'n/a' }}</dd>
</div>
<div> <div>
<dt>Roles</dt> <dt>Roles</dt>
<dd> <dd>
@if (profile.roles.length) { @if (profile.roles.length) {
<span> <span>{{ profile.roles.join(', ') }}</span>
{{ profile.roles.join(', ') }}
</span>
} @else { } @else {
n/a n/a
} }
@@ -90,9 +61,7 @@
<dt>Scopes</dt> <dt>Scopes</dt>
<dd> <dd>
@if (profile.scopes.length) { @if (profile.scopes.length) {
<span> <span>{{ profile.scopes.join(', ') }}</span>
{{ profile.scopes.join(', ') }}
</span>
} @else { } @else {
n/a n/a
} }
@@ -102,111 +71,38 @@
<dt>Audiences</dt> <dt>Audiences</dt>
<dd> <dd>
@if (profile.audiences.length) { @if (profile.audiences.length) {
<span> <span>{{ profile.audiences.join(', ') }}</span>
{{ profile.audiences.join(', ') }}
</span>
} @else { } @else {
n/a n/a
} }
</dd> </dd>
</div> </div>
<div> <div>
<dt>Authentication methods</dt> <dt>Authentication Methods</dt>
<dd> <dd>
@if (profile.authenticationMethods.length) { @if (profile.authenticationMethods.length) {
<span <span>{{ profile.authenticationMethods.join(', ') }}</span>
>
{{ profile.authenticationMethods.join(', ') }}
</span>
} @else { } @else {
n/a n/a
} }
</dd> </dd>
</div> </div>
<div> <div>
<dt>Issued at</dt> <dt>Issued At</dt>
<dd> <dd>{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}</dd>
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div> </div>
<div> <div>
<dt>Authentication time</dt> <dt>Authentication Time</dt>
<dd> <dd>{{ profile.authenticationTime ? (profile.authenticationTime | date : 'medium') : 'n/a' }}</dd>
{{
profile.authenticationTime
? (profile.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div> </div>
<div> <div>
<dt>Expires at</dt> <dt>Expires At</dt>
<dd> <dd>{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}</dd>
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div> </div>
</dl> </dl>
</section> </section>
} }
@if (tokenInfo(); as token) {
<section class="console-profile__card">
<header>
<h2>Access Token</h2>
<span
class="chip"
[class.chip--active]="token.active"
[class.chip--inactive]="!token.active"
>
{{ token.active ? 'Active' : 'Inactive' }}
</span>
</header>
<dl>
<div>
<dt>Token ID</dt>
<dd>{{ token.tokenId || 'n/a' }}</dd>
</div>
<div>
<dt>Client ID</dt>
<dd>{{ token.clientId || 'n/a' }}</dd>
</div>
<div>
<dt>Issued at</dt>
<dd>
{{ token.issuedAt ? (token.issuedAt | date : 'medium') : 'n/a' }}
</dd>
</div>
<div>
<dt>Authentication time</dt>
<dd>
{{
token.authenticationTime
? (token.authenticationTime | date : 'medium')
: 'n/a'
}}
</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>
{{ token.expiresAt ? (token.expiresAt | date : 'medium') : 'n/a' }}
</dd>
</div>
</dl>
@if (freshAuthState(); as fresh) {
<div
class="fresh-auth"
[class.fresh-auth--active]="fresh.active"
[class.fresh-auth--stale]="!fresh.active"
>
Fresh auth:
<strong>{{ fresh.active ? 'Active' : 'Stale' }}</strong>
@if (fresh.expiresAt) {
(expires {{ fresh.expiresAt | date : 'mediumTime' }})
}
</div>
}
</section>
}
@if (tenantCount() > 0) { @if (tenantCount() > 0) {
<section class="console-profile__card"> <section class="console-profile__card">
<header> <header>
@@ -215,20 +111,16 @@
</header> </header>
<ul class="tenant-list"> <ul class="tenant-list">
@for (tenant of tenants(); track tenant) { @for (tenant of tenants(); track tenant) {
<li <li [class.tenant-list__item--active]="tenant.id === selectedTenantId()">
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
>
<button type="button" (click)="selectTenant(tenant.id)"> <button type="button" (click)="selectTenant(tenant.id)">
<div class="tenant-list__heading"> <div class="tenant-list__heading">
<span class="tenant-name">{{ tenant.displayName }}</span> <span class="tenant-name">{{ tenant.displayName }}</span>
<span class="tenant-status">{{ tenant.status }}</span> <span class="tenant-status">{{ tenant.status }}</span>
</div> </div>
<div class="tenant-meta"> <div class="tenant-meta">
Isolation: {{ tenant.isolationMode }} · Default roles: Isolation: {{ tenant.isolationMode }} - Default roles:
@if (tenant.defaultRoles.length) { @if (tenant.defaultRoles.length) {
<span> <span>{{ tenant.defaultRoles.join(', ') }}</span>
{{ tenant.defaultRoles.join(', ') }}
</span>
} @else { } @else {
n/a n/a
} }
@@ -239,9 +131,10 @@
</ul> </ul>
</section> </section>
} }
@if (!hasProfile() && tenantCount() === 0) { @if (!hasProfile() && tenantCount() === 0) {
<p class="console-profile__empty"> <p class="console-profile__empty">
No console session data available for the current identity. No profile data is currently available for this identity.
</p> </p>
} }
} }

View File

@@ -85,12 +85,13 @@ describe('ConsoleProfileComponent', () => {
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain( expect(compiled.querySelector('h1')?.textContent).toContain(
'Console Session' 'Profile'
); );
expect(compiled.querySelector('.tenant-name')?.textContent).toContain( expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
'Tenant Default' 'Tenant Default'
); );
expect(compiled.querySelector('dd')?.textContent).toContain('Console User'); expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
expect(compiled.textContent).not.toContain('testing/auth-fixtures.ts');
expect(service.loadConsoleContext).not.toHaveBeenCalled(); expect(service.loadConsoleContext).not.toHaveBeenCalled();
}); });

View File

@@ -24,22 +24,11 @@ export class ConsoleProfileComponent implements OnInit {
readonly loading = this.store.loading; readonly loading = this.store.loading;
readonly error = this.store.error; readonly error = this.store.error;
readonly profile = this.store.profile; readonly profile = this.store.profile;
readonly tokenInfo = this.store.tokenInfo;
readonly tenants = this.store.tenants; readonly tenants = this.store.tenants;
readonly selectedTenantId = this.store.selectedTenantId; readonly selectedTenantId = this.store.selectedTenantId;
readonly hasProfile = computed(() => this.profile() !== null); readonly hasProfile = computed(() => this.profile() !== null);
readonly tenantCount = computed(() => this.tenants().length); readonly tenantCount = computed(() => this.tenants().length);
readonly freshAuthState = computed(() => {
const token = this.tokenInfo();
if (!token) {
return null;
}
return {
active: token.freshAuthActive,
expiresAt: token.freshAuthExpiresAt,
};
});
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
if (!this.store.hasContext()) { if (!this.store.hasContext()) {

View File

@@ -43,14 +43,14 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
<!-- Hero header: ALWAYS visible regardless of data state --> <!-- Hero header: ALWAYS visible regardless of data state -->
<header class="dashboard__header"> <header class="dashboard__header">
<div> <div>
<h1 class="dashboard__title">Control Plane</h1> <h1 class="dashboard__title">Dashboard</h1>
<p class="dashboard__subtitle"> <p class="dashboard__subtitle">
Release governance with evidence. Promote by digest. Explain every decision. Release governance with evidence. Promote by digest. Explain every decision.
</p> </p>
</div> </div>
<div class="dashboard__actions"> <div class="dashboard__actions">
<a routerLink="/releases" class="btn btn--secondary">Releases</a> <a routerLink="/release-control/releases" class="btn btn--secondary">Releases</a>
<a routerLink="/approvals" class="btn btn--primary">Approvals</a> <a routerLink="/release-control/approvals" class="btn btn--primary">Approvals</a>
</div> </div>
</header> </header>
@@ -122,7 +122,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
@for (approval of pendingApprovals(); track approval.id) { @for (approval of pendingApprovals(); track approval.id) {
<li class="card__item"> <li class="card__item">
<div class="card__item-header"> <div class="card__item-header">
<a [routerLink]="['/approvals', approval.id]" class="card__item-link"> <a [routerLink]="['/release-control/approvals', approval.id]" class="card__item-link">
{{ approval.releaseName }} {{ approval.releaseVersion }} {{ approval.releaseName }} {{ approval.releaseVersion }}
</a> </a>
<span class="card__urgency" [class]="'card__urgency--' + approval.urgency"> <span class="card__urgency" [class]="'card__urgency--' + approval.urgency">
@@ -135,7 +135,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
</ul> </ul>
} }
<div class="card__actions"> <div class="card__actions">
<a routerLink="/approvals" class="btn btn--small">View All</a> <a routerLink="/release-control/approvals" class="btn btn--small">View All</a>
</div> </div>
</section> </section>
@@ -187,7 +187,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
@for (release of recentReleases(); track release.id) { @for (release of recentReleases(); track release.id) {
<tr> <tr>
<td> <td>
<a [routerLink]="['/releases', release.id]">{{ release.name }}</a> <a [routerLink]="['/release-control/releases', release.id]">{{ release.name }}</a>
</td> </td>
<td>{{ release.version }}</td> <td>{{ release.version }}</td>
<td> <td>
@@ -199,7 +199,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
<td>{{ release.componentCount }}</td> <td>{{ release.componentCount }}</td>
<td> <td>
@if (release.status === 'ready' || release.status === 'promoting') { @if (release.status === 'ready' || release.status === 'promoting') {
<a [routerLink]="['/approvals']" [queryParams]="{ releaseId: release.id }" class="btn btn--small btn--primary"> <a [routerLink]="['/release-control/approvals']" [queryParams]="{ releaseId: release.id }" class="btn btn--small btn--primary">
Review Review
</a> </a>
} }

View File

@@ -12,7 +12,7 @@ import {
signal, signal,
computed, computed,
} from '@angular/core'; } from '@angular/core';
import { TitleCasePipe } from '@angular/common'; import { TitleCasePipe, UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
interface EnvironmentCard { interface EnvironmentCard {
@@ -23,10 +23,18 @@ interface EnvironmentCard {
sbomFreshness: 'fresh' | 'stale' | 'missing'; sbomFreshness: 'fresh' | 'stale' | 'missing';
critRCount: number; critRCount: number;
highRCount: number; highRCount: number;
birCoverage: string;
pendingApprovals: number; pendingApprovals: number;
lastDeployedAt: string; lastDeployedAt: string;
} }
interface NightlyOpsSignal {
id: string;
label: string;
status: 'ok' | 'warn' | 'fail';
detail: string;
}
interface MissionSummary { interface MissionSummary {
activePromotions: number; activePromotions: number;
blockedPromotions: number; blockedPromotions: number;
@@ -37,15 +45,15 @@ interface MissionSummary {
@Component({ @Component({
selector: 'app-dashboard-v3', selector: 'app-dashboard-v3',
standalone: true, standalone: true,
imports: [RouterLink, TitleCasePipe], imports: [RouterLink, TitleCasePipe, UpperCasePipe],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="mission-board"> <div class="mission-board">
<!-- Header: environment selector, date range filter, mission summary --> <!-- Header: environment selector, date range filter, mission summary -->
<header class="board-header"> <header class="board-header">
<div class="header-identity"> <div class="header-identity">
<h1 class="board-title">Mission Board</h1> <h1 class="board-title">Dashboard</h1>
<p class="board-subtitle">Release pipeline health across all regions and environments</p> <p class="board-subtitle">Mission board for release health across regions and environments</p>
</div> </div>
<div class="header-controls"> <div class="header-controls">
@@ -151,6 +159,10 @@ interface MissionSummary {
{{ env.highRCount }} {{ env.highRCount }}
</span> </span>
</div> </div>
<div class="metric">
<span class="metric-label">B/I/R</span>
<span class="metric-value">{{ env.birCoverage }}</span>
</div>
<div class="metric"> <div class="metric">
<span class="metric-label">Pending</span> <span class="metric-label">Pending</span>
<span class="metric-value" [class.warning]="env.pendingApprovals > 0"> <span class="metric-value" [class.warning]="env.pendingApprovals > 0">
@@ -181,38 +193,79 @@ interface MissionSummary {
</div> </div>
</section> </section>
<section class="risk-table" aria-label="Environments at risk">
<div class="section-header">
<h2 class="section-title">Environments at Risk</h2>
<a routerLink="/release-control/environments" class="section-link">Open environments</a>
</div>
@if (riskEnvironments().length === 0) {
<div class="env-grid-empty">
<p>All environments are healthy.</p>
</div>
} @else {
<div class="risk-table__container">
<table class="risk-table__table">
<thead>
<tr>
<th>Region/Env</th>
<th>Deploy Health</th>
<th>SBOM Status</th>
<th>Crit Reach</th>
<th>Hybrid B/I/R</th>
<th>Last SBOM</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (env of riskEnvironments(); track env.id) {
<tr>
<td>{{ env.region }} / {{ env.name }}</td>
<td>{{ env.deployStatus }}</td>
<td>{{ env.sbomFreshness }}</td>
<td [class.danger]="env.critRCount > 0">{{ env.critRCount }}</td>
<td>{{ env.birCoverage }}</td>
<td>{{ env.lastDeployedAt }}</td>
<td>
<a [routerLink]="['/release-control/environments', env.id]">Open</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<!-- Summary Cards Row --> <!-- Summary Cards Row -->
<div class="cards-row"> <div class="cards-row">
<!-- SBOM Snapshot Card --> <!-- SBOM Snapshot Card -->
<section class="domain-card" aria-label="SBOM snapshot"> <section class="domain-card" aria-label="SBOM snapshot">
<div class="card-header"> <div class="card-header">
<h2 class="card-title">SBOM Snapshot</h2> <h2 class="card-title">SBOM Findings Snapshot</h2>
<a routerLink="/security-risk/sbom" class="card-link">View SBOM</a> <a routerLink="/security-risk/sbom" class="card-link">View SBOM</a>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="snapshot-stat"> <div class="snapshot-stat">
<span class="stat-value">{{ sbomStats().totalComponents.toLocaleString() }}</span> <span class="stat-value">{{ sbomStats().criticalEnvCount }}</span>
<span class="stat-label">Total Components</span> <span class="stat-label">Critical Reachable Environments</span>
</div> </div>
<div class="snapshot-stat"> <div class="snapshot-stat">
<span class="stat-value danger">{{ sbomStats().criticalFindings }}</span> <span class="stat-value danger">{{ sbomStats().totalCritR }}</span>
<span class="stat-label">Critical Findings</span> <span class="stat-label">Total Critical Reachable Findings</span>
</div> </div>
<div class="snapshot-stat"> <div class="snapshot-stat">
<span class="stat-value" [class.warning]="sbomStats().staleCount > 0"> <span class="stat-value" [class.warning]="sbomStats().noIssueCount > 0">
{{ sbomStats().staleCount }} {{ sbomStats().noIssueCount }}
</span> </span>
<span class="stat-label">Stale SBOMs</span> <span class="stat-label">Environments with No Critical Findings</span>
</div>
<div class="snapshot-stat">
<span class="stat-value" [class.danger]="sbomStats().missingCount > 0">
{{ sbomStats().missingCount }}
</span>
<span class="stat-label">Missing SBOMs</span>
</div> </div>
@if (sbomStats().totalCritR === 0) {
<p class="card-note">No critical reachable issues detected in current scope.</p>
}
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a routerLink="/security-risk/findings" class="card-action">Explore findings</a> <a routerLink="/security-risk/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
<a routerLink="/release-control" class="card-action">Release Control</a> <a routerLink="/release-control" class="card-action">Release Control</a>
</div> </div>
</section> </section>
@@ -256,32 +309,25 @@ interface MissionSummary {
</div> </div>
</section> </section>
<!-- Data Integrity Summary Card --> <!-- Nightly Ops Signals Card -->
<section class="domain-card" aria-label="Data integrity summary"> <section class="domain-card" aria-label="Nightly ops signals">
<div class="card-header"> <div class="card-header">
<h2 class="card-title">Data Integrity</h2> <h2 class="card-title">Nightly Ops Signals</h2>
<a routerLink="/platform-ops/data-integrity" class="card-link">Platform Ops detail</a> <a routerLink="/platform-ops/data-integrity" class="card-link">Open Data Integrity</a>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="integrity-stat" [class.warning]="dataIntegrityStats().staleFeedCount > 0"> @for (signal of nightlyOpsSignals(); track signal.id) {
<span class="stat-value">{{ dataIntegrityStats().staleFeedCount }}</span> <div class="integrity-stat" [class.warning]="signal.status === 'warn'" [class.danger]="signal.status === 'fail'">
<span class="stat-label">Stale Feeds</span> <span class="stat-label">{{ signal.label }}</span>
</div> <span class="integrity-status" [class]="'integrity-status--' + signal.status">
<div class="integrity-stat" [class.danger]="dataIntegrityStats().failedScans > 0"> {{ signal.status | uppercase }}
<span class="stat-value">{{ dataIntegrityStats().failedScans }}</span> </span>
<span class="stat-label">Failed Scans</span> <span class="integrity-detail">{{ signal.detail }}</span>
</div> </div>
<div class="integrity-stat" [class.warning]="dataIntegrityStats().dlqDepth > 0"> }
<span class="stat-value">{{ dataIntegrityStats().dlqDepth }}</span>
<span class="stat-label">DLQ Depth</span>
</div>
<p class="card-note integrity-ownership-note">
Advisory source health is managed in
<a routerLink="/platform-ops/data-integrity">Platform Ops &gt; Data Integrity</a>.
</p>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a routerLink="/platform-ops/data-integrity" class="card-action">Ops diagnostics</a> <a routerLink="/platform-ops/data-integrity" class="card-action">Open Data Integrity</a>
</div> </div>
</section> </section>
</div> </div>
@@ -511,7 +557,7 @@ interface MissionSummary {
.env-metrics { .env-metrics {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -567,6 +613,47 @@ interface MissionSummary {
text-decoration: none; text-decoration: none;
} }
.risk-table {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.risk-table__container {
overflow-x: auto;
}
.risk-table__table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
.risk-table__table th,
.risk-table__table td {
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
padding: 0.55rem 0.45rem;
}
.risk-table__table th {
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.risk-table__table td a {
color: var(--color-brand-primary);
text-decoration: none;
}
.risk-table__table td.danger {
color: var(--color-status-error);
font-weight: var(--font-weight-semibold);
}
/* Cards Row */ /* Cards Row */
.cards-row { .cards-row {
display: grid; display: grid;
@@ -694,18 +781,45 @@ interface MissionSummary {
/* Data Integrity */ /* Data Integrity */
.integrity-stat { .integrity-stat {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto;
align-items: center; align-items: center;
padding: 0.25rem 0; gap: 0.25rem 0.75rem;
padding: 0.45rem 0;
border-bottom: 1px dashed var(--color-border-primary);
} }
.integrity-stat.warning .stat-value { color: var(--color-status-warning); } .integrity-stat:last-child {
.integrity-stat.danger .stat-value { color: var(--color-status-error); } border-bottom: 0;
}
.integrity-ownership-note a { .integrity-status {
color: var(--color-brand-primary); font-size: 0.7rem;
text-decoration: none; font-weight: var(--font-weight-semibold);
border-radius: var(--radius-full);
padding: 0.15rem 0.5rem;
letter-spacing: 0.04em;
}
.integrity-status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.integrity-status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.integrity-status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.integrity-detail {
grid-column: 1 / -1;
color: var(--color-text-muted);
font-size: 0.75rem;
} }
/* Status dot */ /* Status dot */
@@ -791,6 +905,7 @@ export class DashboardV3Component {
sbomFreshness: 'fresh', sbomFreshness: 'fresh',
critRCount: 0, critRCount: 0,
highRCount: 2, highRCount: 2,
birCoverage: '3/3',
pendingApprovals: 0, pendingApprovals: 0,
lastDeployedAt: '2h ago', lastDeployedAt: '2h ago',
}, },
@@ -802,6 +917,7 @@ export class DashboardV3Component {
sbomFreshness: 'stale', sbomFreshness: 'stale',
critRCount: 1, critRCount: 1,
highRCount: 5, highRCount: 5,
birCoverage: '2/3',
pendingApprovals: 2, pendingApprovals: 2,
lastDeployedAt: '6h ago', lastDeployedAt: '6h ago',
}, },
@@ -813,6 +929,7 @@ export class DashboardV3Component {
sbomFreshness: 'fresh', sbomFreshness: 'fresh',
critRCount: 3, critRCount: 3,
highRCount: 8, highRCount: 8,
birCoverage: '3/3',
pendingApprovals: 1, pendingApprovals: 1,
lastDeployedAt: '1d ago', lastDeployedAt: '1d ago',
}, },
@@ -824,6 +941,7 @@ export class DashboardV3Component {
sbomFreshness: 'fresh', sbomFreshness: 'fresh',
critRCount: 0, critRCount: 0,
highRCount: 1, highRCount: 1,
birCoverage: '3/3',
pendingApprovals: 0, pendingApprovals: 0,
lastDeployedAt: '3h ago', lastDeployedAt: '3h ago',
}, },
@@ -835,6 +953,7 @@ export class DashboardV3Component {
sbomFreshness: 'missing', sbomFreshness: 'missing',
critRCount: 5, critRCount: 5,
highRCount: 12, highRCount: 12,
birCoverage: '1/3',
pendingApprovals: 3, pendingApprovals: 3,
lastDeployedAt: '3d ago', lastDeployedAt: '3d ago',
}, },
@@ -848,12 +967,26 @@ export class DashboardV3Component {
); );
}); });
// Placeholder SBOM stats readonly riskEnvironments = computed(() =>
readonly sbomStats = signal({ this.filteredEnvironments().filter((env) => {
totalComponents: 24_850, const isSbomRisk = env.sbomFreshness === 'stale' || env.sbomFreshness === 'missing';
criticalFindings: 8, const isReachabilityRisk = env.critRCount > 0;
staleCount: 2, const isDeployRisk = env.deployStatus === 'degraded' || env.deployStatus === 'blocked';
missingCount: 1, return isSbomRisk || isReachabilityRisk || isDeployRisk;
})
);
readonly sbomStats = computed(() => {
const envs = this.filteredEnvironments();
const criticalEnvCount = envs.filter((env) => env.critRCount > 0).length;
const totalCritR = envs.reduce((total, env) => total + env.critRCount, 0);
const noIssueCount = envs.filter((env) => env.critRCount === 0).length;
return {
criticalEnvCount,
totalCritR,
noIssueCount,
};
}); });
// Placeholder reachability stats // Placeholder reachability stats
@@ -863,12 +996,32 @@ export class DashboardV3Component {
rCoverage: 61, rCoverage: 61,
}); });
// Placeholder data integrity stats readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([
readonly dataIntegrityStats = signal({ {
staleFeedCount: 1, id: 'sbom-rescan',
failedScans: 0, label: 'SBOM rescan',
dlqDepth: 3, status: 'ok',
}); detail: 'Nightly SBOM rescan completed in 14m.',
},
{
id: 'nvd-feed',
label: 'NVD feed',
status: 'warn',
detail: 'Feed freshness is stale (3h 12m).',
},
{
id: 'integration-health',
label: 'Integration health',
status: 'ok',
detail: 'Key connectors are operational.',
},
{
id: 'dlq',
label: 'DLQ',
status: 'warn',
detail: '3 pending items require replay.',
},
]);
onRegionChange(event: Event): void { onRegionChange(event: Event): void {
const select = event.target as HTMLSelectElement; const select = event.target as HTMLSelectElement;

View File

@@ -27,7 +27,7 @@ import {
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" (click)="exportData()"> <button class="btn btn-secondary" (click)="exportData()">
<span class="icon">download</span> <span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg></span>
Export CSV Export CSV
</button> </button>
<button <button
@@ -35,11 +35,11 @@ import {
(click)="replayAllRetryable()" (click)="replayAllRetryable()"
[disabled]="!stats()?.stats?.retryable" [disabled]="!stats()?.stats?.retryable"
> >
<span class="icon">refresh</span> <span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
Replay All Retryable ({{ stats()?.stats?.retryable || 0 }}) Replay All Retryable ({{ stats()?.stats?.retryable || 0 }})
</button> </button>
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()"> <button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
<span class="icon" [class.spinning]="refreshing()">refresh</span> <span class="icon" [class.spinning]="refreshing()" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
</button> </button>
</div> </div>
</header> </header>

View File

@@ -75,7 +75,7 @@ import {
} }
@if (entry()?.state === 'replayed') { @if (entry()?.state === 'replayed') {
<span class="status-detail"> <span class="status-detail">
New Job: <a [href]="'/orchestrator/jobs/' + entry()?.replayedJobId">{{ entry()?.replayedJobId }}</a> New Job: <a [href]="'/platform-ops/orchestrator/jobs/' + entry()?.replayedJobId">{{ entry()?.replayedJobId }}</a>
</span> </span>
} }
</div> </div>
@@ -654,7 +654,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy {
next: (response) => { next: (response) => {
this.hideReplayDialog(); this.hideReplayDialog();
if (response.success && response.newJobId) { if (response.success && response.newJobId) {
window.location.href = `/orchestrator/jobs/${response.newJobId}`; window.location.href = `/platform-ops/orchestrator/jobs/${response.newJobId}`;
} }
}, },
}); });

View File

@@ -9,47 +9,52 @@ import { Routes } from '@angular/router';
export const evidenceExportRoutes: Routes = [ export const evidenceExportRoutes: Routes = [
{ {
path: '', path: '',
redirectTo: 'bundles', redirectTo: 'export',
pathMatch: 'full', pathMatch: 'full',
}, },
{ {
path: 'bundles', path: 'bundles',
title: 'Evidence Bundles',
data: { breadcrumb: 'Evidence Bundles' },
loadComponent: () => loadComponent: () =>
import('./evidence-bundles.component').then( import('./evidence-bundles.component').then(
(m) => m.EvidenceBundlesComponent (m) => m.EvidenceBundlesComponent
), ),
data: { title: 'Evidence Bundles' },
}, },
{ {
path: 'export', path: 'export',
title: 'Export Center',
data: { breadcrumb: 'Export Center' },
loadComponent: () => loadComponent: () =>
import('./export-center.component').then( import('./export-center.component').then(
(m) => m.ExportCenterComponent (m) => m.ExportCenterComponent
), ),
data: { title: 'Export Center' },
}, },
{ {
path: 'replay', path: 'replay',
title: 'Verdict Replay',
data: { breadcrumb: 'Verdict Replay' },
loadComponent: () => loadComponent: () =>
import('./replay-controls.component').then( import('./replay-controls.component').then(
(m) => m.ReplayControlsComponent (m) => m.ReplayControlsComponent
), ),
data: { title: 'Verdict Replay' },
}, },
{ {
path: 'proof-chains', path: 'proof-chains',
title: 'Proof Chains',
data: { breadcrumb: 'Proof Chains' },
loadComponent: () => loadComponent: () =>
import('../proof-chain/proof-chain.component').then( import('../proof-chain/proof-chain.component').then(
(m) => m.ProofChainComponent (m) => m.ProofChainComponent
), ),
data: { title: 'Proof Chains' },
}, },
{ {
path: 'provenance', path: 'provenance',
title: 'Evidence Provenance',
data: { breadcrumb: 'Evidence Provenance' },
loadComponent: () => loadComponent: () =>
import('./provenance-visualization.component').then( import('./provenance-visualization.component').then(
(m) => m.ProvenanceVisualizationComponent (m) => m.ProvenanceVisualizationComponent
), ),
data: { title: 'Evidence Provenance' },
}, },
]; ];

View File

@@ -5,7 +5,7 @@
<p class="dashboard-subtitle">Manage policy exceptions with auditable workflows.</p> <p class="dashboard-subtitle">Manage policy exceptions with auditable workflows.</p>
</div> </div>
<div class="dashboard-actions"> <div class="dashboard-actions">
<a class="btn-secondary" routerLink="/exceptions/approvals">Approval Queue</a> <a class="btn-secondary" routerLink="approvals">Approval Queue</a>
<button class="btn-secondary" (click)="refresh()">Refresh</button> <button class="btn-secondary" (click)="refresh()">Refresh</button>
<button class="btn-primary" (click)="openWizard()">+ New Exception</button> <button class="btn-primary" (click)="openWizard()">+ New Exception</button>
</div> </div>

View File

@@ -231,7 +231,9 @@ describe('ExceptionDashboardComponent', () => {
component.selectException(viewException); component.selectException(viewException);
expect(component.selectedExceptionId()).toBe('exc-001'); expect(component.selectedExceptionId()).toBe('exc-001');
expect(mockRouter.navigate).toHaveBeenCalledWith(['/exceptions', 'exc-001']); expect(mockRouter.navigate).toHaveBeenCalledWith(['exc-001'], {
relativeTo: jasmine.anything(),
});
}); });
it('cleans up subscriptions on destroy', () => { it('cleans up subscriptions on destroy', () => {

View File

@@ -90,7 +90,7 @@ export class ExceptionDashboardComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.refresh(); this.refresh();
this.routeSubscription = this.route.paramMap.subscribe((params) => { this.routeSubscription = this.route.paramMap.subscribe((params) => {
const exceptionId = params.get('exceptionId'); const exceptionId = params.get('exceptionId') ?? params.get('id');
this.selectedExceptionId.set(exceptionId); this.selectedExceptionId.set(exceptionId);
}); });
this.subscribeToEvents(); this.subscribeToEvents();
@@ -140,12 +140,12 @@ export class ExceptionDashboardComponent implements OnInit, OnDestroy {
selectException(exception: Exception): void { selectException(exception: Exception): void {
this.selectedExceptionId.set(exception.id); this.selectedExceptionId.set(exception.id);
this.router.navigate(['/exceptions', exception.id]); this.router.navigate([exception.id], { relativeTo: this.route });
} }
closeDetail(): void { closeDetail(): void {
this.selectedExceptionId.set(null); this.selectedExceptionId.set(null);
this.router.navigate(['/exceptions']); this.router.navigate(['../'], { relativeTo: this.route });
} }
async handleTransition(payload: { exception: Exception; to: ExceptionStatus }): Promise<void> { async handleTransition(payload: { exception: Exception; to: ExceptionStatus }): Promise<void> {

View File

@@ -57,7 +57,19 @@ import { IntegrationType } from './integration.models';
<section class="hub-summary"> <section class="hub-summary">
<h2>Recent Activity</h2> <h2>Recent Activity</h2>
<p class="placeholder">Integration activity timeline coming soon...</p> <div class="coming-soon" role="status" aria-live="polite">
<div class="coming-soon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</div>
<div>
<p class="coming-soon__title">Activity stream is coming soon</p>
<p class="coming-soon__description">
Connector timeline events will appear here once ingestion telemetry is fully enabled.
</p>
</div>
</div>
</section> </section>
</div> </div>
`, `,
@@ -160,9 +172,38 @@ import { IntegrationType } from './integration.models';
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.placeholder { .coming-soon {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.coming-soon__icon {
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
color: var(--color-brand-primary);
background: var(--color-brand-soft);
flex-shrink: 0;
}
.coming-soon__title {
margin: 0;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
.coming-soon__description {
margin: 0.25rem 0 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-style: italic; font-size: 0.875rem;
} }
`] `]
}) })

View File

@@ -71,6 +71,11 @@ export const integrationHubRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./integration-list.component').then((m) => m.IntegrationListComponent), import('./integration-list.component').then((m) => m.IntegrationListComponent),
}, },
{
path: 'ci-cd',
pathMatch: 'full',
redirectTo: 'ci',
},
// Category: Targets / Runtimes // Category: Targets / Runtimes
{ {
@@ -85,6 +90,11 @@ export const integrationHubRoutes: Routes = [
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'hosts', redirectTo: 'hosts',
}, },
{
path: 'targets',
pathMatch: 'full',
redirectTo: 'hosts',
},
// Category: Secrets Managers // Category: Secrets Managers
{ {

View File

@@ -67,7 +67,7 @@ import {
@for (integration of integrations; track integration.integrationId) { @for (integration of integrations; track integration.integrationId) {
<tr> <tr>
<td> <td>
<a [routerLink]="['/settings/integrations', integration.integrationId]">{{ integration.name }}</a> <a [routerLink]="['/integrations', integration.integrationId]">{{ integration.name }}</a>
</td> </td>
<td>{{ getProviderName(integration.provider) }}</td> <td>{{ getProviderName(integration.provider) }}</td>
<td> <td>
@@ -85,7 +85,7 @@ import {
<button (click)="editIntegration(integration)" title="Edit"><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"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button> <button (click)="editIntegration(integration)" title="Edit"><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"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
<button (click)="testConnection(integration)" title="Test Connection"><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"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button> <button (click)="testConnection(integration)" title="Test Connection"><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"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
<button (click)="checkHealth(integration)" title="Check Health"><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"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button> <button (click)="checkHealth(integration)" title="Check Health"><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"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
<a [routerLink]="['/settings/integrations', integration.integrationId]" title="View Details"><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"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a> <a [routerLink]="['/integrations', integration.integrationId]" title="View Details"><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"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
</td> </td>
</tr> </tr>
} }
@@ -331,12 +331,12 @@ export class IntegrationListComponent implements OnInit {
} }
editIntegration(integration: Integration): void { editIntegration(integration: Integration): void {
void this.router.navigate(['/settings/integrations', integration.integrationId], { queryParams: { edit: true } }); void this.router.navigate(['/integrations', integration.integrationId], { queryParams: { edit: true } });
} }
addIntegration(): void { addIntegration(): void {
void this.router.navigate( void this.router.navigate(
['/settings/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)] ['/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
); );
} }

View File

@@ -23,14 +23,14 @@ import { AUTH_SERVICE, AuthService } from '../../core/auth';
</header> </header>
<nav class="orch-dashboard__nav"> <nav class="orch-dashboard__nav">
<a routerLink="/orchestrator/jobs" class="orch-dashboard__card"> <a routerLink="/platform-ops/orchestrator/jobs" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/><line x1="8" y1="18" x2="12" y2="18"/></svg></span> <span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/><line x1="8" y1="18" x2="12" y2="18"/></svg></span>
<span class="orch-dashboard__card-title">Jobs</span> <span class="orch-dashboard__card-title">Jobs</span>
<span class="orch-dashboard__card-desc">View job status and history</span> <span class="orch-dashboard__card-desc">View job status and history</span>
</a> </a>
@if (authService.canOperateOrchestrator()) { @if (authService.canOperateOrchestrator()) {
<a routerLink="/orchestrator/quotas" class="orch-dashboard__card"> <a routerLink="/platform-ops/orchestrator/quotas" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span> <span class="orch-dashboard__card-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" 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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="orch-dashboard__card-title">Quotas</span> <span class="orch-dashboard__card-title">Quotas</span>
<span class="orch-dashboard__card-desc">Manage resource quotas</span> <span class="orch-dashboard__card-desc">Manage resource quotas</span>

View File

@@ -14,7 +14,7 @@ import { RouterLink } from '@angular/router';
template: ` template: `
<div class="orch-job-detail"> <div class="orch-job-detail">
<header class="orch-job-detail__header"> <header class="orch-job-detail__header">
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a> <a routerLink="/platform-ops/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1> <h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p> <p class="orch-job-detail__id">ID: {{ jobId }}</p>
<div class="orch-job-detail__actions"> <div class="orch-job-detail__actions">

View File

@@ -150,7 +150,7 @@ interface OrchestratorJob {
@if (job.parentJobId) { @if (job.parentJobId) {
<div class="job-parent"> <div class="job-parent">
<span class="label">Parent Job:</span> <span class="label">Parent Job:</span>
<a [routerLink]="['/orchestrator/jobs', job.parentJobId]"> <a [routerLink]="['/platform-ops/orchestrator/jobs', job.parentJobId]">
{{ job.parentJobId }} {{ job.parentJobId }}
</a> </a>
</div> </div>
@@ -161,7 +161,7 @@ interface OrchestratorJob {
<span class="label">Child Jobs ({{ job.childJobIds.length }}):</span> <span class="label">Child Jobs ({{ job.childJobIds.length }}):</span>
<div class="children-list"> <div class="children-list">
@for (childId of job.childJobIds; track childId) { @for (childId of job.childJobIds; track childId) {
<a [routerLink]="['/orchestrator/jobs', childId]">{{ childId }}</a> <a [routerLink]="['/platform-ops/orchestrator/jobs', childId]">{{ childId }}</a>
} }
</div> </div>
</div> </div>
@@ -190,7 +190,7 @@ interface OrchestratorJob {
} }
<a <a
class="btn btn-secondary" class="btn btn-secondary"
[routerLink]="['/orchestrator/jobs', job.id]" [routerLink]="['/platform-ops/orchestrator/jobs', job.id]"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
View Details View Details
@@ -198,7 +198,7 @@ interface OrchestratorJob {
@if (job.childJobIds.length > 0) { @if (job.childJobIds.length > 0) {
<a <a
class="btn btn-secondary" class="btn btn-secondary"
[routerLink]="['/orchestrator/jobs', job.id, 'dag']" [routerLink]="['/platform-ops/orchestrator/jobs', job.id, 'dag']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
View DAG View DAG

View File

@@ -1,254 +1,371 @@
/** /**
* Data Integrity Overview * Data Integrity Overview
* Sprint: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-02) * Sprint: SPRINT_20260219_023_FE_operations_data_integrity_section
* *
* Platform Ops-owned Data Integrity surface. * Platform Ops source of truth for feed freshness, scan health,
* Provides overview and drilldown links for: * reachability ingest, integration connectivity, and DLQ impact.
* - Nightly data-quality reports
* - Feed freshness status
* - Scan pipeline health
* - Reachability ingest health
* - Integration connectivity status
* - Dead-Letter Queue management
* - SLO burn-rate monitoring
*
* Security Data ownership split:
* - THIS PAGE: connectivity health, feed freshness, pipeline operational state
* - Security & Risk: gating impact and decision context (consumer only)
*/ */
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { UpperCasePipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
interface IntegritySection { type SignalStatus = 'ok' | 'warn' | 'fail';
interface TrustSignal {
id: string;
label: string;
status: SignalStatus;
detail: string;
route: string;
}
interface ImpactedDecision {
id: string;
name: string;
reason: string;
}
interface FailureItem {
id: string; id: string;
title: string; title: string;
description: string; detail: string;
route: string; route: string;
ownerNote?: string;
} }
@Component({ @Component({
selector: 'app-data-integrity-overview', selector: 'app-data-integrity-overview',
standalone: true, standalone: true,
imports: [RouterLink], imports: [RouterLink, UpperCasePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="di-overview"> <div class="data-integrity-overview">
<header class="di-overview__header"> <header class="header">
<h1 class="di-overview__title">Data Integrity</h1> <h1>Data Integrity</h1>
<p class="di-overview__subtitle"> <p>
Platform Ops source of truth for feed freshness, pipeline health, and data quality SLOs. Release-confidence view for feed freshness, scan pipeline health, reachability ingest,
</p> integrations, and DLQ safety.
<p class="di-overview__ownership-note">
<strong>Ownership:</strong> Platform Ops manages connectivity and freshness.
Gating impact is consumed by
<a routerLink="/security-risk/advisory-sources">Security &amp; Risk</a>.
</p> </p>
</header> </header>
<div class="di-overview__grid"> <section class="filters" aria-label="Scope filters">
@for (section of sections; track section.id) { <label>
<a class="di-card" [routerLink]="section.route"> Region
<div class="di-card__body"> <select [value]="region()" (change)="onRegionChange($event)">
<h2 class="di-card__title">{{ section.title }}</h2> <option value="all">All regions</option>
<p class="di-card__description">{{ section.description }}</p> <option value="eu-west">EU West</option>
@if (section.ownerNote) { <option value="us-east">US East</option>
<span class="di-card__owner">{{ section.ownerNote }}</span> <option value="ap-south">AP South</option>
} </select>
</div> </label>
</a> <label>
} Time window
<select [value]="timeWindow()" (change)="onTimeWindowChange($event)">
<option value="24h">Last 24h</option>
<option value="7d">Last 7d</option>
<option value="30d">Last 30d</option>
</select>
</label>
</section>
<section class="panel" aria-label="Data trust score">
<h2>Data Trust Score</h2>
<div class="trust-grid">
@for (item of trustSignals; track item.id) {
<a class="trust-item" [routerLink]="item.route">
<span class="trust-item__label">{{ item.label }}</span>
<span class="trust-item__status" [class]="'trust-item__status trust-item__status--' + item.status">
{{ item.status | uppercase }}
</span>
<span class="trust-item__detail">{{ item.detail }}</span>
</a>
}
</div>
</section>
<div class="grid-two">
<section class="panel" aria-label="Impacted decisions">
<h2>Impacted Decisions</h2>
<p class="panel-subtitle">{{ impactedDecisions.length }} approvals currently impacted by data quality signals.</p>
<ul class="list">
@for (decision of impactedDecisions; track decision.id) {
<li>
<a [routerLink]="'/release-control/approvals'" [queryParams]="{ releaseId: decision.id }">{{ decision.name }}</a>
<span>{{ decision.reason }}</span>
</li>
} @empty {
<li>No impacted approvals in selected scope.</li>
}
</ul>
</section>
<section class="panel" aria-label="Top failures">
<h2>Top Failures</h2>
<ul class="list">
@for (item of topFailures; track item.id) {
<li>
<a [routerLink]="item.route">{{ item.title }}</a>
<span>{{ item.detail }}</span>
</li>
}
</ul>
</section>
</div> </div>
<section class="di-overview__related"> <section class="panel" aria-label="Drilldowns">
<h2 class="di-overview__section-heading">Related Operational Controls</h2> <h2>Drilldowns</h2>
<ul class="di-overview__links"> <div class="drilldowns">
<li><a routerLink="/platform-ops/feeds">Feeds &amp; Mirrors</a> — feed source management</li> <a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<li><a routerLink="/platform-ops/dead-letter">Dead-Letter Queue</a> — failed message replay</li> <a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<li><a routerLink="/platform-ops/slo">SLO Monitoring</a> — burn-rate and error budgets</li> <a routerLink="/platform-ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
<li><a routerLink="/platform-ops/doctor">Diagnostics</a> — registry connectivity checks</li> <a routerLink="/platform-ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
</ul> <a routerLink="/platform-ops/data-integrity/integration-connectivity">Integration Connectivity</a>
<h2 class="di-overview__section-heading">Security &amp; Risk Consumers</h2> <a routerLink="/platform-ops/data-integrity/dlq">DLQ and Replays</a>
<ul class="di-overview__links"> <a routerLink="/platform-ops/data-integrity/slos">Data Quality SLOs</a>
<li> </div>
<a routerLink="/security-risk/advisory-sources">Advisory Sources</a> — gating impact from fresh data
</li>
</ul>
</section> </section>
</div> </div>
`, `,
styles: [` styles: [`
.di-overview { .data-integrity-overview {
padding: 1.5rem; padding: 1.5rem;
max-width: 1000px; max-width: 1200px;
}
.di-overview__header {
margin-bottom: 2rem;
}
.di-overview__title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.di-overview__subtitle {
color: var(--color-text-secondary, #666);
margin: 0 0 0.75rem;
}
.di-overview__ownership-note {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-sm, 4px);
padding: 0.5rem 0.75rem;
margin: 0;
}
.di-overview__ownership-note a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
.di-overview__ownership-note a:hover {
text-decoration: underline;
}
.di-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.di-card {
display: block;
padding: 1.25rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.di-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.di-card__title {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.di-card__description {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
margin: 0 0 0.25rem;
}
.di-card__owner {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.di-overview__section-heading {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
}
.di-overview__related {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.di-overview__links { .header h1 {
margin: 0;
font-size: 1.5rem;
}
.header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.filters label {
display: grid;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.filters select {
min-width: 150px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.45rem 0.6rem;
background: var(--color-surface-primary);
color: var(--color-text-primary);
text-transform: none;
letter-spacing: normal;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.panel-subtitle {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.trust-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.7rem;
}
.trust-item {
display: grid;
gap: 0.2rem;
text-decoration: none;
color: inherit;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.65rem;
background: var(--color-surface-secondary);
}
.trust-item__label {
font-size: 0.78rem;
font-weight: var(--font-weight-semibold);
}
.trust-item__status {
width: fit-content;
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
}
.trust-item__status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.trust-item__status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.trust-item__status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.trust-item__detail {
font-size: 0.74rem;
color: var(--color-text-muted);
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: grid;
flex-direction: column; gap: 0.55rem;
gap: 0.5rem;
} }
.di-overview__links li { .list li {
font-size: 0.875rem; display: grid;
color: var(--color-text-secondary, #666); gap: 0.1rem;
font-size: 0.84rem;
} }
.di-overview__links a { .list a,
color: var(--color-brand-primary, #4f46e5); .drilldowns a {
color: var(--color-brand-primary);
text-decoration: none; text-decoration: none;
} }
.di-overview__links a:hover { .list span {
text-decoration: underline; color: var(--color-text-secondary);
font-size: 0.76rem;
}
.drilldowns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 0.5rem;
}
@media (max-width: 900px) {
.grid-two {
grid-template-columns: 1fr;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DataIntegrityOverviewComponent { export class DataIntegrityOverviewComponent {
readonly sections: IntegritySection[] = [ readonly region = signal('all');
readonly timeWindow = signal('24h');
readonly trustSignals: TrustSignal[] = [
{ {
id: 'nightly-report', id: 'feeds',
title: 'Nightly Data Quality Report', label: 'Feeds Freshness',
description: 'Aggregated quality metrics, freshness scores, and anomaly flags from last run.', status: 'warn',
route: '/platform-ops/health', detail: 'NVD feed stale by 3h 12m',
ownerNote: 'Platform Ops — source of truth', route: '/platform-ops/data-integrity/feeds-freshness',
}, },
{ {
id: 'feeds-freshness', id: 'scan',
title: 'Feeds Freshness', label: 'SBOM Pipeline',
description: 'Advisory feed source staleness, last-sync timestamps, and delta alerts.', status: 'ok',
route: '/platform-ops/feeds', detail: 'Nightly rescan completed',
ownerNote: 'Platform Ops — connectivity ownership', route: '/platform-ops/data-integrity/scan-pipeline',
}, },
{ {
id: 'scan-pipeline', id: 'reachability',
title: 'Scan Pipeline Health', label: 'Reachability Ingest',
description: 'Scanner queue depth, throughput rates, and error rates.', status: 'warn',
route: '/platform-ops/health', detail: 'Runtime backlog elevated',
ownerNote: 'Platform Ops — pipeline operations', route: '/platform-ops/data-integrity/reachability-ingest',
}, },
{ {
id: 'reachability-ingest', id: 'integrations',
title: 'Reachability Ingest Health', label: 'Integrations',
description: 'Reachability graph ingestion status and stale-graph detection.', status: 'ok',
route: '/platform-ops/health', detail: 'Core connectors are reachable',
ownerNote: 'Platform Ops — ingest operations', route: '/platform-ops/data-integrity/integration-connectivity',
}, },
{ {
id: 'integration-connectivity', id: 'dlq',
title: 'Integration Connectivity', label: 'DLQ',
description: 'Live connectivity status for all registered integration connectors.', status: 'warn',
route: '/integrations', detail: '3 items pending replay',
ownerNote: 'Integrations — connector ownership', route: '/platform-ops/data-integrity/dlq',
},
{
id: 'dlq-replays',
title: 'DLQ & Replays',
description: 'Dead-letter queue contents, replay operations, and failure investigation.',
route: '/platform-ops/dead-letter',
ownerNote: 'Platform Ops — operations',
},
{
id: 'slo-burn',
title: 'Data Quality SLOs',
description: 'Error-budget burn rates and latency targets for data pipeline SLOs.',
route: '/platform-ops/slo',
ownerNote: 'Platform Ops — SLO ownership',
}, },
]; ];
}
readonly impactedDecisions: ImpactedDecision[] = [
{
id: 'rel-hotfix-124',
name: 'Hotfix 1.2.4',
reason: 'Waiting for feed freshness to recover',
},
{
id: 'rel-platform-130-rc1',
name: 'Platform Release 1.3.0-rc1',
reason: 'Runtime ingest coverage below threshold',
},
];
readonly topFailures: FailureItem[] = [
{
id: 'failure-nvd',
title: 'NVD sync lag',
detail: 'Feed lag exceeds SLA for release-critical path.',
route: '/platform-ops/data-integrity/feeds-freshness',
},
{
id: 'failure-runtime',
title: 'Runtime ingest backlog',
detail: 'Runtime source queue depth is increasing.',
route: '/platform-ops/data-integrity/reachability-ingest',
},
{
id: 'failure-dlq',
title: 'DLQ replay queue',
detail: 'Pending replay items block confidence for approvals.',
route: '/platform-ops/data-integrity/dlq',
},
];
onRegionChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.region.set(select.value);
}
onTimeWindowChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.timeWindow.set(select.value);
}
}

View File

@@ -0,0 +1,84 @@
import { Routes } from '@angular/router';
export const dataIntegrityRoutes: Routes = [
{
path: '',
title: 'Data Integrity - StellaOps',
data: { breadcrumb: 'Data Integrity' },
loadComponent: () =>
import('../data-integrity-overview.component').then(
(m) => m.DataIntegrityOverviewComponent
),
},
{
path: 'nightly-ops',
title: 'Nightly Ops Report - StellaOps',
data: { breadcrumb: 'Nightly Ops Report' },
loadComponent: () =>
import('./nightly-ops-report-page.component').then(
(m) => m.NightlyOpsReportPageComponent
),
},
{
path: 'nightly-ops/:runId',
title: 'Job Run Detail - StellaOps',
loadComponent: () =>
import('./job-run-detail-page.component').then(
(m) => m.DataIntegrityJobRunDetailPageComponent
),
},
{
path: 'feeds-freshness',
title: 'Feeds Freshness - StellaOps',
data: { breadcrumb: 'Feeds Freshness' },
loadComponent: () =>
import('./feeds-freshness-page.component').then(
(m) => m.FeedsFreshnessPageComponent
),
},
{
path: 'scan-pipeline',
title: 'Scan Pipeline Health - StellaOps',
data: { breadcrumb: 'Scan Pipeline Health' },
loadComponent: () =>
import('./scan-pipeline-health-page.component').then(
(m) => m.ScanPipelineHealthPageComponent
),
},
{
path: 'reachability-ingest',
title: 'Reachability Ingest Health - StellaOps',
data: { breadcrumb: 'Reachability Ingest Health' },
loadComponent: () =>
import('./reachability-ingest-health-page.component').then(
(m) => m.ReachabilityIngestHealthPageComponent
),
},
{
path: 'integration-connectivity',
title: 'Integration Connectivity - StellaOps',
data: { breadcrumb: 'Integration Connectivity' },
loadComponent: () =>
import('./integration-connectivity-page.component').then(
(m) => m.IntegrationConnectivityPageComponent
),
},
{
path: 'dlq',
title: 'DLQ and Replays - StellaOps',
data: { breadcrumb: 'DLQ and Replays' },
loadComponent: () =>
import('./dlq-replays-page.component').then(
(m) => m.DlqReplaysPageComponent
),
},
{
path: 'slos',
title: 'Data Quality SLOs - StellaOps',
data: { breadcrumb: 'Data Quality SLOs' },
loadComponent: () =>
import('./data-quality-slos-page.component').then(
(m) => m.DataQualitySlosPageComponent
),
},
];

View File

@@ -0,0 +1,162 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type SloStatus = 'OK' | 'WARN' | 'FAIL';
interface SloRow {
id: string;
slo: string;
target: string;
current: string;
status: SloStatus;
approvalImpact: string;
}
@Component({
selector: 'app-data-quality-slos-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Data Quality SLOs</h1>
<p>SLO status for data signals that affect promotion and approval confidence.</p>
</header>
<table class="table" aria-label="Data quality slos table">
<thead>
<tr>
<th>SLO</th>
<th>Target</th>
<th>Current</th>
<th>Status</th>
<th>Approval impact</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>{{ row.slo }}</td>
<td>{{ row.target }}</td>
<td>{{ row.current }}</td>
<td><span class="status" [class]="'status status--' + row.status.toLowerCase()">{{ row.status }}</span></td>
<td>{{ row.approvalImpact }}</td>
</tr>
}
</tbody>
</table>
<footer class="links">
<a routerLink="/administration/system">Open System SLO Monitoring</a>
<a routerLink="/release-control/approvals">Open impacted approvals</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1100px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.table th,
.table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
font-size: 0.82rem;
vertical-align: top;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
}
.status {
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
`],
})
export class DataQualitySlosPageComponent {
readonly rows: SloRow[] = [
{
id: 'nvd-freshness',
slo: 'CVE feed freshness (NVD/OSV)',
target: '< 2h',
current: '3h 12m',
status: 'WARN',
approvalImpact: 'Approval confidence reduced for critical CVE gates.',
},
{
id: 'sbom-staleness',
slo: 'SBOM staleness (production)',
target: '< 24h',
current: '21h 40m',
status: 'OK',
approvalImpact: 'No active impact in current scope.',
},
{
id: 'runtime-coverage',
slo: 'Runtime reachability coverage (prod)',
target: '> 50%',
current: '46%',
status: 'WARN',
approvalImpact: 'Reachability confidence downgrade for production approvals.',
},
];
}

View File

@@ -0,0 +1,218 @@
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
interface DlqBucket {
id: string;
name: string;
count: number;
}
interface DlqItem {
id: string;
bucketId: string;
payload: string;
age: string;
}
@Component({
selector: 'app-dlq-replays-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>DLQ and Replays</h1>
<p>Data-integrity view of dead-letter queues and replay impact.</p>
</header>
<div class="layout">
<section class="panel">
<h2>Buckets</h2>
<ul class="buckets">
@for (bucket of buckets; track bucket.id) {
<li>
<button
type="button"
[class.active]="selectedBucketId() === bucket.id"
(click)="selectedBucketId.set(bucket.id)">
<span>{{ bucket.name }}</span>
<strong>{{ bucket.count }}</strong>
</button>
</li>
}
</ul>
</section>
<section class="panel">
<h2>Items</h2>
<table class="table" aria-label="DLQ item table">
<thead>
<tr>
<th>Payload</th>
<th>Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (item of selectedItems(); track item.id) {
<tr>
<td>{{ item.payload }}</td>
<td>{{ item.age }}</td>
<td class="actions">
<a routerLink="/platform-ops/dead-letter">Replay</a>
<a routerLink="/platform-ops/dead-letter">View</a>
<a routerLink="/platform-ops/data-integrity/nightly-ops">Link job</a>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">No DLQ items in selected bucket.</td>
</tr>
}
</tbody>
</table>
</section>
</div>
<footer class="links">
<a routerLink="/platform-ops/dead-letter">Open Dead Letter</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1150px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1rem;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.65rem;
font-size: 0.95rem;
}
.buckets {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.buckets button {
width: 100%;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.45rem 0.55rem;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
color: var(--color-text-primary);
}
.buckets button.active {
border-color: var(--color-brand-primary);
background: rgba(245, 166, 35, 0.08);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.5rem 0.45rem;
text-align: left;
font-size: 0.82rem;
vertical-align: top;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.actions {
display: grid;
gap: 0.2rem;
}
a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
}
@media (max-width: 850px) {
.layout {
grid-template-columns: 1fr;
}
}
`],
})
export class DlqReplaysPageComponent {
readonly buckets: DlqBucket[] = [
{ id: 'reach-runtime', name: 'reachability-runtime-ingest', count: 2 },
{ id: 'sbom-rescan', name: 'sbom-nightly-rescan', count: 1 },
{ id: 'evidence-seal', name: 'evidence-seal-bundles', count: 0 },
];
readonly items: DlqItem[] = [
{
id: 'item-1',
bucketId: 'reach-runtime',
payload: 'runtime batch 2026-02-19T03:49Z',
age: '1h 12m',
},
{
id: 'item-2',
bucketId: 'reach-runtime',
payload: 'runtime batch 2026-02-19T03:51Z',
age: '1h 10m',
},
{
id: 'item-3',
bucketId: 'sbom-rescan',
payload: 'rescan bundle platform-130-rc1',
age: '4h 02m',
},
];
readonly selectedBucketId = signal(this.buckets[0].id);
readonly selectedItems = computed(() =>
this.items.filter((item) => item.bucketId === this.selectedBucketId())
);
}

View File

@@ -0,0 +1,162 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type FeedStatus = 'OK' | 'WARN' | 'FAIL';
interface FeedRow {
id: string;
source: string;
status: FeedStatus;
lastSync: string;
sla: string;
gateImpact: string;
}
@Component({
selector: 'app-feeds-freshness-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Feeds Freshness</h1>
<p>Read-only freshness lens for release decision confidence.</p>
</header>
<table class="table" aria-label="Feeds freshness table">
<thead>
<tr>
<th>Source</th>
<th>Status</th>
<th>Last Sync</th>
<th>SLA</th>
<th>Resulting gate impact</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>{{ row.source }}</td>
<td><span class="status" [class]="'status status--' + row.status.toLowerCase()">{{ row.status }}</span></td>
<td>{{ row.lastSync }}</td>
<td>{{ row.sla }}</td>
<td>{{ row.gateImpact }}</td>
</tr>
}
</tbody>
</table>
<footer class="links">
<a routerLink="/platform-ops/feeds">Open Feeds and AirGap Ops</a>
<a routerLink="/platform-ops/feeds/locks">Apply Version Lock</a>
<a routerLink="/platform-ops/feeds/mirror">Retry source sync</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1100px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.table th,
.table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
font-size: 0.82rem;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
}
.status {
border-radius: var(--radius-full);
padding: 0.12rem 0.45rem;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
`],
})
export class FeedsFreshnessPageComponent {
readonly rows: FeedRow[] = [
{
id: 'osv',
source: 'OSV',
status: 'OK',
lastSync: '2026-02-19 01:02 UTC',
sla: '<= 2h',
gateImpact: 'Fresh source keeps CVE gates trusted.',
},
{
id: 'nvd',
source: 'NVD',
status: 'WARN',
lastSync: '2026-02-18 22:49 UTC',
sla: '<= 2h',
gateImpact: 'Staleness can downgrade approval confidence.',
},
{
id: 'cisa-kev',
source: 'CISA KEV',
status: 'OK',
lastSync: '2026-02-19 00:35 UTC',
sla: '<= 6h',
gateImpact: 'Critical exploit intel remains up to date.',
},
];
}

View File

@@ -0,0 +1,176 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type ConnectorStatus = 'OK' | 'WARN' | 'FAIL';
interface ConnectorRow {
id: string;
connector: string;
status: ConnectorStatus;
dependentPipelines: string;
impact: string;
}
@Component({
selector: 'app-integration-connectivity-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Integration Connectivity</h1>
<p>Data integrity lens that maps connector health to release impact.</p>
</header>
<table class="table" aria-label="Integration connectivity table">
<thead>
<tr>
<th>Connector</th>
<th>Status</th>
<th>Dependent pipelines</th>
<th>Impact</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>{{ row.connector }}</td>
<td><span class="status" [class]="'status status--' + row.status.toLowerCase()">{{ row.status }}</span></td>
<td>{{ row.dependentPipelines }}</td>
<td>{{ row.impact }}</td>
<td class="actions">
<a routerLink="/integrations">Open Detail</a>
<a routerLink="/integrations">Test</a>
<a routerLink="/platform-ops/data-integrity/nightly-ops">View dependent jobs</a>
<a routerLink="/release-control/approvals">View impacted approvals</a>
</td>
</tr>
}
</tbody>
</table>
<footer class="links">
<a routerLink="/integrations">Open Integrations Hub</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1150px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.table th,
.table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
font-size: 0.82rem;
vertical-align: top;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
}
.status {
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.actions {
display: grid;
gap: 0.2rem;
}
a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
white-space: nowrap;
}
`],
})
export class IntegrationConnectivityPageComponent {
readonly rows: ConnectorRow[] = [
{
id: 'harbor',
connector: 'Harbor Registry',
status: 'OK',
dependentPipelines: 'Image discovery, SBOM ingest',
impact: 'No release impact.',
},
{
id: 'jenkins',
connector: 'Jenkins',
status: 'WARN',
dependentPipelines: 'Build metadata ingest',
impact: 'Build evidence lag impacts policy confidence.',
},
{
id: 'vault',
connector: 'Vault',
status: 'OK',
dependentPipelines: 'Secret materialization checks',
impact: 'No active risk signal.',
},
{
id: 'consul',
connector: 'Consul',
status: 'WARN',
dependentPipelines: 'Config-readiness checks',
impact: 'Readiness checks may require manual confirmation.',
},
{
id: 'nvd-source',
connector: 'NVD Source',
status: 'FAIL',
dependentPipelines: 'CVE update pipeline',
impact: 'Approvals may block due to stale advisory data.',
},
];
}

View File

@@ -0,0 +1,151 @@
import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { BreadcrumbService } from '../../../layout/breadcrumb';
interface AffectedItem {
id: string;
target: string;
reason: string;
}
@Component({
selector: 'app-data-integrity-job-run-detail-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header class="panel">
<h1>Run {{ runId }}</h1>
<p>Job: reachability-ingest-runtime</p>
<p>Status: <span class="status status--warn">WARN</span></p>
<p>Started: 2026-02-19 03:51 UTC</p>
<p>Finished: 2026-02-19 03:58 UTC</p>
<p>Error: Runtime queue backlog exceeded ingest threshold.</p>
</header>
<section class="panel">
<h2>Integration Reference</h2>
<a routerLink="/integrations">Jenkins connector (job trigger source)</a>
</section>
<section class="panel">
<h2>Affected Items</h2>
<ul class="items">
@for (item of affectedItems; track item.id) {
<li>
<strong>{{ item.target }}</strong>
<span>{{ item.reason }}</span>
</li>
} @empty {
<li>No affected items in this run.</li>
}
</ul>
</section>
<footer class="links">
<a routerLink="/release-control/approvals">Open impacted approvals</a>
<a routerLink="/release-control/bundles">Open bundles</a>
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/platform-ops/orchestrator/jobs">Open logs</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 950px;
display: grid;
gap: 1rem;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1rem;
display: grid;
gap: 0.25rem;
}
h1,
h2 {
margin: 0;
}
p,
span {
font-size: 0.84rem;
color: var(--color-text-secondary);
margin: 0;
}
.status {
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.items {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.5rem;
}
.items li {
display: grid;
gap: 0.1rem;
}
.items strong {
font-size: 0.85rem;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
`],
})
export class DataIntegrityJobRunDetailPageComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly breadcrumbService = inject(BreadcrumbService);
readonly runId = this.route.snapshot.paramMap.get('runId') ?? 'unknown';
readonly affectedItems: AffectedItem[] = [
{
id: 'api-gateway',
target: 'api-gateway:2.3.1',
reason: 'Runtime evidence missing for current digest.',
},
{
id: 'payments-worker',
target: 'payments-worker:5.4.0',
reason: 'Runtime source backlog blocked ingest completion.',
},
];
ngOnInit(): void {
this.breadcrumbService.setContextCrumbs([{ label: `Run #${this.runId}` }]);
}
ngOnDestroy(): void {
this.breadcrumbService.clearContextCrumbs();
}
}

View File

@@ -0,0 +1,252 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type JobStatus = 'OK' | 'WARN' | 'FAIL';
interface NightlyJobRow {
id: string;
runId: string;
job: string;
schedule: string;
lastRun: string;
status: JobStatus;
impact: string;
}
@Component({
selector: 'app-nightly-ops-report-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Nightly Ops Report</h1>
<p>Release-impact view of overnight data and integrity jobs.</p>
</header>
<div class="filters">
<label>
Window
<select>
<option>24h</option>
<option>7d</option>
<option>30d</option>
</select>
</label>
<label>
Region
<select>
<option>All regions</option>
<option>EU West</option>
<option>US East</option>
<option>AP South</option>
</select>
</label>
</div>
<table class="table" aria-label="Nightly ops report table">
<thead>
<tr>
<th>Job</th>
<th>Schedule</th>
<th>Last Run</th>
<th>Status</th>
<th>Why it matters</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>{{ row.job }}</td>
<td>{{ row.schedule }}</td>
<td>{{ row.lastRun }}</td>
<td>
<span class="status" [class]="'status status--' + row.status.toLowerCase()">{{ row.status }}</span>
</td>
<td>{{ row.impact }}</td>
<td class="actions">
<a [routerLink]="['/platform-ops/data-integrity/nightly-ops', row.runId]">View Run</a>
<a routerLink="/platform-ops/scheduler/runs">Open Scheduler</a>
<a routerLink="/platform-ops/orchestrator/jobs">Open Orchestrator</a>
<a routerLink="/integrations">Open Integration</a>
<a routerLink="/platform-ops/dead-letter">Open DLQ</a>
</td>
</tr>
}
</tbody>
</table>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1200px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
label {
display: grid;
gap: 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
letter-spacing: 0.04em;
}
select {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.4rem 0.55rem;
background: var(--color-surface-primary);
color: var(--color-text-primary);
min-width: 130px;
text-transform: none;
letter-spacing: normal;
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.table th,
.table td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.55rem 0.5rem;
text-align: left;
font-size: 0.82rem;
vertical-align: top;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
}
.status {
border-radius: var(--radius-full);
padding: 0.12rem 0.5rem;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.actions {
display: grid;
gap: 0.2rem;
}
.actions a {
color: var(--color-brand-primary);
text-decoration: none;
white-space: nowrap;
}
`],
})
export class NightlyOpsReportPageComponent {
readonly rows: NightlyJobRow[] = [
{
id: 'job-osv',
runId: 'run-24021',
job: 'cve-sync-osv',
schedule: '0 1 * * *',
lastRun: '2026-02-19 01:02 UTC',
status: 'OK',
impact: 'Fresh advisory feed supports accurate approval gating.',
},
{
id: 'job-nvd',
runId: 'run-24022',
job: 'cve-sync-nvd',
schedule: '0 1 * * *',
lastRun: '2026-02-19 01:06 UTC',
status: 'WARN',
impact: 'Stale CVE feed can block promotions due to confidence downgrade.',
},
{
id: 'job-sbom-ingest',
runId: 'run-24023',
job: 'sbom-ingest-registry',
schedule: '0 2 * * *',
lastRun: '2026-02-19 02:08 UTC',
status: 'OK',
impact: 'Maintains complete component inventory for release checks.',
},
{
id: 'job-sbom-rescan',
runId: 'run-24024',
job: 'sbom-nightly-rescan',
schedule: '30 2 * * *',
lastRun: '2026-02-19 02:40 UTC',
status: 'WARN',
impact: 'Stale SBOMs can force manual review in approval workflow.',
},
{
id: 'job-reach-image',
runId: 'run-24025',
job: 'reachability-ingest-image',
schedule: '15 3 * * *',
lastRun: '2026-02-19 03:18 UTC',
status: 'OK',
impact: 'Image evidence supports B/I/R risk interpretation.',
},
{
id: 'job-reach-runtime',
runId: 'run-24026',
job: 'reachability-ingest-runtime',
schedule: '45 3 * * *',
lastRun: '2026-02-19 03:51 UTC',
status: 'FAIL',
impact: 'Missing runtime ingest lowers confidence for production decisions.',
},
{
id: 'job-seal',
runId: 'run-24027',
job: 'evidence-seal-bundles',
schedule: '0 4 * * *',
lastRun: '2026-02-19 04:02 UTC',
status: 'OK',
impact: 'Evidence packet sealing preserves auditability of release actions.',
},
];
}

View File

@@ -0,0 +1,200 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type SourceStatus = 'OK' | 'WARN' | 'FAIL';
interface IngestRow {
id: string;
source: string;
lastBatch: string;
backlog: number;
status: SourceStatus;
}
@Component({
selector: 'app-reachability-ingest-health-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Reachability Ingest Health</h1>
<p>Hybrid B/I/R ingest confidence for release-policy interpretation.</p>
</header>
<section class="coverage" aria-label="Coverage summary">
<div><span>Build</span><strong>{{ buildCoverage }}%</strong></div>
<div><span>Image</span><strong>{{ imageCoverage }}%</strong></div>
<div><span>Runtime</span><strong>{{ runtimeCoverage }}%</strong></div>
</section>
<table class="table" aria-label="Reachability ingest table">
<thead>
<tr>
<th>Source</th>
<th>Last batch</th>
<th>Backlog</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of rows; track row.id) {
<tr>
<td>{{ row.source }}</td>
<td>{{ row.lastBatch }}</td>
<td>{{ row.backlog }}</td>
<td>
<span class="status" [class]="'status status--' + row.status.toLowerCase()">{{ row.status }}</span>
</td>
</tr>
}
</tbody>
</table>
<footer class="links">
<a routerLink="/platform-ops/agents">Open Agents</a>
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
<a routerLink="/release-control/approvals">Open impacted approvals</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 950px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.coverage {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.coverage div {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.2rem;
}
.coverage span {
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.coverage strong {
font-size: 1.05rem;
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.table th,
.table td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
font-size: 0.82rem;
}
.table th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
}
.status {
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
@media (max-width: 700px) {
.coverage {
grid-template-columns: 1fr;
}
}
`],
})
export class ReachabilityIngestHealthPageComponent {
readonly buildCoverage = 84;
readonly imageCoverage = 92;
readonly runtimeCoverage = 61;
readonly rows: IngestRow[] = [
{
id: 'image',
source: 'Image / Dover',
lastBatch: '2026-02-19 03:18 UTC',
backlog: 7,
status: 'OK',
},
{
id: 'build',
source: 'Build',
lastBatch: '2026-02-19 03:12 UTC',
backlog: 4,
status: 'OK',
},
{
id: 'runtime',
source: 'Runtime',
lastBatch: '2026-02-19 03:51 UTC',
backlog: 43,
status: 'WARN',
},
];
}

View File

@@ -0,0 +1,175 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
type StageStatus = 'OK' | 'WARN' | 'FAIL';
interface Stage {
id: string;
name: string;
detail: string;
status: StageStatus;
}
@Component({
selector: 'app-scan-pipeline-health-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="di-page">
<header>
<h1>Scan Pipeline Health</h1>
<p>End-to-end SBOM and finding pipeline readiness for release decisions.</p>
</header>
<section class="panel" aria-label="Pipeline stages">
<h2>Pipeline Stages</h2>
<ul class="stages">
@for (stage of stages; track stage.id) {
<li>
<span>{{ stage.name }}</span>
<span class="status" [class]="'status status--' + stage.status.toLowerCase()">{{ stage.status }}</span>
<span>{{ stage.detail }}</span>
</li>
}
</ul>
</section>
<section class="panel" aria-label="Impact summary">
<h2>Impact Summary</h2>
<p>Environments affected: <strong>{{ affectedEnvironments }}</strong></p>
<p>Approvals blocked: <strong>{{ blockedApprovals }}</strong></p>
</section>
<footer class="links">
<a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
<a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
<a routerLink="/integrations">Integrations</a>
<a routerLink="/security-risk/findings">Security Findings</a>
</footer>
</div>
`,
styles: [`
.di-page {
padding: 1.5rem;
max-width: 1000px;
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.35rem;
}
p {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1rem;
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.stages {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.stages li {
display: grid;
grid-template-columns: 220px auto 1fr;
gap: 0.55rem;
align-items: center;
font-size: 0.82rem;
}
.status {
border-radius: var(--radius-full);
padding: 0.1rem 0.45rem;
font-size: 0.66rem;
font-weight: var(--font-weight-semibold);
}
.status--ok {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
.status--warn {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.status--fail {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.links {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.links a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.84rem;
}
@media (max-width: 850px) {
.stages li {
grid-template-columns: 1fr;
}
}
`],
})
export class ScanPipelineHealthPageComponent {
readonly stages: Stage[] = [
{
id: 'image-discovery',
name: 'Image discovery (registry)',
detail: '482 images discovered in current scope.',
status: 'OK',
},
{
id: 'sbom-generation',
name: 'SBOM generation and ingest',
detail: '14 pending SBOM ingests.',
status: 'WARN',
},
{
id: 'nightly-rescan',
name: 'Nightly SBOM rescan',
detail: '2 production SBOMs are stale.',
status: 'WARN',
},
{
id: 'feed-sync',
name: 'CVE feeds sync',
detail: 'NVD lag exceeds expected SLA window.',
status: 'WARN',
},
{
id: 'match-update',
name: 'CVE to SBOM match and update',
detail: 'Mapping coverage at 98.1 percent.',
status: 'OK',
},
];
readonly affectedEnvironments = 3;
readonly blockedApprovals = 2;
}

View File

@@ -1,6 +1,6 @@
<div class="proof-chain-container" [class.expanded]="expandedView"> <div class="proof-chain-container" [class.expanded]="expandedView">
<div class="proof-chain-header"> <div class="proof-chain-header">
<h2>Evidence Chain</h2> <h2>Proof Chains</h2>
<div class="proof-chain-actions"> <div class="proof-chain-actions">
@if (hasData()) { @if (hasData()) {
<button class="btn-icon" (click)="refresh()" [disabled]="loading()" title="Refresh proof chain"> <button class="btn-icon" (click)="refresh()" [disabled]="loading()" title="Refresh proof chain">
@@ -10,6 +10,27 @@
</div> </div>
</div> </div>
<form class="proof-chain-search" (submit)="$event.preventDefault(); onSearchSubmit()">
<label for="proof-chain-digest" class="proof-chain-search__label">Subject Digest</label>
<div class="proof-chain-search__controls">
<input
id="proof-chain-digest"
type="text"
[ngModel]="inputDigest()"
(ngModelChange)="inputDigest.set($event)"
name="subjectDigest"
placeholder="sha256:..."
/>
<button type="submit" class="btn-primary">Search</button>
</div>
</form>
@if (validationError(); as validationMessage) {
<div class="error-state">
<p>{{ validationMessage }}</p>
</div>
}
@if (loading()) { @if (loading()) {
<div class="loading-state"> <div class="loading-state">
<div class="spinner"></div> <div class="spinner"></div>
@@ -54,8 +75,8 @@
<div class="proof-chain-graph"> <div class="proof-chain-graph">
@if (nodeCount() === 0) { @if (nodeCount() === 0) {
<div class="empty-graph-state" role="status" aria-live="polite"> <div class="empty-graph-state" role="status" aria-live="polite">
<p class="empty-graph-title">No proof nodes are available for this subject.</p> <p class="empty-graph-title">No proof chain found for this subject.</p>
<p class="empty-graph-description">Refresh to check whether new attestations were published.</p> <p class="empty-graph-description">Try another digest or refresh to check for recently published attestations.</p>
<button class="btn-secondary" type="button" (click)="refresh()">Refresh graph</button> <button class="btn-secondary" type="button" (click)="refresh()">Refresh graph</button>
</div> </div>
} @else { } @else {

View File

@@ -35,6 +35,31 @@
gap: var(--space-2); gap: var(--space-2);
} }
.proof-chain-search {
display: grid;
gap: var(--space-2);
}
.proof-chain-search__label {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.proof-chain-search__controls {
display: flex;
gap: var(--space-2);
}
.proof-chain-search input {
flex: 1;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.btn-icon { .btn-icon {
background: none; background: none;
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);

View File

@@ -12,8 +12,11 @@ import {
signal, signal,
computed, computed,
effect, effect,
inject,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ProofChainService } from './proof-chain.service'; import { ProofChainService } from './proof-chain.service';
import { ProofNode, ProofChainResponse } from './proof-chain.models'; import { ProofNode, ProofChainResponse } from './proof-chain.models';
@@ -43,7 +46,7 @@ import { ProofNode, ProofChainResponse } from './proof-chain.models';
*/ */
@Component({ @Component({
selector: 'stella-proof-chain', selector: 'stella-proof-chain',
imports: [CommonModule], imports: [CommonModule, FormsModule],
templateUrl: './proof-chain.component.html', templateUrl: './proof-chain.component.html',
styleUrls: ['./proof-chain.component.scss'], styleUrls: ['./proof-chain.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -64,6 +67,8 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly proofChain = signal<ProofChainResponse | null>(null); readonly proofChain = signal<ProofChainResponse | null>(null);
readonly selectedNode = signal<ProofNode | null>(null); readonly selectedNode = signal<ProofNode | null>(null);
readonly inputDigest = signal('');
readonly validationError = signal<string | null>(null);
// Computed values // Computed values
readonly hasData = computed(() => this.proofChain() !== null); readonly hasData = computed(() => this.proofChain() !== null);
@@ -72,6 +77,7 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
private cytoscapeInstance: any = null; private cytoscapeInstance: any = null;
private readonly proofChainService: ProofChainService; private readonly proofChainService: ProofChainService;
private readonly route = inject(ActivatedRoute);
constructor(private readonly service: ProofChainService) { constructor(private readonly service: ProofChainService) {
this.proofChainService = service; this.proofChainService = service;
@@ -86,7 +92,14 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.loadProofChain(); const routeDigest = this.route.snapshot.paramMap.get('subjectDigest') ?? '';
const initialDigest = (this.subjectDigest || routeDigest).trim();
this.inputDigest.set(initialDigest);
if (initialDigest) {
this.subjectDigest = initialDigest;
this.loadProofChain();
}
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@@ -107,26 +120,45 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
* Load proof chain from API * Load proof chain from API
*/ */
loadProofChain(): void { loadProofChain(): void {
if (!this.subjectDigest) { const digest = this.subjectDigest.trim();
this.error.set('Subject digest is required'); if (!digest) {
this.validationError.set('A subject digest is required.');
return; return;
} }
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
this.validationError.set(null);
this.subjectDigest = digest;
this.proofChainService.getProofChain(this.subjectDigest, this.maxDepth).subscribe({ this.proofChainService.getProofChain(digest, this.maxDepth).subscribe({
next: (chain) => { next: (chain) => {
this.proofChain.set(chain); this.proofChain.set(chain);
this.loading.set(false); this.loading.set(false);
}, },
error: (err) => { error: (err) => {
this.error.set(`Failed to load proof chain: ${err.message || 'Unknown error'}`); if (err?.status === 404) {
this.error.set('No proof chain found for this subject digest.');
} else {
this.error.set(`Failed to load proof chain: ${err.message || 'Unknown error'}`);
}
this.loading.set(false); this.loading.set(false);
}, },
}); });
} }
onSearchSubmit(): void {
const digest = this.inputDigest().trim();
if (!digest) {
this.validationError.set('A subject digest is required.');
return;
}
this.subjectDigest = digest;
this.selectedNode.set(null);
this.loadProofChain();
}
/** /**
* Render the proof chain graph using Cytoscape.js * Render the proof chain graph using Cytoscape.js
* Note: This is a placeholder implementation. In production, install cytoscape via npm. * Note: This is a placeholder implementation. In production, install cytoscape via npm.

View File

@@ -25,15 +25,15 @@ import {
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" routerLink="alerts"> <button class="btn btn-secondary" routerLink="alerts">
<span class="icon">bell</span> <span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/><path d="M10 17a2 2 0 1 0 4 0"/></svg></span>
Configure Alerts Configure Alerts
</button> </button>
<button class="btn btn-secondary" routerLink="reports"> <button class="btn btn-secondary" routerLink="reports">
<span class="icon">download</span> <span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg></span>
Export Report Export Report
</button> </button>
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()"> <button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
<span class="icon" [class.spinning]="refreshing()">refresh</span> <span class="icon" [class.spinning]="refreshing()" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
</button> </button>
</div> </div>
</header> </header>
@@ -289,7 +289,7 @@ import {
} }
@if (!recentViolations().length) { @if (!recentViolations().length) {
<div class="empty-state success"> <div class="empty-state success">
<span class="icon">check-circle</span> <span class="icon" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12.75 11.5 15 15.5 9.5"/><circle cx="12" cy="12" r="9"/></svg></span>
<p>No throttle events in the last 24 hours</p> <p>No throttle events in the last 24 hours</p>
</div> </div>
} }

View File

@@ -0,0 +1,82 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-release-control-governance-hub',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance-hub">
<header class="header">
<h1>Governance</h1>
<p>Policy and exception controls anchored under Release Control.</p>
</header>
<div class="cards">
<a routerLink="/release-control/governance/baselines" class="card">
<h2>Policy Baselines</h2>
<p>Environment-scoped baseline definitions and lock rules.</p>
</a>
<a routerLink="/release-control/governance/rules" class="card">
<h2>Governance Rules</h2>
<p>Rule catalog for release control gate enforcement.</p>
</a>
<a routerLink="/release-control/governance/simulation" class="card">
<h2>Policy Simulation</h2>
<p>Dry-run policy evaluations before production rollout.</p>
</a>
<a routerLink="/release-control/governance/exceptions" class="card">
<h2>Exception Workflow</h2>
<p>Exception requests, approvals, and expiry management.</p>
</a>
</div>
</section>
`,
styles: [
`
.governance-hub {
display: grid;
gap: 0.8rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.cards {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.72rem 0.8rem;
text-decoration: none;
color: inherit;
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 0.95rem;
}
.card p {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
`,
],
})
export class ReleaseControlGovernanceHubComponent {}

View File

@@ -0,0 +1,59 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-release-control-governance-section',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance-section">
<header>
<h1>{{ sectionTitle() }}</h1>
<p>This governance area is scaffolded and ready for backend contract binding.</p>
</header>
<p class="note">Canonical location: Release Control &gt; Governance.</p>
<a routerLink="/release-control/governance">Back to Governance Hub</a>
</section>
`,
styles: [
`
.governance-section {
display: grid;
gap: 0.7rem;
}
h1 {
margin: 0 0 0.2rem;
font-size: 1.35rem;
}
p {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.note {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.55rem 0.65rem;
}
a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
}
`,
],
})
export class ReleaseControlGovernanceSectionComponent {
private readonly route = inject(ActivatedRoute);
readonly sectionTitle = signal(
(this.route.snapshot.data['sectionTitle'] as string | undefined) ?? 'Governance'
);
}

View File

@@ -0,0 +1,49 @@
import { Routes } from '@angular/router';
export const RELEASE_CONTROL_GOVERNANCE_ROUTES: Routes = [
{
path: '',
title: 'Governance',
data: { breadcrumb: 'Governance' },
loadComponent: () =>
import('./release-control-governance-hub.component').then(
(m) => m.ReleaseControlGovernanceHubComponent
),
},
{
path: 'baselines',
title: 'Policy Baselines',
data: { breadcrumb: 'Policy Baselines', sectionTitle: 'Policy Baselines' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'rules',
title: 'Governance Rules',
data: { breadcrumb: 'Governance Rules', sectionTitle: 'Governance Rules' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'simulation',
title: 'Policy Simulation',
data: { breadcrumb: 'Policy Simulation', sectionTitle: 'Policy Simulation' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'exceptions',
title: 'Exception Workflow',
data: { breadcrumb: 'Exception Workflow', sectionTitle: 'Exception Workflow' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
];

View File

@@ -0,0 +1,125 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
interface HotfixRow {
bundle: string;
targetEnv: string;
urgency: string;
gates: string;
}
@Component({
selector: 'app-hotfixes-queue',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="hotfixes">
<header class="header">
<h1>Hotfixes</h1>
<p>Dedicated queue for expedited release-control promotions.</p>
</header>
<button type="button" class="create-btn">+ Create Hotfix</button>
@if (hotfixes.length === 0) {
<p class="empty">No active hotfixes.</p>
} @else {
<table>
<thead>
<tr>
<th>Bundle</th>
<th>Target Env</th>
<th>Urgency</th>
<th>Gates</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (row of hotfixes; track row.bundle + row.targetEnv) {
<tr>
<td>{{ row.bundle }}</td>
<td>{{ row.targetEnv }}</td>
<td>{{ row.urgency }}</td>
<td>{{ row.gates }}</td>
<td><button type="button">Review</button></td>
</tr>
}
</tbody>
</table>
}
</section>
`,
styles: [
`
.hotfixes {
display: grid;
gap: 0.8rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.create-btn {
justify-self: start;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: 0.45rem 0.72rem;
font-size: 0.8rem;
cursor: pointer;
}
.empty {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.83rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
padding: 0.46rem;
font-size: 0.8rem;
}
th {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
td button {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: 0.25rem 0.45rem;
font-size: 0.74rem;
cursor: pointer;
}
`,
],
})
export class HotfixesQueueComponent {
readonly hotfixes: HotfixRow[] = [
{
bundle: 'platform-bundle@1.3.1-hotfix1',
targetEnv: 'prod-eu',
urgency: 'Critical',
gates: 'WARN',
},
];
}

View File

@@ -0,0 +1,129 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
interface EnvironmentNode {
id: string;
stage: string;
status: string;
}
@Component({
selector: 'app-region-detail',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="region-detail">
<header class="header">
<h1>{{ regionLabel() }} Region</h1>
<p>Pipeline posture by environment with promotion flow context.</p>
</header>
<section class="summary">
<article>
<span>Total environments</span>
<strong>{{ environments.length }}</strong>
</article>
<article>
<span>Overall health</span>
<strong>DEGRADED</strong>
</article>
<article>
<span>SBOM posture</span>
<strong>WARN</strong>
</article>
</section>
<section class="pipeline">
@for (env of environments; track env.id) {
<a class="pipeline-node" [routerLink]="['/release-control/regions', regionLabel(), 'environments', env.id]">
<h2>{{ env.id }}</h2>
<p>{{ env.stage }}</p>
<p>Status: {{ env.status }}</p>
</a>
}
</section>
</section>
`,
styles: [
`
.region-detail {
display: grid;
gap: 0.9rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.42rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.summary {
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.summary article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.6rem 0.7rem;
}
.summary span {
display: block;
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.summary strong {
font-size: 1.08rem;
}
.pipeline {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.pipeline-node {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem 0.75rem;
text-decoration: none;
color: inherit;
}
.pipeline-node h2 {
margin: 0 0 0.2rem;
font-size: 0.98rem;
}
.pipeline-node p {
margin: 0.14rem 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
`,
],
})
export class RegionDetailComponent {
private readonly route = inject(ActivatedRoute);
readonly regionLabel = signal(this.route.snapshot.paramMap.get('region') ?? 'global');
readonly environments: EnvironmentNode[] = [
{ id: 'dev', stage: 'Development', status: 'HEALTHY' },
{ id: 'stage', stage: 'Staging', status: 'HEALTHY' },
{ id: 'prod', stage: 'Production', status: 'DEGRADED' },
];
}

View File

@@ -0,0 +1,99 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface RegionCard {
id: string;
name: string;
envCount: number;
health: string;
sbomPosture: string;
}
@Component({
selector: 'app-regions-overview',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="regions-overview">
<header class="header">
<h1>Regions & Environments</h1>
<p>Region-first release control posture with environment health and SBOM coverage context.</p>
</header>
<div class="cards">
@for (region of regions; track region.id) {
<a class="card" [routerLink]="['/release-control/regions', region.id]">
<h2>{{ region.name }}</h2>
<p>Environments: {{ region.envCount }}</p>
<p>Health: {{ region.health }}</p>
<p>SBOM posture: {{ region.sbomPosture }}</p>
</a>
}
</div>
</section>
`,
styles: [
`
.regions-overview {
display: grid;
gap: 0.9rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.45rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.cards {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.75rem 0.85rem;
text-decoration: none;
color: inherit;
}
.card h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.card p {
margin: 0.15rem 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
`,
],
})
export class RegionsOverviewComponent {
readonly regions: RegionCard[] = [
{
id: 'global',
name: 'Global',
envCount: 4,
health: 'DEGRADED',
sbomPosture: 'WARN',
},
{
id: 'eu-west',
name: 'EU West',
envCount: 3,
health: 'HEALTHY',
sbomPosture: 'OK',
},
];
}

View File

@@ -59,7 +59,7 @@ import {
<div class="environment-card" [class.is-production]="env.isProduction"> <div class="environment-card" [class.is-production]="env.isProduction">
<div class="card-header"> <div class="card-header">
<span class="order-badge">#{{ env.order }}</span> <span class="order-badge">#{{ env.order }}</span>
<a [routerLink]="['/release-orchestrator/environments', env.id]" class="env-name"> <a [routerLink]="['/release-control/regions', 'global', 'environments', env.id]" class="env-name">
{{ env.displayName }} {{ env.displayName }}
</a> </a>
@if (env.isProduction) { @if (env.isProduction) {
@@ -69,8 +69,8 @@ import {
<button class="menu-btn" (click)="toggleMenu(env.id, $event)">...</button> <button class="menu-btn" (click)="toggleMenu(env.id, $event)">...</button>
@if (openMenuId === env.id) { @if (openMenuId === env.id) {
<div class="dropdown-menu"> <div class="dropdown-menu">
<a [routerLink]="['/release-orchestrator/environments', env.id]">View Details</a> <a [routerLink]="['/release-control/regions', 'global', 'environments', env.id]">View Details</a>
<a [routerLink]="['/release-orchestrator/environments', env.id, 'settings']">Settings</a> <a [routerLink]="['/release-control/regions', 'global', 'environments', env.id, 'settings']">Settings</a>
<hr /> <hr />
<button class="danger" (click)="confirmDelete(env)">Delete</button> <button class="danger" (click)="confirmDelete(env)">Delete</button>
</div> </div>

View File

@@ -16,31 +16,40 @@ export const ENVIRONMENT_ROUTES: Routes = [
), ),
}, },
{ {
path: ':id', path: ':region/:env',
data: { title: 'Environment Detail',
breadcrumb: 'Environment Detail',
tabs: [
'overview',
'deployments',
'sbom',
'reachability',
'inputs',
'promotions',
'data-integrity',
'evidence',
],
},
loadComponent: () => loadComponent: () =>
import('./environment-detail/environment-detail.component').then( import('./environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent (m) => m.EnvironmentDetailComponent
), ),
}, },
{ {
path: ':id/settings', path: ':region/:env/settings',
data: { breadcrumb: 'Environment Settings', tab: 'settings' }, title: 'Environment Detail',
data: { initialTab: 'inputs' },
loadComponent: () => loadComponent: () =>
import('./environment-detail/environment-detail.component').then( import('./environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent (m) => m.EnvironmentDetailComponent
), ),
}, },
{
path: ':id',
redirectTo: 'global/:id',
pathMatch: 'full',
},
{
path: ':id/settings',
redirectTo: 'global/:id/settings',
pathMatch: 'full',
},
{
path: ':id/:page',
redirectTo: 'global/:id',
pathMatch: 'full',
},
{
path: '**',
redirectTo: '',
pathMatch: 'full',
},
]; ];

View File

@@ -28,7 +28,7 @@ import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.
<div class="schedule-management"> <div class="schedule-management">
<header class="page-header"> <header class="page-header">
<div class="header-content"> <div class="header-content">
<a routerLink="/scheduler/runs" class="back-link">&larr; Back to Runs</a> <a routerLink="/platform-ops/scheduler/runs" class="back-link">&larr; Back to Runs</a>
<h1>Schedule Management</h1> <h1>Schedule Management</h1>
<p>Create, edit, and manage scheduled tasks.</p> <p>Create, edit, and manage scheduled tasks.</p>
</div> </div>

View File

@@ -28,10 +28,10 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
<p>Monitor and manage scheduled task executions.</p> <p>Monitor and manage scheduled task executions.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" routerLink="/scheduler/schedules"> <button class="btn btn-secondary" routerLink="/platform-ops/scheduler/schedules">
Manage Schedules Manage Schedules
</button> </button>
<button class="btn btn-secondary" routerLink="/scheduler/workers"> <button class="btn btn-secondary" routerLink="/platform-ops/scheduler/workers">
Worker Fleet Worker Fleet
</button> </button>
</div> </div>
@@ -194,7 +194,7 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
</button> </button>
<a <a
class="btn btn-secondary" class="btn btn-secondary"
[routerLink]="['/scheduler/runs', run.id, 'stream']" [routerLink]="['/platform-ops/scheduler/runs', run.id, 'stream']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
Live Stream Live Stream

View File

@@ -24,7 +24,7 @@ import {
<div class="worker-fleet"> <div class="worker-fleet">
<header class="page-header"> <header class="page-header">
<div class="header-content"> <div class="header-content">
<a routerLink="/scheduler/runs" class="back-link"><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" style="display:inline;vertical-align:middle"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Back to Runs</a> <a routerLink="/platform-ops/scheduler/runs" class="back-link"><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" style="display:inline;vertical-align:middle"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Back to Runs</a>
<h1>Worker Fleet</h1> <h1>Worker Fleet</h1>
<p>Monitor worker status, load distribution, and health.</p> <p>Monitor worker status, load distribution, and health.</p>
</div> </div>

View File

@@ -79,7 +79,7 @@ interface AdvisorySummaryVm {
<div> <div>
<h1>Advisory Sources</h1> <h1>Advisory Sources</h1>
<p> <p>
Security and Risk decision-impact surface. Connectivity belongs to Integrations. Mirror Security &amp; Risk decision-impact surface. Connectivity belongs to Integrations. Mirror
and freshness operations belong to Platform Ops. and freshness operations belong to Platform Ops.
</p> </p>
</div> </div>
@@ -179,7 +179,7 @@ interface AdvisorySummaryVm {
@if (rows().length === 0) { @if (rows().length === 0) {
<section class="empty" aria-label="No advisory sources"> <section class="empty" aria-label="No advisory sources">
<h2>No advisory sources configured</h2> <h2>No advisory sources configured</h2>
<p>Configure the first source in Integrations before Security and Risk can evaluate impact.</p> <p>Configure the first source in Integrations before Security &amp; Risk can evaluate impact.</p>
<a routerLink="/integrations/feeds">Open Integrations Feeds</a> <a routerLink="/integrations/feeds">Open Integrations Feeds</a>
</section> </section>
} @else { } @else {

View File

@@ -0,0 +1,100 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-finding-detail-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="finding-detail">
<header class="header">
<h1>Finding {{ findingId() }}</h1>
<p>CVE-2026-1234 | openssl | Critical | api-gateway | sha256:api123 | prod-eu</p>
</header>
<section class="panel">
<h2>Reachability</h2>
<p>REACHABLE (confidence 91%)</p>
<p>B/I/R evidence age: B 42m | I 38m | R 2h 11m</p>
</section>
<section class="panel">
<h2>Impact</h2>
<p>Affected environments: 3 | Affected bundle versions: 2</p>
<p>
Blocked approvals:
<a [routerLink]="['/release-control/approvals']" [queryParams]="{ finding: findingId() }">2 approvals</a>
</p>
</section>
<section class="panel">
<h2>Disposition</h2>
<p>VEX statements: 1 linked</p>
<p>Exceptions: none active</p>
</section>
<section class="actions">
<button type="button">Create Exception Request</button>
<button type="button">Search/Import VEX</button>
<button type="button">Export as Evidence</button>
</section>
</section>
`,
styles: [
`
.finding-detail {
display: grid;
gap: 0.85rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem 0.8rem;
}
.panel h2 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
}
.panel p {
margin: 0.2rem 0;
font-size: 0.82rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.actions button {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: 0.45rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
`,
],
})
export class FindingDetailPageComponent {
private readonly route = inject(ActivatedRoute);
readonly findingId = signal(this.route.snapshot.paramMap.get('findingId') ?? 'unknown-finding');
}

View File

@@ -38,6 +38,12 @@ interface RiskSummaryCard {
</div> </div>
</header> </header>
<section class="data-confidence-banner" role="status">
<strong>Data Confidence: WARN</strong>
<span>NVD stale 3h; SBOM rescan FAIL; runtime ingest lagging</span>
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
</section>
<!-- Primary cards: risk-blocking decisions first --> <!-- Primary cards: risk-blocking decisions first -->
<section class="cards-grid primary-cards" aria-label="Security risk summary"> <section class="cards-grid primary-cards" aria-label="Security risk summary">
<!-- Risk Score Card --> <!-- Risk Score Card -->
@@ -92,6 +98,29 @@ interface RiskSummaryCard {
</a> </a>
</section> </section>
<section class="critical-by-env" aria-label="Critical reachable by environment">
<h2 class="context-links-title">Critical Reachable by Environment</h2>
<div class="critical-grid">
@for (env of criticalReachableByEnvironment(); track env.name) {
<article>
<span>{{ env.name }}</span>
<strong>{{ env.count }}</strong>
</article>
}
</div>
</section>
<section class="posture-grid" aria-label="SBOM and VEX posture">
<article class="posture-card">
<h2>SBOM Posture</h2>
<p>Coverage {{ sbomPosture().coverage }}% | Freshness {{ sbomPosture().freshness }} | Pending scans {{ sbomPosture().pending }}</p>
</article>
<article class="posture-card">
<h2>VEX &amp; Exceptions</h2>
<p>Statements {{ vexPosture().statements }} | Expiring exceptions {{ vexPosture().expiringExceptions }}</p>
</article>
</section>
<!-- Contextual navigation links --> <!-- Contextual navigation links -->
<section class="context-links" aria-label="Related surfaces"> <section class="context-links" aria-label="Related surfaces">
<h2 class="context-links-title">More in Security &amp; Risk</h2> <h2 class="context-links-title">More in Security &amp; Risk</h2>
@@ -149,6 +178,25 @@ interface RiskSummaryCard {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
} }
.data-confidence-banner {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
border: 1px solid #f59e0b;
background: #fffbeb;
color: #92400e;
border-radius: var(--radius-md);
padding: 0.62rem 0.78rem;
font-size: 0.82rem;
}
.data-confidence-banner a {
color: var(--color-brand-primary);
text-decoration: none;
margin-left: auto;
}
/* Card Grids */ /* Card Grids */
.cards-grid { .cards-grid {
display: grid; display: grid;
@@ -163,6 +211,63 @@ interface RiskSummaryCard {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
} }
.critical-by-env,
.posture-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.critical-by-env {
padding: 1rem;
display: grid;
gap: 0.7rem;
}
.critical-grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.critical-grid article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.6rem 0.7rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.82rem;
}
.critical-grid strong {
font-size: 1.15rem;
}
.posture-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.posture-card {
padding: 0.8rem 0.9rem;
}
.posture-card h2 {
margin: 0 0 0.35rem;
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.posture-card p {
margin: 0;
font-size: 0.84rem;
color: var(--color-text-secondary);
}
/* Cards */ /* Cards */
.card { .card {
display: flex; display: flex;
@@ -359,4 +464,21 @@ export class SecurityRiskOverviewComponent {
link: '/security-risk/reachability', link: '/security-risk/reachability',
linkLabel: 'Reachability center', linkLabel: 'Reachability center',
}); });
readonly criticalReachableByEnvironment = signal([
{ name: 'prod-eu', count: 4 },
{ name: 'prod-us', count: 3 },
{ name: 'stage-eu', count: 1 },
]);
readonly sbomPosture = signal({
coverage: 94,
freshness: 'WARN',
pending: 2,
});
readonly vexPosture = signal({
statements: 476,
expiringExceptions: 3,
});
} }

View File

@@ -0,0 +1,112 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-vulnerability-detail-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="vuln-detail">
<header class="header">
<h1>{{ cveId() }}</h1>
<p>Package: openssl | Severity: Critical | EPSS: 0.87 | KEV: YES</p>
</header>
@if (showDataConfidenceBanner()) {
<section class="banner">
Data Confidence: WARN (NVD stale 3h)
</section>
}
<section class="panel">
<h2>Impact Summary</h2>
<p>Impacted environments: 3</p>
<p>Reachability classes: reachable 4 | not reachable 9 | unknown 3</p>
<p>Affected bundle versions: 2</p>
</section>
<section class="panel">
<h2>Disposition</h2>
<p>VEX: 1 linked statement</p>
<p>Exceptions: none active</p>
</section>
<section class="actions">
<a routerLink="/security-risk/findings">Open Findings</a>
<a routerLink="/security-risk/sbom">Open SBOM Graph</a>
<a routerLink="/security-risk/exceptions">Create Exception</a>
<button type="button">Export Report</button>
</section>
</section>
`,
styles: [
`
.vuln-detail {
display: grid;
gap: 0.85rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.35rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.banner {
border: 1px solid #f59e0b;
background: #fffbeb;
color: #92400e;
border-radius: var(--radius-md);
padding: 0.55rem 0.7rem;
font-size: 0.82rem;
}
.panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem 0.8rem;
}
.panel h2 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
}
.panel p {
margin: 0.2rem 0;
font-size: 0.82rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.actions a,
.actions button {
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
padding: 0.42rem 0.68rem;
font-size: 0.8rem;
text-decoration: none;
color: inherit;
cursor: pointer;
}
`,
],
})
export class VulnerabilityDetailPageComponent {
private readonly route = inject(ActivatedRoute);
readonly cveId = signal(this.route.snapshot.paramMap.get('cveId') ?? 'CVE-UNKNOWN');
readonly showDataConfidenceBanner = signal(true);
}

View File

@@ -4,11 +4,12 @@
*/ */
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-sbom-graph-page', selector: 'app-sbom-graph-page',
imports: [], imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<section class="page"> <section class="page">
@@ -16,10 +17,114 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
<h1 class="page-title">SBOM Graph</h1> <h1 class="page-title">SBOM Graph</h1>
<p class="page-subtitle">Visualize dependency relationships and component impact.</p> <p class="page-subtitle">Visualize dependency relationships and component impact.</p>
</header> </header>
<div class="panel">
<p>SBOM graph visualization is not yet available in this build.</p> <div class="coming-soon" role="status" aria-live="polite">
<div class="coming-soon__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="6" cy="12" r="2.5"></circle>
<circle cx="18" cy="6" r="2.5"></circle>
<circle cx="18" cy="18" r="2.5"></circle>
<path d="M8.3 10.9 15.7 7.1"></path>
<path d="M8.3 13.1 15.7 16.9"></path>
</svg>
</div>
<h2 class="coming-soon__title">Graph visualization is coming soon</h2>
<p class="coming-soon__text">
Core lineage data is available, but the interactive dependency explorer is still being wired.
</p>
<div class="coming-soon__actions">
<a routerLink="/security-risk/findings" class="btn btn--primary">Open Findings</a>
<a routerLink="/security-risk/reachability" class="btn btn--secondary">Open Reachability</a>
</div>
</div> </div>
</section> </section>
` `,
styles: [`
.page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
.page-subtitle {
margin: 0;
color: var(--color-text-secondary);
}
.coming-soon {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, var(--color-surface-secondary), var(--color-surface-primary));
}
.coming-soon__icon {
width: 2.25rem;
height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
color: var(--color-brand-primary);
background: var(--color-brand-soft);
}
.coming-soon__title {
margin: 0;
font-size: 1.0625rem;
font-weight: var(--font-weight-semibold);
}
.coming-soon__text {
margin: 0;
color: var(--color-text-secondary);
max-width: 64ch;
}
.coming-soon__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.875rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
border: 1px solid transparent;
}
.btn--primary {
color: #fff;
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
}
.btn--secondary {
color: var(--color-text-primary);
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
}
`]
}) })
export class SbomGraphPageComponent {} export class SbomGraphPageComponent {}

View File

@@ -619,7 +619,7 @@ export class VulnerabilityDetailPageComponent implements OnInit {
} }
createRemediationTask(): void { createRemediationTask(): void {
void this.router.navigate(['/operations/orchestrator/jobs'], { void this.router.navigate(['/platform-ops/orchestrator/jobs'], {
queryParams: { queryParams: {
action: 'remediate', action: 'remediate',
cveId: this.vuln().id, cveId: this.vuln().id,

View File

@@ -12,7 +12,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="branding-settings"> <div class="branding-settings">
<h1 class="page-title">Tenant / Branding</h1> <h1 class="page-title">Tenant & Branding</h1>
<p class="page-subtitle">Customize appearance and branding for your tenant</p> <p class="page-subtitle">Customize appearance and branding for your tenant</p>
<div class="settings-grid"> <div class="settings-grid">

View File

@@ -3,40 +3,110 @@
* Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-002) * Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-002)
*/ */
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IntegrationService } from '../../integration-hub/integration.service';
import {
Integration,
getIntegrationStatusLabel,
getIntegrationTypeLabel,
} from '../../integration-hub/integration.models';
@Component({ @Component({
selector: 'app-integration-detail-page', selector: 'app-integration-detail-page',
imports: [RouterLink], imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="integration-detail"> <div class="integration-detail">
<header class="page-header"> <header class="page-header">
<a routerLink="../" class="back-link">Back to Integrations</a> <a routerLink="/settings/integrations" class="back-link">Back to Integrations</a>
<h1 class="page-title">Integration Detail</h1> <h1 class="page-title">{{ pageTitle() }}</h1>
<p class="page-subtitle">Integration ID: {{ integrationId() }}</p> <p class="page-subtitle">{{ pageSubtitle() }}</p>
</header> </header>
<div class="tabs"> @if (loading()) {
<button type="button" class="tab tab--active">Overview</button> <section class="state-card" role="status">Loading integration details...</section>
<button type="button" class="tab">Health</button> } @else if (error(); as errorMessage) {
<button type="button" class="tab">Activity</button> <section class="state-card state-card--error" role="alert">
<button type="button" class="tab">Permissions</button> <p>{{ errorMessage }}</p>
<button type="button" class="tab">Secrets</button> <button type="button" class="btn btn--secondary" (click)="reload()">Retry</button>
<button type="button" class="tab">Webhooks</button> </section>
</div> } @else if (integration(); as current) {
<section class="summary-grid" aria-label="Integration overview">
<article>
<h2>Name</h2>
<p>{{ current.name }}</p>
</article>
<article>
<h2>Type</h2>
<p>{{ getTypeLabel(current.type) }}</p>
</article>
<article>
<h2>Status</h2>
<p>{{ getStatusLabel(current.status) }}</p>
</article>
<article>
<h2>Last Sync</h2>
<p>{{ current.lastSyncAt ? (current.lastSyncAt | date : 'medium') : 'Not yet available' }}</p>
</article>
</section>
<div class="content"> <nav class="tabs" aria-label="Integration detail tabs">
<div class="card"> @for (tab of tabs; track tab) {
<h3>Integration Overview</h3> <button
<p>Configure and monitor this integration's connection status, permissions, and activity.</p> type="button"
</div> class="tab"
</div> [class.tab--active]="activeTab() === tab"
(click)="activeTab.set(tab)">
{{ tab }}
</button>
}
</nav>
<section class="content" aria-live="polite">
@if (activeTab() === 'Overview') {
<div class="card">
<h3>Integration Overview</h3>
<p>{{ current.description || 'No description provided.' }}</p>
<dl>
<div>
<dt>Integration ID</dt>
<dd>{{ current.integrationId }}</dd>
</div>
<div>
<dt>Tenant</dt>
<dd>{{ current.tenantId }}</dd>
</div>
<div>
<dt>Endpoint</dt>
<dd>{{ current.baseUrl || 'Not configured' }}</dd>
</div>
</dl>
</div>
} @else {
<div class="card card--placeholder">
<h3>{{ activeTab() }}</h3>
<p>Not yet available for this integration. Implementation is tracked in the related sprint tasks.</p>
</div>
}
</section>
} @else {
<section class="state-card">
No integration found for this ID.
</section>
}
</div> </div>
`, `,
styles: [` styles: [`
.integration-detail { .integration-detail {
max-width: 1000px; max-width: 1000px;
} }
@@ -64,11 +134,38 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.summary-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
margin-bottom: 1rem;
}
.summary-grid article {
padding: 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.summary-grid h2 {
margin: 0 0 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.summary-grid p {
margin: 0;
font-size: 0.9rem;
}
.tabs { .tabs {
display: flex; display: flex;
gap: 0.25rem; gap: 0.25rem;
border-bottom: 1px solid var(--color-border-primary); border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 1.5rem; margin-bottom: 1rem;
} }
.tab { .tab {
@@ -92,33 +189,151 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
border-bottom-color: var(--color-brand-primary); border-bottom-color: var(--color-brand-primary);
} }
.state-card {
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.state-card--error {
border-color: rgba(248, 113, 113, 0.4);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
.card { .card {
padding: 1.5rem; padding: 1rem;
background: var(--color-surface-primary); background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-md);
display: grid;
gap: 0.75rem;
} }
.card h3 { .card h3 {
margin: 0 0 0.5rem; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: var(--font-weight-semibold);
} }
.card p { .card p {
margin: 0; margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
`]
.card dl {
margin: 0;
display: grid;
gap: 0.5rem;
}
.card dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.card dd {
margin: 0;
word-break: break-word;
}
.card--placeholder {
background: var(--color-surface-secondary);
}
.btn {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
cursor: pointer;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
`],
}) })
export class IntegrationDetailPageComponent { export class IntegrationDetailPageComponent {
private route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly integrationService = inject(IntegrationService);
private readonly destroyRef = inject(DestroyRef);
integrationId = signal(''); readonly integrationId = signal('');
readonly integration = signal<Integration | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly activeTab = signal('Overview');
readonly tabs = ['Overview', 'Health', 'Activity', 'Permissions', 'Secrets', 'Webhooks'] as const;
constructor() { constructor() {
this.route.params.subscribe(params => { this.route.paramMap
this.integrationId.set(params['id'] || ''); .pipe(takeUntilDestroyed(this.destroyRef))
}); .subscribe((params) => {
const id = params.get('id') ?? '';
this.integrationId.set(id);
this.loadIntegration(id);
});
}
readonly pageTitle = () => {
const current = this.integration();
if (current) {
return current.name;
}
const id = this.integrationId();
return id ? `Integration ${id}` : 'Integration Detail';
};
readonly pageSubtitle = () => {
const current = this.integration();
if (current) {
return `Settings / Integrations / ${current.name}`;
}
const id = this.integrationId();
return id ? `Loading integration ${id}` : 'Integration details';
};
reload(): void {
this.loadIntegration(this.integrationId());
}
getTypeLabel(type: number): string {
return getIntegrationTypeLabel(type);
}
getStatusLabel(status: number): string {
return getIntegrationStatusLabel(status);
}
private loadIntegration(id: string): void {
if (!id) {
this.integration.set(null);
this.loading.set(false);
this.error.set('Integration ID is required.');
return;
}
this.loading.set(true);
this.error.set(null);
this.integrationService
.get(id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
this.integration.set(response);
this.loading.set(false);
},
error: () => {
this.integration.set(null);
this.loading.set(false);
this.error.set('Failed to load integration details.');
},
});
} }
} }

View File

@@ -4,40 +4,41 @@
*/ */
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-release-control-settings-page', selector: 'app-release-control-settings-page',
imports: [], imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="release-control-settings"> <div class="release-control-settings">
<h1 class="page-title">Release Control</h1> <h1 class="page-title">Release Control Settings</h1>
<p class="page-subtitle">Configure environments, targets, agents, and workflows</p> <p class="page-subtitle">Configure environments, targets, agents, and workflows.</p>
<div class="settings-grid"> <div class="settings-grid">
<section class="settings-section"> <section class="settings-section">
<h2>Environments</h2> <h2>Environments</h2>
<p>Configure release environments and promotion paths.</p> <p>Configure release environments and promotion paths.</p>
<button type="button" class="btn btn--secondary">Manage Environments</button> <a class="btn btn--secondary" routerLink="/release-control/setup/environments-paths">Manage Environments</a>
</section> </section>
<section class="settings-section"> <section class="settings-section">
<h2>Targets</h2> <h2>Targets</h2>
<p>Configure deployment targets for each environment.</p> <p>Configure deployment targets for each environment.</p>
<button type="button" class="btn btn--secondary">Manage Targets</button> <a class="btn btn--secondary" routerLink="/release-control/setup/targets-agents">Manage Targets</a>
</section> </section>
<section class="settings-section"> <section class="settings-section">
<h2>Agents</h2> <h2>Agents</h2>
<p>Manage deployment agents and their configurations.</p> <p>Manage deployment agents and their configurations.</p>
<button type="button" class="btn btn--secondary">Manage Agents</button> <a class="btn btn--secondary" routerLink="/release-control/setup/targets-agents">Manage Agents</a>
</section> </section>
<section class="settings-section"> <section class="settings-section">
<h2>Workflows</h2> <h2>Workflows</h2>
<p>Configure deployment workflow templates.</p> <p>Configure deployment workflow templates.</p>
<button type="button" class="btn btn--secondary">Edit Workflows</button> <a class="btn btn--secondary" routerLink="/release-control/setup/workflows">Edit Workflows</a>
</section> </section>
</div> </div>
</div> </div>
@@ -60,6 +61,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
.settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); } .settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } .settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; } .btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; }
.btn { display: inline-flex; text-decoration: none; color: var(--color-text-primary); }
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
`] `]
}) })

View File

@@ -8,6 +8,7 @@ import { Routes } from '@angular/router';
export const SETTINGS_ROUTES: Routes = [ export const SETTINGS_ROUTES: Routes = [
{ {
path: '', path: '',
title: 'Settings',
loadComponent: () => loadComponent: () =>
import('./settings-page.component').then(m => m.SettingsPageComponent), import('./settings-page.component').then(m => m.SettingsPageComponent),
data: { breadcrumb: 'Settings' }, data: { breadcrumb: 'Settings' },
@@ -19,96 +20,112 @@ export const SETTINGS_ROUTES: Routes = [
}, },
{ {
path: 'integrations', path: 'integrations',
title: 'Integrations',
loadComponent: () => loadComponent: () =>
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent), import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
data: { breadcrumb: 'Integrations' }, data: { breadcrumb: 'Integrations' },
}, },
{ {
path: 'integrations/:id', path: 'integrations/:id',
title: 'Integration Detail',
loadComponent: () => loadComponent: () =>
import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent), import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent),
data: { breadcrumb: 'Integration Detail' }, data: { breadcrumb: 'Integration Detail' },
}, },
{ {
path: 'configuration-pane', path: 'configuration-pane',
title: 'Configuration Pane',
loadComponent: () => loadComponent: () =>
import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent), import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent),
data: { breadcrumb: 'Configuration Pane' }, data: { breadcrumb: 'Configuration Pane' },
}, },
{ {
path: 'release-control', path: 'release-control',
title: 'Release Control',
loadComponent: () => loadComponent: () =>
import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent), import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent),
data: { breadcrumb: 'Release Control' }, data: { breadcrumb: 'Release Control' },
}, },
{ {
path: 'trust', path: 'trust',
title: 'Trust & Signing',
loadComponent: () => loadComponent: () =>
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent), import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
data: { breadcrumb: 'Trust & Signing' }, data: { breadcrumb: 'Trust & Signing' },
}, },
{ {
path: 'trust/:page', path: 'trust/:page',
title: 'Trust & Signing',
loadComponent: () => loadComponent: () =>
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent), import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
data: { breadcrumb: 'Trust & Signing' }, data: { breadcrumb: 'Trust & Signing' },
}, },
{ {
path: 'security-data', path: 'security-data',
title: 'Security Data',
loadComponent: () => loadComponent: () =>
import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent), import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent),
data: { breadcrumb: 'Security Data' }, data: { breadcrumb: 'Security Data' },
}, },
{ {
path: 'admin', path: 'admin',
title: 'Identity & Access',
loadComponent: () => loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent), import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' }, data: { breadcrumb: 'Identity & Access' },
}, },
{ {
path: 'admin/:page', path: 'admin/:page',
title: 'Identity & Access',
loadComponent: () => loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent), import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' }, data: { breadcrumb: 'Identity & Access' },
}, },
{ {
path: 'branding', path: 'branding',
title: 'Tenant & Branding',
loadComponent: () => loadComponent: () =>
import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent), import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent),
data: { breadcrumb: 'Branding' }, data: { breadcrumb: 'Tenant & Branding' },
}, },
{ {
path: 'usage', path: 'usage',
title: 'Usage & Limits',
loadComponent: () => loadComponent: () =>
import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent), import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent),
data: { breadcrumb: 'Usage & Limits' }, data: { breadcrumb: 'Usage & Limits' },
}, },
{ {
path: 'notifications', path: 'notifications',
title: 'Notifications',
loadComponent: () => loadComponent: () =>
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent), import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
data: { breadcrumb: 'Notifications' }, data: { breadcrumb: 'Notifications' },
}, },
{ {
path: 'ai-preferences', path: 'ai-preferences',
title: 'AI Preferences',
loadComponent: () => loadComponent: () =>
import('./ai-preferences-workbench.component').then(m => m.AiPreferencesWorkbenchComponent), import('./ai-preferences-workbench.component').then(m => m.AiPreferencesWorkbenchComponent),
data: { breadcrumb: 'AI Preferences' }, data: { breadcrumb: 'AI Preferences' },
}, },
{ {
path: 'policy', path: 'policy',
title: 'Policy Governance',
loadComponent: () => loadComponent: () =>
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent), import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
data: { breadcrumb: 'Policy Governance' }, data: { breadcrumb: 'Policy Governance' },
}, },
{ {
path: 'offline', path: 'offline',
title: 'Offline Settings',
loadComponent: () => loadComponent: () =>
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent), import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
data: { breadcrumb: 'Offline Settings' }, data: { breadcrumb: 'Offline Settings' },
}, },
{ {
path: 'system', path: 'system',
title: 'System',
loadComponent: () => loadComponent: () =>
import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent), import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent),
data: { breadcrumb: 'System' }, data: { breadcrumb: 'System' },

View File

@@ -29,11 +29,14 @@ describe('AppSidebarComponent', () => {
expect(fixture.nativeElement.textContent).not.toContain('Analytics'); expect(fixture.nativeElement.textContent).not.toContain('Analytics');
}); });
it('shows analytics navigation when analytics scope is present', () => { it('keeps canonical v2 root nav even when analytics scope is present', () => {
setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ]); setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.ANALYTICS_READ]);
const fixture = createComponent(); const fixture = createComponent();
expect(fixture.nativeElement.textContent).toContain('Analytics'); const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Dashboard');
expect(text).toContain('Security & Risk');
expect(text).not.toContain('Analytics');
}); });
function setScopes(scopes: readonly StellaOpsScope[]): void { function setScopes(scopes: readonly StellaOpsScope[]): void {

View File

@@ -9,11 +9,15 @@ import {
computed, computed,
NgZone, NgZone,
OnDestroy, OnDestroy,
DestroyRef,
} from '@angular/core'; } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
import type { StellaOpsScope } from '../../core/auth'; import type { StellaOpsScope } from '../../core/auth';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client';
import { SidebarNavGroupComponent } from './sidebar-nav-group.component'; import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component'; import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
@@ -316,6 +320,8 @@ export class AppSidebarComponent implements OnDestroy {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly ngZone = inject(NgZone); private readonly ngZone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
@Input() collapsed = false; @Input() collapsed = false;
@Output() toggleCollapse = new EventEmitter<void>(); @Output() toggleCollapse = new EventEmitter<void>();
@@ -324,8 +330,9 @@ export class AppSidebarComponent implements OnDestroy {
/** Whether sidebar is temporarily expanded due to hover */ /** Whether sidebar is temporarily expanded due to hover */
readonly hoverExpanded = signal(false); readonly hoverExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null; private hoverTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0);
/** Track which groups are expanded — default open: Release Control, Security and Risk */ /** Track which groups are expanded — default open: Release Control, Security & Risk */
readonly expandedGroups = signal<Set<string>>(new Set(['release-control', 'security-risk'])); readonly expandedGroups = signal<Set<string>>(new Set(['release-control', 'security-risk']));
/** /**
@@ -392,9 +399,21 @@ export class AppSidebarComponent implements OnDestroy {
{ {
id: 'rc-environments', id: 'rc-environments',
label: 'Regions & Environments', label: 'Regions & Environments',
route: '/release-control/environments', route: '/release-control/regions',
icon: 'server', icon: 'server',
}, },
{
id: 'rc-governance',
label: 'Governance',
route: '/release-control/governance',
icon: 'shield',
},
{
id: 'rc-hotfixes',
label: 'Hotfixes',
route: '/release-control/hotfixes',
icon: 'zap',
},
{ {
id: 'rc-setup', id: 'rc-setup',
label: 'Setup', label: 'Setup',
@@ -404,20 +423,22 @@ export class AppSidebarComponent implements OnDestroy {
], ],
}, },
// 3. Security and Risk // 3. Security & Risk
{ {
id: 'security-risk', id: 'security-risk',
label: 'Security and Risk', label: 'Security & Risk',
icon: 'shield', icon: 'shield',
route: '/security-risk', route: '/security-risk',
children: [ children: [
{ id: 'sr-overview', label: 'Overview', route: '/security-risk', icon: 'chart' }, { id: 'sr-overview', label: 'Risk Overview', route: '/security-risk', icon: 'chart' },
{ id: 'sr-findings', label: 'Findings', route: '/security-risk/findings', icon: 'list' },
{ id: 'sr-vulnerabilities', label: 'Vulnerabilities', route: '/security-risk/vulnerabilities', icon: 'alert' },
{ id: 'sr-reachability', label: 'Reachability', route: '/security-risk/reachability', icon: 'git-branch' },
{ id: 'sr-sbom', label: 'SBOM Graph', route: '/security-risk/sbom', icon: 'graph' },
{ id: 'sr-vex', label: 'VEX Hub', route: '/security-risk/vex', icon: 'file-check' },
{ id: 'sr-advisory-sources', label: 'Advisory Sources', route: '/security-risk/advisory-sources', icon: 'radio' }, { id: 'sr-advisory-sources', label: 'Advisory Sources', route: '/security-risk/advisory-sources', icon: 'radio' },
{ id: 'sr-findings', label: 'Findings Explorer', route: '/security-risk/findings', icon: 'list' },
{ id: 'sr-vulnerabilities', label: 'Vulnerabilities Explorer', route: '/security-risk/vulnerabilities', icon: 'alert' },
{ id: 'sr-reachability', label: 'Reachability', route: '/security-risk/reachability', icon: 'git-branch' },
{ id: 'sr-sbom', label: 'SBOM Data: Graph', route: '/security-risk/sbom', icon: 'graph' },
{ id: 'sr-sbom-lake', label: 'SBOM Data: Lake', route: '/security-risk/sbom-lake', icon: 'database' },
{ id: 'sr-vex', label: 'VEX & Exceptions: VEX Hub', route: '/security-risk/vex', icon: 'file-check' },
{ id: 'sr-exceptions', label: 'VEX & Exceptions: Exceptions', route: '/security-risk/exceptions', icon: 'shield-off' },
{ id: 'sr-symbol-sources', label: 'Symbol Sources', route: '/security-risk/symbol-sources', icon: 'package' }, { id: 'sr-symbol-sources', label: 'Symbol Sources', route: '/security-risk/symbol-sources', icon: 'package' },
{ id: 'sr-symbol-marketplace', label: 'Symbol Marketplace', route: '/security-risk/symbol-marketplace', icon: 'shopping-bag' }, { id: 'sr-symbol-marketplace', label: 'Symbol Marketplace', route: '/security-risk/symbol-marketplace', icon: 'shopping-bag' },
{ id: 'sr-remediation', label: 'Remediation', route: '/security-risk/remediation', icon: 'tool' }, { id: 'sr-remediation', label: 'Remediation', route: '/security-risk/remediation', icon: 'tool' },
@@ -427,16 +448,20 @@ export class AppSidebarComponent implements OnDestroy {
// 4. Evidence and Audit // 4. Evidence and Audit
{ {
id: 'evidence-audit', id: 'evidence-audit',
label: 'Evidence and Audit', label: 'Evidence & Audit',
icon: 'file-text', icon: 'file-text',
route: '/evidence-audit', route: '/evidence-audit',
children: [ children: [
{ id: 'ea-packets', label: 'Evidence Packets', route: '/evidence-audit', icon: 'archive' }, { id: 'ea-home', label: 'Home', route: '/evidence-audit', icon: 'home' },
{ id: 'ea-packs', label: 'Evidence Packs', route: '/evidence-audit/packs', icon: 'archive' },
{ id: 'ea-bundles', label: 'Evidence Bundles', route: '/evidence-audit/bundles', icon: 'package' },
{ id: 'ea-export', label: 'Export Center', route: '/evidence-audit/evidence', icon: 'download' },
{ id: 'ea-proof-chains', label: 'Proof Chains', route: '/evidence-audit/proofs', icon: 'link' }, { id: 'ea-proof-chains', label: 'Proof Chains', route: '/evidence-audit/proofs', icon: 'link' },
{ id: 'ea-audit', label: 'Audit Log', route: '/evidence-audit/audit', icon: 'book-open' }, { id: 'ea-audit', label: 'Audit Log', route: '/evidence-audit/audit-log', icon: 'book-open' },
{ id: 'ea-change-trace', label: 'Change Trace', route: '/evidence-audit/change-trace', icon: 'git-commit' }, { id: 'ea-change-trace', label: 'Change Trace', route: '/evidence-audit/change-trace', icon: 'git-commit' },
{ id: 'ea-timeline', label: 'Timeline', route: '/evidence-audit/timeline', icon: 'clock' }, { id: 'ea-timeline', label: 'Timeline', route: '/evidence-audit/timeline', icon: 'clock' },
{ id: 'ea-replay', label: 'Replay / Verify', route: '/evidence-audit/replay', icon: 'refresh' }, { id: 'ea-replay', label: 'Replay & Verify', route: '/evidence-audit/replay', icon: 'refresh' },
{ id: 'ea-trust-signing', label: 'Trust & Signing', route: '/evidence-audit/trust-signing', icon: 'key' },
], ],
}, },
@@ -449,10 +474,10 @@ export class AppSidebarComponent implements OnDestroy {
children: [ children: [
{ id: 'int-hub', label: 'Hub', route: '/integrations', icon: 'grid' }, { id: 'int-hub', label: 'Hub', route: '/integrations', icon: 'grid' },
{ id: 'int-scm', label: 'SCM', route: '/integrations/scm', icon: 'git-branch' }, { id: 'int-scm', label: 'SCM', route: '/integrations/scm', icon: 'git-branch' },
{ id: 'int-ci', label: 'CI/CD', route: '/integrations/ci', icon: 'play' }, { id: 'int-ci', label: 'CI/CD', route: '/integrations/ci-cd', icon: 'play' },
{ id: 'int-registries', label: 'Registries', route: '/integrations/registries', icon: 'box' }, { id: 'int-registries', label: 'Registries', route: '/integrations/registries', icon: 'box' },
{ id: 'int-secrets', label: 'Secrets', route: '/integrations/secrets', icon: 'key' }, { id: 'int-secrets', label: 'Secrets', route: '/integrations/secrets', icon: 'key' },
{ id: 'int-targets', label: 'Targets / Runtimes', route: '/integrations/hosts', icon: 'package' }, { id: 'int-targets', label: 'Targets / Runtimes', route: '/integrations/targets', icon: 'package' },
{ id: 'int-feeds', label: 'Feeds', route: '/integrations/feeds', icon: 'rss' }, { id: 'int-feeds', label: 'Feeds', route: '/integrations/feeds', icon: 'rss' },
], ],
}, },
@@ -488,7 +513,7 @@ export class AppSidebarComponent implements OnDestroy {
{ id: 'adm-notifications', label: 'Notifications', route: '/administration/notifications', icon: 'bell' }, { id: 'adm-notifications', label: 'Notifications', route: '/administration/notifications', icon: 'bell' },
{ id: 'adm-usage', label: 'Usage & Limits', route: '/administration/usage', icon: 'bar-chart' }, { id: 'adm-usage', label: 'Usage & Limits', route: '/administration/usage', icon: 'bar-chart' },
{ id: 'adm-policy', label: 'Policy Governance', route: '/administration/policy-governance', icon: 'book' }, { id: 'adm-policy', label: 'Policy Governance', route: '/administration/policy-governance', icon: 'book' },
{ id: 'adm-trust', label: 'Trust & Signing', route: '/administration/trust-signing', icon: 'key' }, { id: 'adm-offline', label: 'Offline Settings', route: '/administration/offline', icon: 'download-cloud' },
{ id: 'adm-system', label: 'System', route: '/administration/system', icon: 'terminal' }, { id: 'adm-system', label: 'System', route: '/administration/system', icon: 'terminal' },
], ],
}, },
@@ -501,6 +526,17 @@ export class AppSidebarComponent implements OnDestroy {
.filter((section): section is NavSection => section !== null); .filter((section): section is NavSection => section !== null);
}); });
constructor() {
this.loadPendingApprovalsBadge();
this.router.events
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge();
}
});
}
private filterSection(section: NavSection): NavSection | null { private filterSection(section: NavSection): NavSection | null {
if (section.requiredScopes && !this.hasAllScopes(section.requiredScopes)) { if (section.requiredScopes && !this.hasAllScopes(section.requiredScopes)) {
return null; return null;
@@ -522,9 +558,22 @@ export class AppSidebarComponent implements OnDestroy {
return null; return null;
} }
const children = visibleChildren.map((child) => this.withDynamicChildState(child));
return { return {
...section, ...section,
children: visibleChildren, children,
};
}
private withDynamicChildState(item: NavItem): NavItem {
if (item.id !== 'rc-approvals') {
return item;
}
return {
...item,
badge: this.pendingApprovalsCount(),
}; };
} }
@@ -548,6 +597,19 @@ export class AppSidebarComponent implements OnDestroy {
return this.authService.hasAnyScope(scopes); return this.authService.hasAnyScope(scopes);
} }
private loadPendingApprovalsBadge(): void {
if (!this.approvalApi) {
return;
}
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe({
next: (approvals) => this.pendingApprovalsCount.set(approvals.length),
error: () => this.pendingApprovalsCount.set(0),
});
}
onSidebarMouseEnter(): void { onSidebarMouseEnter(): void {
if (!this.collapsed || this.hoverExpanded()) return; if (!this.collapsed || this.hoverExpanded()) return;
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {

View File

@@ -31,7 +31,7 @@ export interface NavItem {
[class.nav-item--child]="isChild" [class.nav-item--child]="isChild"
[routerLink]="route" [routerLink]="route"
routerLinkActive="nav-item--active" routerLinkActive="nav-item--active"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: !isChild }"
[attr.title]="collapsed ? label : null" [attr.title]="collapsed ? label : null"
> >
<span class="nav-item__icon" [attr.aria-hidden]="true"> <span class="nav-item__icon" [attr.aria-hidden]="true">

View File

@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip" class="chip"
[class.chip--fresh]="!isStale()" [class.chip--fresh]="!isStale()"
[class.chip--stale]="isStale()" [class.chip--stale]="isStale()"
routerLink="/operations/feeds" routerLink="/platform-ops/feeds"
[attr.title]="tooltip()" [attr.title]="tooltip()"
> >
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"> <svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">

View File

@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip" class="chip"
[class.chip--ok]="status() === 'ok'" [class.chip--ok]="status() === 'ok'"
[class.chip--degraded]="status() === 'degraded'" [class.chip--degraded]="status() === 'degraded'"
routerLink="/settings/offline" routerLink="/administration/offline"
[attr.title]="tooltip()" [attr.title]="tooltip()"
aria-live="polite" aria-live="polite"
> >

View File

@@ -235,6 +235,15 @@ export const ADMINISTRATION_ROUTES: Routes = [
(m) => m.SystemSettingsPageComponent (m) => m.SystemSettingsPageComponent
), ),
}, },
{
path: 'offline',
title: 'Offline Settings',
data: { breadcrumb: 'Offline Settings' },
loadComponent: () =>
import('../features/offline-kit/components/offline-dashboard.component').then(
(m) => m.OfflineDashboardComponent
),
},
// Configuration pane (formerly /console/configuration) // Configuration pane (formerly /console/configuration)
{ {
path: 'configuration-pane', path: 'configuration-pane',

View File

@@ -5,26 +5,132 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const EVIDENCE_AUDIT_ROUTES: Routes = [ export const EVIDENCE_AUDIT_ROUTES: Routes = [
{ path: '', title: 'Evidence and Audit', data: { breadcrumb: 'Evidence and Audit' }, {
loadComponent: () => import('../features/evidence-audit/evidence-audit-overview.component').then(m => m.EvidenceAuditOverviewComponent) }, path: '',
{ path: 'packs', title: 'Evidence Packs', data: { breadcrumb: 'Evidence Packs' }, title: 'Evidence & Audit',
loadComponent: () => import('../features/evidence-pack/evidence-pack-list.component').then(m => m.EvidencePackListComponent) }, data: { breadcrumb: 'Evidence & Audit' },
{ path: 'packs/:packId', title: 'Evidence Pack', data: { breadcrumb: 'Evidence Pack' }, loadComponent: () =>
loadComponent: () => import('../features/evidence-pack/evidence-pack-viewer.component').then(m => m.EvidencePackViewerComponent) }, import('../features/evidence-audit/evidence-audit-overview.component').then(
{ path: 'proofs', title: 'Proof Chains', data: { breadcrumb: 'Proof Chains' }, (m) => m.EvidenceAuditOverviewComponent
loadComponent: () => import('../features/proof-chain/proof-chain.component').then(m => m.ProofChainComponent) }, ),
{ path: 'proofs/:subjectDigest', title: 'Proof Chain', data: { breadcrumb: 'Proof Chain' }, },
loadComponent: () => import('../features/proof-chain/proof-chain.component').then(m => m.ProofChainComponent) }, {
{ path: 'timeline', title: 'Timeline', data: { breadcrumb: 'Timeline' }, path: 'home',
loadChildren: () => import('../features/timeline/timeline.routes').then(m => m.TIMELINE_ROUTES) }, pathMatch: 'full',
{ path: 'replay', title: 'Replay / Verify', data: { breadcrumb: 'Replay / Verify' }, redirectTo: '',
loadComponent: () => import('../features/evidence-export/replay-controls.component').then(m => m.ReplayControlsComponent) }, },
{ path: 'receipts/cvss/:receiptId', title: 'CVSS Receipt', data: { breadcrumb: 'CVSS Receipt' }, {
loadComponent: () => import('../features/cvss/cvss-receipt.component').then(m => m.CvssReceiptComponent) }, path: 'packs',
{ path: 'audit', title: 'Audit Log', data: { breadcrumb: 'Audit Log' }, title: 'Evidence Packs',
loadChildren: () => import('../features/audit-log/audit-log.routes').then(m => m.auditLogRoutes) }, data: { breadcrumb: 'Evidence Packs' },
{ path: 'change-trace', title: 'Change Trace', data: { breadcrumb: 'Change Trace' }, loadComponent: () =>
loadChildren: () => import('../features/change-trace/change-trace.routes').then(m => m.changeTraceRoutes) }, import('../features/evidence-pack/evidence-pack-list.component').then(
{ path: 'evidence', title: 'Evidence', data: { breadcrumb: 'Evidence' }, (m) => m.EvidencePackListComponent
loadChildren: () => import('../features/evidence-export/evidence-export.routes').then(m => m.evidenceExportRoutes) }, ),
},
{
path: 'packs/:packId',
title: 'Evidence Pack',
data: { breadcrumb: 'Evidence Pack' },
loadComponent: () =>
import('../features/evidence-pack/evidence-pack-viewer.component').then(
(m) => m.EvidencePackViewerComponent
),
},
{
path: 'bundles',
title: 'Evidence Bundles',
data: { breadcrumb: 'Evidence Bundles' },
loadComponent: () =>
import('../features/evidence-export/evidence-bundles.component').then(
(m) => m.EvidenceBundlesComponent
),
},
{
path: 'proofs',
title: 'Proof Chains',
data: { breadcrumb: 'Proof Chains' },
loadComponent: () =>
import('../features/proof-chain/proof-chain.component').then(
(m) => m.ProofChainComponent
),
},
{
path: 'proofs/:subjectDigest',
title: 'Proof Chain',
data: { breadcrumb: 'Proof Chain' },
loadComponent: () =>
import('../features/proof-chain/proof-chain.component').then(
(m) => m.ProofChainComponent
),
},
{
path: 'timeline',
title: 'Timeline',
data: { breadcrumb: 'Timeline' },
loadChildren: () =>
import('../features/timeline/timeline.routes').then((m) => m.TIMELINE_ROUTES),
},
{
path: 'replay',
title: 'Replay & Verify',
data: { breadcrumb: 'Replay & Verify' },
loadComponent: () =>
import('../features/evidence-export/replay-controls.component').then(
(m) => m.ReplayControlsComponent
),
},
{
path: 'receipts/cvss/:receiptId',
title: 'CVSS Receipt',
data: { breadcrumb: 'CVSS Receipt' },
loadComponent: () =>
import('../features/cvss/cvss-receipt.component').then((m) => m.CvssReceiptComponent),
},
{
path: 'audit-log',
title: 'Audit Log',
data: { breadcrumb: 'Audit Log' },
loadChildren: () =>
import('../features/audit-log/audit-log.routes').then((m) => m.auditLogRoutes),
},
{
path: 'audit',
pathMatch: 'full',
redirectTo: 'audit-log',
},
{
path: 'change-trace',
title: 'Change Trace',
data: { breadcrumb: 'Change Trace' },
loadChildren: () =>
import('../features/change-trace/change-trace.routes').then((m) => m.changeTraceRoutes),
},
{
path: 'trust-signing',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent
),
},
{
path: 'trust-signing/:page',
title: 'Trust & Signing',
data: { breadcrumb: 'Trust & Signing' },
loadComponent: () =>
import('../features/settings/trust/trust-settings-page.component').then(
(m) => m.TrustSettingsPageComponent
),
},
{
path: 'evidence',
title: 'Export Center',
data: { breadcrumb: 'Export Center' },
loadChildren: () =>
import('../features/evidence-export/evidence-export.routes').then(
(m) => m.evidenceExportRoutes
),
},
]; ];

View File

@@ -60,6 +60,14 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
{ path: 'findings', redirectTo: '/security-risk/findings', pathMatch: 'full' }, { path: 'findings', redirectTo: '/security-risk/findings', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' }, { path: 'findings/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' },
{ path: 'security/findings', redirectTo: '/security-risk/findings', pathMatch: 'full' },
{ path: 'security/findings/:findingId', redirectTo: '/security-risk/findings/:findingId', pathMatch: 'full' },
{ path: 'security/vulnerabilities', redirectTo: '/security-risk/vulnerabilities', pathMatch: 'full' },
{ path: 'security/vulnerabilities/:vulnId', redirectTo: '/security-risk/vulnerabilities/:vulnId', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security-risk/sbom', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security-risk/vex', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security-risk/exceptions', pathMatch: 'full' },
{ path: 'security/advisory-sources', redirectTo: '/security-risk/advisory-sources', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' }, { path: 'scans/:scanId', redirectTo: '/security-risk/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security-risk/vulnerabilities', pathMatch: 'full' }, { path: 'vulnerabilities', redirectTo: '/security-risk/vulnerabilities', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security-risk/vulnerabilities/:vulnId', pathMatch: 'full' }, { path: 'vulnerabilities/:vulnId', redirectTo: '/security-risk/vulnerabilities/:vulnId', pathMatch: 'full' },
@@ -71,6 +79,8 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'reachability', redirectTo: '/security-risk/reachability', pathMatch: 'full' }, { path: 'reachability', redirectTo: '/security-risk/reachability', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security-risk/unknowns', pathMatch: 'full' }, { path: 'analyze/unknowns', redirectTo: '/security-risk/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security-risk/patch-map', pathMatch: 'full' }, { path: 'analyze/patch-map', redirectTo: '/security-risk/patch-map', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security-risk/sbom-lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security-risk/sbom-lake', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence-audit/receipts/cvss/:receiptId', pathMatch: 'full' }, { path: 'cvss/receipts/:receiptId', redirectTo: '/evidence-audit/receipts/cvss/:receiptId', pathMatch: 'full' },
// =========================================== // ===========================================
@@ -154,11 +164,28 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
{ path: 'sbom-sources', redirectTo: '/integrations/sbom-sources', pathMatch: 'full' }, { path: 'sbom-sources', redirectTo: '/integrations/sbom-sources', pathMatch: 'full' },
// ===========================================
// Settings -> canonical v2 domains
// ===========================================
{ path: 'settings/integrations', redirectTo: '/integrations', pathMatch: 'full' },
{ path: 'settings/integrations/:id', redirectTo: '/integrations/:id', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/release-control/setup', pathMatch: 'full' },
{ path: 'settings/trust', redirectTo: '/evidence-audit/trust-signing', pathMatch: 'full' },
{ path: 'settings/trust/:page', redirectTo: '/evidence-audit/trust-signing/:page', pathMatch: 'full' },
{ path: 'settings/policy', redirectTo: '/release-control/governance', pathMatch: 'full' },
{ path: 'settings/admin', redirectTo: '/administration/identity-access', pathMatch: 'full' },
{ path: 'settings/branding', redirectTo: '/administration/tenant-branding', pathMatch: 'full' },
{ path: 'settings/notifications', redirectTo: '/administration/notifications', pathMatch: 'full' },
{ path: 'settings/usage', redirectTo: '/administration/usage', pathMatch: 'full' },
{ path: 'settings/system', redirectTo: '/administration/system', pathMatch: 'full' },
{ path: 'settings/offline', redirectTo: '/administration/offline', pathMatch: 'full' },
{ path: 'settings/security-data', redirectTo: '/integrations/feeds', pathMatch: 'full' },
// =========================================== // ===========================================
// Release Orchestrator -> Release Control // Release Orchestrator -> Release Control
// =========================================== // ===========================================
{ path: 'release-orchestrator', redirectTo: '/', pathMatch: 'full' }, { path: 'release-orchestrator', redirectTo: '/', pathMatch: 'full' },
{ path: 'release-orchestrator/environments', redirectTo: '/release-control/environments', pathMatch: 'full' }, { path: 'release-orchestrator/environments', redirectTo: '/release-control/regions', pathMatch: 'full' },
{ path: 'release-orchestrator/releases', redirectTo: '/release-control/releases', pathMatch: 'full' }, { path: 'release-orchestrator/releases', redirectTo: '/release-control/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/release-control/approvals', pathMatch: 'full' }, { path: 'release-orchestrator/approvals', redirectTo: '/release-control/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/release-control/deployments', pathMatch: 'full' }, { path: 'release-orchestrator/deployments', redirectTo: '/release-control/deployments', pathMatch: 'full' },
@@ -168,6 +195,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// =========================================== // ===========================================
// Evidence -> Evidence & Audit // Evidence -> Evidence & Audit
// =========================================== // ===========================================
{ path: 'evidence/packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' },
{ path: 'evidence/bundles', redirectTo: '/evidence-audit/bundles', pathMatch: 'full' },
{ path: 'evidence/export', redirectTo: '/evidence-audit/evidence/export', pathMatch: 'full' },
{ path: 'evidence/replay', redirectTo: '/evidence-audit/replay', pathMatch: 'full' },
{ path: 'evidence/proof-chains', redirectTo: '/evidence-audit/proofs', pathMatch: 'full' },
{ path: 'evidence/audit-log', redirectTo: '/evidence-audit/audit-log', pathMatch: 'full' },
{ path: 'evidence-packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' }, { path: 'evidence-packs', redirectTo: '/evidence-audit/packs', pathMatch: 'full' },
{ path: 'evidence-packs/:packId', redirectTo: '/evidence-audit/packs/:packId', pathMatch: 'full' }, { path: 'evidence-packs/:packId', redirectTo: '/evidence-audit/packs/:packId', pathMatch: 'full' },
// Keep /proofs/* as permanent short alias for convenience // Keep /proofs/* as permanent short alias for convenience

View File

@@ -145,9 +145,9 @@ export const PLATFORM_OPS_ROUTES: Routes = [
path: 'data-integrity', path: 'data-integrity',
title: 'Data Integrity', title: 'Data Integrity',
data: { breadcrumb: 'Data Integrity' }, data: { breadcrumb: 'Data Integrity' },
loadComponent: () => loadChildren: () =>
import('../features/platform-ops/data-integrity-overview.component').then( import('../features/platform-ops/data-integrity/data-integrity.routes').then(
(m) => m.DataIntegrityOverviewComponent (m) => m.dataIntegrityRoutes
), ),
}, },
{ {

View File

@@ -11,10 +11,21 @@ import { Routes } from '@angular/router';
export const RELEASE_CONTROL_ROUTES: Routes = [ export const RELEASE_CONTROL_ROUTES: Routes = [
{ {
path: '', path: '',
redirectTo: 'releases', redirectTo: 'control-plane',
pathMatch: 'full', pathMatch: 'full',
}, },
// Control plane entry under Release Control
{
path: 'control-plane',
title: 'Control Plane',
data: { breadcrumb: 'Control Plane' },
loadComponent: () =>
import('../features/control-plane/control-plane-dashboard.component').then(
(m) => m.ControlPlaneDashboardComponent
),
},
// Setup hub and setup child pages (Pack 21 migration from Settings -> Release Control) // Setup hub and setup child pages (Pack 21 migration from Settings -> Release Control)
{ {
path: 'setup', path: 'setup',
@@ -104,16 +115,68 @@ export const RELEASE_CONTROL_ROUTES: Routes = [
), ),
}, },
// Environments // Regions & Environments (region-first canonical structure)
{ {
path: 'environments', path: 'regions',
title: 'Regions & Environments', title: 'Regions & Environments',
data: { breadcrumb: 'Regions & Environments' }, data: { breadcrumb: 'Regions & Environments' },
loadChildren: () => loadComponent: () =>
import('../features/release-orchestrator/environments/environments.routes').then( import('../features/release-control/regions/regions-overview.component').then(
(m) => m.ENVIRONMENT_ROUTES (m) => m.RegionsOverviewComponent
), ),
}, },
{
path: 'regions/:region',
title: 'Region Detail',
data: { breadcrumb: 'Region Detail' },
loadComponent: () =>
import('../features/release-control/regions/region-detail.component').then(
(m) => m.RegionDetailComponent
),
},
{
path: 'regions/:region/environments/:env',
title: 'Environment Detail',
data: { breadcrumb: 'Environment Detail' },
loadComponent: () =>
import('../features/release-orchestrator/environments/environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent
),
},
{
path: 'regions/:region/environments/:env/settings',
title: 'Environment Detail',
data: { breadcrumb: 'Environment Detail', initialTab: 'inputs' },
loadComponent: () =>
import('../features/release-orchestrator/environments/environment-detail/environment-detail.component').then(
(m) => m.EnvironmentDetailComponent
),
},
{
path: 'environments',
pathMatch: 'full',
redirectTo: 'regions',
},
{
path: 'environments/:region/:env',
pathMatch: 'full',
redirectTo: 'regions/:region/environments/:env',
},
{
path: 'environments/:region/:env/settings',
pathMatch: 'full',
redirectTo: 'regions/:region/environments/:env/settings',
},
{
path: 'environments/:id',
pathMatch: 'full',
redirectTo: 'regions/global/environments/:id',
},
{
path: 'environments/:id/settings',
pathMatch: 'full',
redirectTo: 'regions/global/environments/:id/settings',
},
// Deployments // Deployments
{ {
@@ -158,4 +221,26 @@ export const RELEASE_CONTROL_ROUTES: Routes = [
(m) => m.PIPELINE_RUN_ROUTES (m) => m.PIPELINE_RUN_ROUTES
), ),
}, },
// Governance & Policy hub
{
path: 'governance',
title: 'Governance',
data: { breadcrumb: 'Governance' },
loadChildren: () =>
import('../features/release-control/governance/release-control-governance.routes').then(
(m) => m.RELEASE_CONTROL_GOVERNANCE_ROUTES
),
},
// Dedicated hotfix queue
{
path: 'hotfixes',
title: 'Hotfixes',
data: { breadcrumb: 'Hotfixes' },
loadComponent: () =>
import('../features/release-control/hotfixes/hotfixes-queue.component').then(
(m) => m.HotfixesQueueComponent
),
},
]; ];

View File

@@ -5,58 +5,246 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const SECURITY_RISK_ROUTES: Routes = [ export const SECURITY_RISK_ROUTES: Routes = [
{ path: '', title: 'Security and Risk', data: { breadcrumb: 'Security and Risk' }, {
loadComponent: () => import('../features/security-risk/security-risk-overview.component').then(m => m.SecurityRiskOverviewComponent) }, path: '',
{ path: 'findings', title: 'Findings', data: { breadcrumb: 'Findings' }, title: 'Risk Overview',
loadComponent: () => import('../features/findings/container/findings-container.component').then(m => m.FindingsContainerComponent) }, data: { breadcrumb: 'Risk Overview' },
{ path: 'advisory-sources', title: 'Advisory Sources', data: { breadcrumb: 'Advisory Sources' }, loadComponent: () =>
loadComponent: () => import('../features/security-risk/advisory-sources.component').then(m => m.AdvisorySourcesComponent) }, import('../features/security-risk/security-risk-overview.component').then(
{ path: 'vulnerabilities', title: 'Vulnerabilities', data: { breadcrumb: 'Vulnerabilities' }, (m) => m.SecurityRiskOverviewComponent
loadComponent: () => import('../features/vulnerabilities/vulnerability-explorer.component').then(m => m.VulnerabilityExplorerComponent) }, ),
{ path: 'vulnerabilities/:vulnId', title: 'Vulnerability Detail', data: { breadcrumb: 'Vulnerability Detail' }, },
loadComponent: () => import('../features/vulnerabilities/vulnerability-detail.component').then(m => m.VulnerabilityDetailComponent) }, {
{ path: 'scans/:scanId', title: 'Scan Detail', data: { breadcrumb: 'Scan Detail' }, path: 'findings',
loadComponent: () => import('../features/scans/scan-detail-page.component').then(m => m.ScanDetailPageComponent) }, title: 'Findings Explorer',
{ path: 'sbom', title: 'SBOM', data: { breadcrumb: 'SBOM' }, data: { breadcrumb: 'Findings Explorer' },
loadComponent: () => import('../features/security/sbom-graph-page.component').then(m => m.SbomGraphPageComponent) }, loadComponent: () =>
{ path: 'sbom/graph', title: 'SBOM Graph', data: { breadcrumb: 'SBOM Graph' }, import('../features/findings/container/findings-container.component').then(
loadComponent: () => import('../features/graph/graph-explorer.component').then(m => m.GraphExplorerComponent) }, (m) => m.FindingsContainerComponent
{ path: 'vex', title: 'VEX', data: { breadcrumb: 'VEX' }, ),
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then(m => m.vexHubRoutes) }, },
{ path: 'vex/:page', title: 'VEX', data: { breadcrumb: 'VEX' }, {
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then(m => m.vexHubRoutes) }, path: 'findings/:findingId',
{ path: 'lineage', title: 'Lineage', data: { breadcrumb: 'Lineage' }, title: 'Finding Detail',
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) }, data: { breadcrumb: 'Finding Detail' },
{ path: 'lineage/:artifact/compare', title: 'Lineage Compare', data: { breadcrumb: 'Lineage Compare' }, loadComponent: () =>
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) }, import('../features/security-risk/finding-detail-page.component').then(
{ path: 'lineage/compare', title: 'Lineage Compare', data: { breadcrumb: 'Lineage Compare' }, (m) => m.FindingDetailPageComponent
loadChildren: () => import('../features/lineage/lineage.routes').then(m => m.lineageRoutes) }, ),
{ path: 'lineage/compare/:currentId', title: 'Compare', data: { breadcrumb: 'Compare' }, },
loadComponent: () => import('../features/compare/components/compare-view/compare-view.component').then(m => m.CompareViewComponent) }, {
{ path: 'reachability', title: 'Reachability', data: { breadcrumb: 'Reachability' }, path: 'advisory-sources',
loadComponent: () => import('../features/reachability/reachability-center.component').then(m => m.ReachabilityCenterComponent) }, title: 'Advisory Sources',
{ path: 'risk', title: 'Risk', data: { breadcrumb: 'Risk Overview' }, data: { breadcrumb: 'Advisory Sources' },
loadComponent: () => import('../features/risk/risk-dashboard.component').then(m => m.RiskDashboardComponent) }, loadComponent: () =>
{ path: 'unknowns', title: 'Unknowns', data: { breadcrumb: 'Unknowns' }, import('../features/security-risk/advisory-sources.component').then(
loadChildren: () => import('../features/unknowns-tracking/unknowns.routes').then(m => m.unknownsRoutes) }, (m) => m.AdvisorySourcesComponent
{ path: 'patch-map', title: 'Patch Map', data: { breadcrumb: 'Patch Map' }, ),
loadComponent: () => import('../features/binary-index/patch-map.component').then(m => m.PatchMapComponent) }, },
{ path: 'artifacts', title: 'Artifacts', data: { breadcrumb: 'Artifacts' }, {
loadComponent: () => import('../features/triage/triage-artifacts.component').then(m => m.TriageArtifactsComponent) }, path: 'vulnerabilities',
{ path: 'artifacts/:artifactId', title: 'Artifact Detail', data: { breadcrumb: 'Artifact Detail' }, title: 'Vulnerabilities Explorer',
loadComponent: () => import('../features/triage/triage-workspace.component').then(m => m.TriageWorkspaceComponent) }, data: { breadcrumb: 'Vulnerabilities Explorer' },
{ path: 'symbol-sources', title: 'Symbol Sources', data: { breadcrumb: 'Symbol Sources' }, loadComponent: () =>
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-sources-list.component').then(m => m.SymbolSourcesListComponent) }, import('../features/vulnerabilities/vulnerability-explorer.component').then(
{ path: 'symbol-sources/:sourceId', title: 'Symbol Source Detail', data: { breadcrumb: 'Symbol Source' }, (m) => m.VulnerabilityExplorerComponent
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-source-detail.component').then(m => m.SymbolSourceDetailComponent) }, ),
{ path: 'symbol-marketplace', title: 'Symbol Marketplace', data: { breadcrumb: 'Symbol Marketplace' }, },
loadComponent: () => import('../features/security-risk/symbol-sources/symbol-marketplace-catalog.component').then(m => m.SymbolMarketplaceCatalogComponent) }, {
{ path: 'remediation', title: 'Remediation', data: { breadcrumb: 'Remediation' }, path: 'vulnerabilities/:cveId',
loadComponent: () => import('../features/security-risk/remediation/remediation-browse.component').then(m => m.RemediationBrowseComponent) }, title: 'Vulnerability Detail',
{ path: 'remediation/submit', title: 'Submit Fix', data: { breadcrumb: 'Submit' }, data: { breadcrumb: 'Vulnerability Detail' },
loadComponent: () => import('../features/security-risk/remediation/remediation-submit.component').then(m => m.RemediationSubmitComponent) }, loadComponent: () =>
{ path: 'remediation/status/:submissionId', title: 'Verification Status', data: { breadcrumb: 'Status' }, import('../features/security-risk/vulnerability-detail-page.component').then(
loadComponent: () => import('../features/security-risk/remediation/remediation-submit.component').then(m => m.RemediationSubmitComponent) }, (m) => m.VulnerabilityDetailPageComponent
{ path: 'remediation/:fixId', title: 'Fix Detail', data: { breadcrumb: 'Fix Detail' }, ),
loadComponent: () => import('../features/security-risk/remediation/remediation-fix-detail.component').then(m => m.RemediationFixDetailComponent) }, },
{
path: 'scans/:scanId',
title: 'Scan Detail',
data: { breadcrumb: 'Scan Detail' },
loadComponent: () =>
import('../features/scans/scan-detail-page.component').then((m) => m.ScanDetailPageComponent),
},
{
path: 'sbom',
title: 'SBOM Graph',
data: { breadcrumb: 'SBOM Graph' },
loadComponent: () =>
import('../features/security/sbom-graph-page.component').then((m) => m.SbomGraphPageComponent),
},
{
path: 'sbom/graph',
title: 'SBOM Graph',
data: { breadcrumb: 'SBOM Graph' },
loadComponent: () =>
import('../features/graph/graph-explorer.component').then((m) => m.GraphExplorerComponent),
},
{
path: 'sbom-lake',
title: 'SBOM Lake',
data: { breadcrumb: 'SBOM Lake' },
loadComponent: () =>
import('../features/analytics/sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
},
{
path: 'vex',
title: 'VEX Hub',
data: { breadcrumb: 'VEX Hub' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
},
{
path: 'vex/:page',
title: 'VEX Hub',
data: { breadcrumb: 'VEX Hub' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
},
{
path: 'exceptions',
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
},
{
path: 'exceptions/:id',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
},
{
path: 'lineage',
title: 'Lineage',
data: { breadcrumb: 'Lineage' },
loadChildren: () => import('../features/lineage/lineage.routes').then((m) => m.lineageRoutes),
},
{
path: 'lineage/:artifact/compare',
title: 'Lineage Compare',
data: { breadcrumb: 'Lineage Compare' },
loadChildren: () => import('../features/lineage/lineage.routes').then((m) => m.lineageRoutes),
},
{
path: 'lineage/compare',
title: 'Lineage Compare',
data: { breadcrumb: 'Lineage Compare' },
loadChildren: () => import('../features/lineage/lineage.routes').then((m) => m.lineageRoutes),
},
{
path: 'lineage/compare/:currentId',
title: 'Compare',
data: { breadcrumb: 'Compare' },
loadComponent: () =>
import('../features/compare/components/compare-view/compare-view.component').then(
(m) => m.CompareViewComponent
),
},
{
path: 'reachability',
title: 'Reachability',
data: { breadcrumb: 'Reachability' },
loadComponent: () =>
import('../features/reachability/reachability-center.component').then(
(m) => m.ReachabilityCenterComponent
),
},
{
path: 'risk',
title: 'Risk Overview',
data: { breadcrumb: 'Risk Overview' },
loadComponent: () =>
import('../features/risk/risk-dashboard.component').then((m) => m.RiskDashboardComponent),
},
{
path: 'unknowns',
title: 'Unknowns',
data: { breadcrumb: 'Unknowns' },
loadChildren: () =>
import('../features/unknowns-tracking/unknowns.routes').then((m) => m.unknownsRoutes),
},
{
path: 'patch-map',
title: 'Patch Map',
data: { breadcrumb: 'Patch Map' },
loadComponent: () =>
import('../features/binary-index/patch-map.component').then((m) => m.PatchMapComponent),
},
{
path: 'artifacts',
title: 'Artifacts',
data: { breadcrumb: 'Artifacts' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
},
{
path: 'artifacts/:artifactId',
title: 'Artifact Detail',
data: { breadcrumb: 'Artifact Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
},
{
path: 'symbol-sources',
title: 'Symbol Sources',
data: { breadcrumb: 'Symbol Sources' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-sources-list.component').then(
(m) => m.SymbolSourcesListComponent
),
},
{
path: 'symbol-sources/:sourceId',
title: 'Symbol Source Detail',
data: { breadcrumb: 'Symbol Source' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-source-detail.component').then(
(m) => m.SymbolSourceDetailComponent
),
},
{
path: 'symbol-marketplace',
title: 'Symbol Marketplace',
data: { breadcrumb: 'Symbol Marketplace' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-marketplace-catalog.component').then(
(m) => m.SymbolMarketplaceCatalogComponent
),
},
{
path: 'remediation',
title: 'Remediation',
data: { breadcrumb: 'Remediation' },
loadComponent: () =>
import('../features/security-risk/remediation/remediation-browse.component').then(
(m) => m.RemediationBrowseComponent
),
},
{
path: 'remediation/submit',
title: 'Submit Fix',
data: { breadcrumb: 'Submit' },
loadComponent: () =>
import('../features/security-risk/remediation/remediation-submit.component').then(
(m) => m.RemediationSubmitComponent
),
},
{
path: 'remediation/status/:submissionId',
title: 'Verification Status',
data: { breadcrumb: 'Status' },
loadComponent: () =>
import('../features/security-risk/remediation/remediation-submit.component').then(
(m) => m.RemediationSubmitComponent
),
},
{
path: 'remediation/:fixId',
title: 'Fix Detail',
data: { breadcrumb: 'Fix Detail' },
loadComponent: () =>
import('../features/security-risk/remediation/remediation-fix-detail.component').then(
(m) => m.RemediationFixDetailComponent
),
},
]; ];

View File

@@ -52,22 +52,22 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
`, `,
styles: [` styles: [`
.freshness-widget { .freshness-widget {
background: rgba(30, 41, 59, 0.6); background: var(--color-surface-primary);
border: 1px solid var(--color-text-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1rem; padding: 1rem;
} }
.freshness-widget.fresh { .freshness-widget.fresh {
border-color: rgba(74, 222, 128, 0.3); border-color: var(--color-status-success-border);
} }
.freshness-widget.stale { .freshness-widget.stale {
border-color: rgba(251, 191, 36, 0.3); border-color: var(--color-status-warning-border);
} }
.freshness-widget.expired { .freshness-widget.expired {
border-color: rgba(239, 68, 68, 0.3); border-color: var(--color-status-error-border);
} }
.status-indicator { .status-indicator {
@@ -85,17 +85,17 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
.fresh .status-dot { .fresh .status-dot {
background: var(--color-status-success-border); background: var(--color-status-success-border);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4); box-shadow: 0 0 0 2px var(--color-status-success-bg);
} }
.stale .status-dot { .stale .status-dot {
background: var(--color-status-warning-border); background: var(--color-status-warning-border);
box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); box-shadow: 0 0 0 2px var(--color-status-warning-bg);
} }
.expired .status-dot { .expired .status-dot {
background: var(--color-status-error-border); background: var(--color-status-error-border);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); box-shadow: 0 0 0 2px var(--color-status-error-bg);
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
@@ -139,7 +139,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
.age-value { .age-value {
font-size: 2rem; font-size: 2rem;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--color-border-primary); color: var(--color-text-heading);
} }
.age-unit { .age-unit {
@@ -165,20 +165,21 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
.freshness-message { .freshness-message {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--color-text-secondary);
padding: 0.5rem; padding: 0.5rem;
background: rgba(15, 23, 42, 0.5); background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.stale .freshness-message { .stale .freshness-message {
background: rgba(251, 191, 36, 0.1); background: var(--color-status-warning-bg);
color: var(--color-status-warning-border); color: var(--color-status-warning-border);
} }
.expired .freshness-message { .expired .freshness-message {
background: rgba(239, 68, 68, 0.1); background: var(--color-status-error-bg);
color: var(--color-status-error-border); color: var(--color-status-error-border);
} }
@@ -199,7 +200,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
top: 0; top: 0;
left: 0; left: 0;
height: 100%; height: 100%;
background: var(--color-border-primary); background: var(--color-brand-primary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: width 0.3s ease; transition: width 0.3s ease;
} }
@@ -209,7 +210,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
top: -4px; top: -4px;
width: 2px; width: 2px;
height: 14px; height: 14px;
background: var(--color-text-secondary); background: var(--color-text-muted);
} }
.marker-7 { .marker-7 {

View File

@@ -345,7 +345,7 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
viewAllResults(type: string): void { viewAllResults(type: string): void {
this.close(); this.close();
const routes: Record<string, string> = { cve: '/vulnerabilities', artifact: '/triage/artifacts', policy: '/policy-studio/packs', job: '/orchestrator/jobs', finding: '/findings', vex: '/admin/vex-hub', integration: '/integrations' }; const routes: Record<string, string> = { cve: '/vulnerabilities', artifact: '/triage/artifacts', policy: '/policy-studio/packs', job: '/platform-ops/orchestrator/jobs', finding: '/findings', vex: '/admin/vex-hub', integration: '/integrations' };
if (routes[type]) this.router.navigate([routes[type]], { queryParams: { q: this.query } }); if (routes[type]) this.router.navigate([routes[type]], { queryParams: { q: this.query } });
} }
} }

View File

@@ -47,7 +47,7 @@ import { NavigationService } from '../../../core/navigation';
<!-- User Info --> <!-- User Info -->
<div class="user-menu__info"> <div class="user-menu__info">
<div class="user-menu__info-name">{{ authService.user()?.name || 'User' }}</div> <div class="user-menu__info-name">{{ authService.user()?.name || 'User' }}</div>
<div class="user-menu__info-email" [title]="authService.user()?.email || ''">{{ authService.user()?.email || '' }}</div> <div class="user-menu__info-email" [title]="displayEmail()">{{ displayEmail() }}</div>
</div> </div>
<div class="user-menu__divider"></div> <div class="user-menu__divider"></div>
@@ -178,6 +178,32 @@ export class UserMenuComponent {
return 'User'; return 'User';
}; };
protected readonly displayEmail = () => {
const email = this.authService.user()?.email?.trim() ?? '';
if (!email) {
return 'No email configured';
}
const lower = email.toLowerCase();
const splitAt = lower.indexOf('@');
if (splitAt <= 0) {
return this.isUuidLike(lower) ? 'No email configured' : email;
}
const local = lower.slice(0, splitAt);
const domain = lower.slice(splitAt + 1);
if (domain === 'unknown.local' && this.isUuidLike(local)) {
return 'No email configured';
}
return email;
};
private isUuidLike(value: string): boolean {
return /^[0-9a-f]{32}$/i.test(value)
|| /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
toggleMenu(): void { toggleMenu(): void {
this.menuOpen.update(open => !open); this.menuOpen.update(open => !open);
} }

View File

@@ -22,6 +22,7 @@ const CANONICAL_PATHS = [
'usage', // A4 'usage', // A4
'policy-governance', // A5 'policy-governance', // A5
'trust-signing', // A6 'trust-signing', // A6
'offline', // A6.5
'system', // A7 'system', // A7
]; ];
@@ -76,6 +77,11 @@ describe('ADMINISTRATION_ROUTES (administration)', () => {
expect(route?.data?.['breadcrumb']).toBe('System'); expect(route?.data?.['breadcrumb']).toBe('System');
}); });
it('offline route is present and uses Offline Settings breadcrumb', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'offline');
expect(route?.data?.['breadcrumb']).toBe('Offline Settings');
});
it('no canonical A0-A7 route uses a deprecated v1 root-level path as its path value', () => { it('no canonical A0-A7 route uses a deprecated v1 root-level path as its path value', () => {
const legacyRoots = ['settings', 'operations', 'security', 'evidence', 'policy']; const legacyRoots = ['settings', 'operations', 'security', 'evidence', 'policy'];
const canonicalPaths = new Set(CANONICAL_PATHS.filter((path) => path.length > 0)); const canonicalPaths = new Set(CANONICAL_PATHS.filter((path) => path.length > 0));

View File

@@ -10,7 +10,7 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
let params$: BehaviorSubject<Record<string, string>>; let params$: BehaviorSubject<Record<string, string>>;
beforeEach(async () => { beforeEach(async () => {
params$ = new BehaviorSubject<Record<string, string>>({ id: 'APPR-2026-045' }); params$ = new BehaviorSubject<Record<string, string>>({ id: 'apr-2026-045' });
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ApprovalDetailPageComponent], imports: [ApprovalDetailPageComponent],
@@ -28,38 +28,91 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('binds route id and opens witness details from security diff rows', () => { it('binds route id and renders all eight approval-detail tabs', () => {
expect(component.approvalId()).toBe('APPR-2026-045'); expect(component.approval().id).toBe('apr-2026-045');
component.openWitness('CVE-2026-1234'); const labels = component.tabs.map((tab) => tab.label);
const witness = component.selectedWitness(); expect(labels).toEqual([
'Overview',
expect(witness).toBeTruthy(); 'Gates',
expect(witness!.findingId).toBe('CVE-2026-1234'); 'Security',
expect(witness!.state).toBe('reachable'); 'Reachability',
'Ops/Data',
'Evidence',
'Replay/Verify',
'History',
]);
expect(component.activeTab()).toBe('overview');
}); });
it('supports witness close and approval decision lifecycle actions', () => { it('shows standardized readiness header fields and digest metadata', () => {
component.openWitness('CVE-2026-5678'); const text = fixture.nativeElement.textContent as string;
expect(component.selectedWitness()).toBeTruthy();
component.closeWitness(); expect(text).toContain('Manifest Digest');
expect(component.selectedWitness()).toBeNull(); expect(text).toContain('Gates PASS/BLOCK');
expect(text).toContain('Hybrid B/I/R');
expect(text).toContain('Data Integrity WARN');
});
it('blocks approve action when blocking gates are present', () => {
component.decisionReason = 'sufficient reason for approval';
expect(component.hasBlockingGates()).toBeTrue();
expect(component.canApprove()).toBeFalse();
component.approve(); component.approve();
expect(component.approval().status).toBe('approved'); expect(component.approval().status).toBe('pending');
expect(component.approval().decidedBy).toBe('Current User'); });
it('preserves reject workflow with decision reason validation', () => {
component.decisionReason = 'short';
expect(component.canReject()).toBeFalse();
component.reject();
expect(component.approval().status).toBe('pending');
component.decisionReason = 'reject because readiness checks are incomplete';
expect(component.canReject()).toBeTrue();
component.reject(); component.reject();
expect(component.approval().status).toBe('rejected'); expect(component.approval().status).toBe('rejected');
expect(component.approval().decidedBy).toBe('Current User');
}); });
it('adds comments and clears input', () => { it('renders gates tab with decision digest and expandable gate trace', () => {
component.newComment = 'Reviewed witness details and accepted risk.'; component.setActiveTab('gates');
component.addComment(); fixture.detectChanges();
expect(component.comments.length).toBe(1); const text = fixture.nativeElement.textContent as string;
expect(component.comments[0].body).toContain('Reviewed witness details'); expect(text).toContain('Decision digest');
expect(component.newComment).toBe(''); expect(text).toContain('Gate detail trace');
component.toggleGateTrace('reachability');
fixture.detectChanges();
expect(text).toContain('Trigger SBOM Scan');
});
it('renders ops/data tab with all data sections and data-integrity link', () => {
component.setActiveTab('ops-data');
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Feeds');
expect(text).toContain('Nightly Jobs');
expect(text).toContain('Integrations');
expect(text).toContain('DLQ');
const link = fixture.nativeElement.querySelector('a[href*="/platform-ops/data-integrity"]');
expect(link).toBeTruthy();
});
it('renders evidence tab with artifacts and export center action', () => {
component.setActiveTab('evidence');
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('policy-decision.dsse');
expect(text).toContain('Signature status');
const link = fixture.nativeElement.querySelector('a[href*="/evidence-audit/evidence/export"]');
expect(link).toBeTruthy();
}); });
}); });

View File

@@ -100,6 +100,10 @@ describe('ApprovalsInboxComponent (approvals)', () => {
let fixture: ComponentFixture<ApprovalsInboxComponent>; let fixture: ComponentFixture<ApprovalsInboxComponent>;
let component: ApprovalsInboxComponent; let component: ApprovalsInboxComponent;
afterEach(() => {
sessionStorage.removeItem('approvals.data-integrity-banner-dismissed');
});
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ApprovalsInboxComponent], imports: [ApprovalsInboxComponent],
@@ -140,4 +144,46 @@ describe('ApprovalsInboxComponent (approvals)', () => {
expect(text).toContain('API Gateway v1.2.5'); expect(text).toContain('API Gateway v1.2.5');
expect(text).toContain('Scanner v3.0.0'); expect(text).toContain('Scanner v3.0.0');
}); });
it('does not render a dead Docs link in the page header', () => {
const links = Array.from(
fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>
);
const docsLink = links.find((link) =>
(link.textContent ?? '').replace(/\s+/g, ' ').trim().startsWith('Docs')
);
expect(docsLink).toBeUndefined();
});
it('renders data integrity warning banner with deep link when status is WARN', () => {
const banner = fixture.nativeElement.querySelector('.data-integrity-banner') as HTMLElement | null;
expect(banner).toBeTruthy();
const text = banner?.textContent ?? '';
expect(text).toContain('Data Integrity WARN');
expect(text).toContain('Open Data Integrity');
});
it('hides data integrity banner when status is OK', () => {
component.dataIntegrityStatus.set('OK');
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('.data-integrity-banner');
expect(banner).toBeFalsy();
});
it('dismisses data integrity banner for current session', () => {
const dismiss = fixture.nativeElement.querySelector(
'.data-integrity-banner__actions button'
) as HTMLButtonElement | null;
expect(dismiss).toBeTruthy();
dismiss?.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.data-integrity-banner')).toBeFalsy();
expect(sessionStorage.getItem('approvals.data-integrity-banner-dismissed')).toBe('1');
});
}); });

View File

@@ -99,10 +99,20 @@ const anomaliesFixture: AuditAnomalyAlert[] = [
]; ];
describe('unified-audit-log-viewer behavior', () => { describe('unified-audit-log-viewer behavior', () => {
it('declares admin/audit app route and expected unified-audit child routes', () => { it('declares canonical evidence-audit route and keeps admin/audit redirect alias', () => {
const appRoute = routes.find((route) => route.path === 'admin/audit'); const legacyAlias = routes.find((route) => route.path === 'admin/audit');
expect(appRoute).toBeDefined(); expect(legacyAlias).toBeDefined();
expect(typeof appRoute?.loadChildren).toBe('function'); expect(typeof legacyAlias?.redirectTo).toBe('function');
const redirectTarget = (legacyAlias?.redirectTo as any)({
params: {},
queryParams: {},
fragment: null,
});
expect(redirectTarget).toBe('/evidence-audit/audit');
const canonicalRoute = routes.find((route) => route.path === 'evidence-audit');
expect(canonicalRoute).toBeDefined();
expect(typeof canonicalRoute?.loadChildren).toBe('function');
const childPaths = auditLogRoutes.map((route) => route.path); const childPaths = auditLogRoutes.map((route) => route.path);
expect(childPaths).toEqual([ expect(childPaths).toEqual([

View File

@@ -0,0 +1,77 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConsoleProfileComponent } from '../../app/features/console/console-profile.component';
import { ConsoleSessionService } from '../../app/core/console/console-session.service';
import { ConsoleSessionStore } from '../../app/core/console/console-session.store';
describe('ConsoleProfileComponent (console profile page)', () => {
let fixture: ComponentFixture<ConsoleProfileComponent>;
let store: ConsoleSessionStore;
let serviceSpy: jasmine.SpyObj<ConsoleSessionService>;
beforeEach(async () => {
serviceSpy = jasmine.createSpyObj(
'ConsoleSessionService',
['loadConsoleContext', 'refresh', 'switchTenant']
) as jasmine.SpyObj<ConsoleSessionService>;
serviceSpy.loadConsoleContext.and.returnValue(Promise.resolve());
serviceSpy.refresh.and.returnValue(Promise.resolve());
serviceSpy.switchTenant.and.returnValue(Promise.resolve());
await TestBed.configureTestingModule({
imports: [ConsoleProfileComponent],
providers: [
ConsoleSessionStore,
{ provide: ConsoleSessionService, useValue: serviceSpy },
],
}).compileComponents();
store = TestBed.inject(ConsoleSessionStore);
store.setContext({
tenants: [
{
id: 'tenant-default',
displayName: 'Default Tenant',
status: 'active',
isolationMode: 'strict',
defaultRoles: ['admin'],
},
],
profile: {
subjectId: 'subject-1',
username: 'admin',
displayName: 'Platform Admin',
tenant: 'Default Tenant',
sessionId: 'session-1',
roles: ['admin'],
scopes: ['ui.read'],
audiences: ['stellaops-web'],
authenticationMethods: ['pwd'],
issuedAt: new Date('2026-02-19T10:00:00Z'),
authenticationTime: new Date('2026-02-19T10:00:00Z'),
expiresAt: new Date('2026-02-19T11:00:00Z'),
freshAuth: true,
},
token: null,
selectedTenantId: 'tenant-default',
});
fixture = TestBed.createComponent(ConsoleProfileComponent);
fixture.detectChanges();
});
it('renders profile heading and identity fields from session context', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Profile');
expect(text).toContain('Platform Admin');
expect(text).toContain('admin');
expect(text).toContain('Default Tenant');
});
it('does not render developer fixture references', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).not.toContain('testing/auth-fixtures.ts');
expect(text).not.toContain('Policy Studio roles');
});
});

View File

@@ -129,7 +129,7 @@ describe('ControlPlaneDashboardComponent (control_plane)', () => {
fixture.detectChanges(); fixture.detectChanges();
const errorMessage = fixture.nativeElement.querySelector( const errorMessage = fixture.nativeElement.querySelector(
'.dashboard__error-message' '.dashboard__error-banner-detail'
) as HTMLElement; ) as HTMLElement;
expect(errorMessage.textContent).toContain('dashboard unavailable'); expect(errorMessage.textContent).toContain('dashboard unavailable');
}); });

View File

@@ -0,0 +1,9 @@
import { routes } from '../../app/app.routes';
describe('Dashboard route aliases', () => {
it('keeps /control-plane as a legacy alias redirecting to root dashboard', () => {
const alias = routes.find((route) => route.path === 'control-plane');
expect(alias).toBeDefined();
expect(alias?.redirectTo).toBe('/');
});
});

View File

@@ -0,0 +1,77 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { DashboardV3Component } from '../../app/features/dashboard-v3/dashboard-v3.component';
describe('DashboardV3Component', () => {
let fixture: ComponentFixture<DashboardV3Component>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DashboardV3Component],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(DashboardV3Component);
fixture.detectChanges();
});
it('renders Dashboard heading and mission-board subtitle', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Dashboard');
expect(text).toContain('Mission board');
});
it('shows SBOM, CritR, and B/I/R metrics in environment cards', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('SBOM');
expect(text).toContain('CritR');
expect(text).toContain('B/I/R');
expect(text).toContain('3/3');
});
it('renders environments-at-risk table with canonical columns and Open actions', () => {
const headers = Array.from(
fixture.nativeElement.querySelectorAll('.risk-table__table th') as NodeListOf<HTMLElement>
).map((node) => (node.textContent ?? '').trim());
expect(headers).toEqual([
'Region/Env',
'Deploy Health',
'SBOM Status',
'Crit Reach',
'Hybrid B/I/R',
'Last SBOM',
'Action',
]);
const openLinks = fixture.nativeElement.querySelectorAll('.risk-table__table td a');
expect(openLinks.length).toBeGreaterThan(0);
});
it('renders SBOM findings snapshot with critical findings filter link', () => {
const links = Array.from(
fixture.nativeElement.querySelectorAll('a.card-action')
) as HTMLAnchorElement[];
const openFindingsLink = links.find((link) =>
(link.textContent ?? '').includes('Open Findings')
);
expect(openFindingsLink).toBeTruthy();
expect(openFindingsLink?.getAttribute('href')).toContain('/security-risk/findings');
expect(openFindingsLink?.getAttribute('href')).toContain('reachability=critical');
});
it('renders nightly ops signals card with four status rows and data integrity link', () => {
const cardHeaders = Array.from(
fixture.nativeElement.querySelectorAll('.card-title') as NodeListOf<HTMLElement>
).map((node) => (node.textContent ?? '').trim());
expect(cardHeaders).toContain('Nightly Ops Signals');
const signalRows = fixture.nativeElement.querySelectorAll('.integrity-stat');
expect(signalRows.length).toBe(4);
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Open Data Integrity');
});
});

View File

@@ -93,4 +93,15 @@ describe('DeadLetterDashboardComponent (deadletter)', () => {
expect(component.canResolve({ ...entry, state: 'failed' })).toBeTrue(); expect(component.canResolve({ ...entry, state: 'failed' })).toBeTrue();
expect(component.canReplay({ ...entry, state: 'resolved' })).toBeFalse(); expect(component.canReplay({ ...entry, state: 'resolved' })).toBeFalse();
}); });
it('renders header action labels without literal icon name leakage', () => {
const labels = Array.from(
fixture.nativeElement.querySelectorAll('.header-actions .btn') as NodeListOf<HTMLButtonElement>
).map((button) => button.textContent?.replace(/\s+/g, ' ').trim());
expect(labels[0]).toBe('Export CSV');
expect(labels[1]).toContain('Replay All Retryable (1)');
expect(labels[0]).not.toContain('download');
expect(labels[1]).not.toContain('refresh');
});
}); });

View File

@@ -24,12 +24,16 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
expect(allPaths).toContain('packs'); expect(allPaths).toContain('packs');
}); });
it('contains the bundles list route', () => {
expect(allPaths).toContain('bundles');
});
it('contains the pack detail route', () => { it('contains the pack detail route', () => {
expect(allPaths).toContain('packs/:packId'); expect(allPaths).toContain('packs/:packId');
}); });
it('contains the audit log route', () => { it('contains the audit-log route', () => {
expect(allPaths).toContain('audit'); expect(allPaths).toContain('audit-log');
}); });
it('contains the change-trace route', () => { it('contains the change-trace route', () => {
@@ -64,15 +68,15 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
// Overview route breadcrumb // Overview route breadcrumb
// ────────────────────────────────────────── // ──────────────────────────────────────────
it('overview route has "Evidence and Audit" breadcrumb', () => { it('overview route has "Evidence & Audit" breadcrumb', () => {
const overviewRoute = getRouteByPath(''); const overviewRoute = getRouteByPath('');
expect(overviewRoute).toBeDefined(); expect(overviewRoute).toBeDefined();
expect(overviewRoute?.data?.['breadcrumb']).toBe('Evidence and Audit'); expect(overviewRoute?.data?.['breadcrumb']).toBe('Evidence & Audit');
}); });
it('overview route has title "Evidence and Audit"', () => { it('overview route has title "Evidence & Audit"', () => {
const overviewRoute = getRouteByPath(''); const overviewRoute = getRouteByPath('');
expect(overviewRoute?.title).toBe('Evidence and Audit'); expect(overviewRoute?.title).toBe('Evidence & Audit');
}); });
// ────────────────────────────────────────── // ──────────────────────────────────────────
@@ -80,7 +84,7 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
// ────────────────────────────────────────── // ──────────────────────────────────────────
it('every route has a breadcrumb in data', () => { it('every route has a breadcrumb in data', () => {
for (const route of EVIDENCE_AUDIT_ROUTES) { for (const route of EVIDENCE_AUDIT_ROUTES.filter((r) => r.redirectTo === undefined)) {
expect(route.data?.['breadcrumb']).toBeTruthy(); expect(route.data?.['breadcrumb']).toBeTruthy();
} }
}); });
@@ -98,7 +102,7 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
}); });
it('audit route has "Audit Log" breadcrumb', () => { it('audit route has "Audit Log" breadcrumb', () => {
expect(getRouteByPath('audit')?.data?.['breadcrumb']).toBe('Audit Log'); expect(getRouteByPath('audit-log')?.data?.['breadcrumb']).toBe('Audit Log');
}); });
it('change-trace route has "Change Trace" breadcrumb', () => { it('change-trace route has "Change Trace" breadcrumb', () => {
@@ -118,7 +122,7 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
}); });
it('replay route has "Replay / Verify" breadcrumb', () => { it('replay route has "Replay / Verify" breadcrumb', () => {
expect(getRouteByPath('replay')?.data?.['breadcrumb']).toBe('Replay / Verify'); expect(getRouteByPath('replay')?.data?.['breadcrumb']).toBe('Replay & Verify');
}); });
// ────────────────────────────────────────── // ──────────────────────────────────────────

View File

@@ -47,9 +47,9 @@ describe('Integration Hub UI (integration_hub)', () => {
const totals = new Map<IntegrationType, number>([ const totals = new Map<IntegrationType, number>([
[IntegrationType.Registry, 5], [IntegrationType.Registry, 5],
[IntegrationType.Scm, 3], [IntegrationType.Scm, 3],
[IntegrationType.Ci, 2], [IntegrationType.CiCd, 2],
[IntegrationType.Host, 4], [IntegrationType.RuntimeHost, 4],
[IntegrationType.Feed, 1], [IntegrationType.FeedMirror, 1],
]); ]);
return of({ return of({
@@ -91,6 +91,12 @@ describe('Integration Hub UI (integration_hub)', () => {
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding/registry']); expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding/registry']);
}); });
it('renders a defined coming-soon state for recent activity', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Activity stream is coming soon');
expect(text).toContain('Connector timeline events will appear here');
});
}); });
describe('IntegrationListComponent', () => { describe('IntegrationListComponent', () => {

View File

@@ -13,9 +13,11 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component'; import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { AUTH_SERVICE } from '../../app/core/auth'; import { AUTH_SERVICE } from '../../app/core/auth';
import { APPROVAL_API } from '../../app/core/api/approval.client';
const CANONICAL_DOMAIN_IDS = [ const CANONICAL_DOMAIN_IDS = [
'dashboard', 'dashboard',
@@ -40,8 +42,8 @@ const CANONICAL_DOMAIN_ROUTES = [
const EXPECTED_SECTION_LABELS: Record<string, string> = { const EXPECTED_SECTION_LABELS: Record<string, string> = {
'dashboard': 'Dashboard', 'dashboard': 'Dashboard',
'release-control': 'Release Control', 'release-control': 'Release Control',
'security-risk': 'Security and Risk', 'security-risk': 'Security & Risk',
'evidence-audit': 'Evidence and Audit', 'evidence-audit': 'Evidence & Audit',
'integrations': 'Integrations', 'integrations': 'Integrations',
'platform-ops': 'Platform Ops', 'platform-ops': 'Platform Ops',
'administration': 'Administration', 'administration': 'Administration',
@@ -54,12 +56,52 @@ describe('AppSidebarComponent nav model (navigation)', () => {
const authSpy = jasmine.createSpyObj('AuthService', ['hasAllScopes', 'hasAnyScope']); const authSpy = jasmine.createSpyObj('AuthService', ['hasAllScopes', 'hasAnyScope']);
authSpy.hasAllScopes.and.returnValue(true); authSpy.hasAllScopes.and.returnValue(true);
authSpy.hasAnyScope.and.returnValue(true); authSpy.hasAnyScope.and.returnValue(true);
const approvalApiSpy = jasmine.createSpyObj('ApprovalApi', ['listApprovals']);
approvalApiSpy.listApprovals.and.returnValue(of([
{
id: 'apr-001',
releaseId: 'rel-001',
releaseName: 'API Gateway',
releaseVersion: '2.1.0',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'alice',
requestedAt: '2026-02-19T10:00:00Z',
urgency: 'normal',
justification: 'normal release',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-20T10:00:00Z',
},
{
id: 'apr-002',
releaseId: 'rel-002',
releaseName: 'User Service',
releaseVersion: '3.0.0',
sourceEnvironment: 'qa',
targetEnvironment: 'staging',
requestedBy: 'bob',
requestedAt: '2026-02-19T09:00:00Z',
urgency: 'high',
justification: 'urgent patch',
status: 'pending',
currentApprovals: 1,
requiredApprovals: 2,
gatesPassed: true,
scheduledTime: null,
expiresAt: '2026-02-20T09:00:00Z',
},
]));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppSidebarComponent], imports: [AppSidebarComponent],
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy }, { provide: AUTH_SERVICE, useValue: authSpy },
{ provide: APPROVAL_API, useValue: approvalApiSpy },
], ],
}).compileComponents(); }).compileComponents();
@@ -107,6 +149,12 @@ describe('AppSidebarComponent nav model (navigation)', () => {
expect(approvals.route).toBe('/release-control/approvals'); expect(approvals.route).toBe('/release-control/approvals');
}); });
it('derives the Approvals badge from pending approvals count', () => {
const rc = component.visibleSections().find((s) => s.id === 'release-control')!;
const approvals = rc.children!.find((c) => c.id === 'rc-approvals')!;
expect(approvals.badge).toBe(2);
});
it('Release Control includes Setup route under canonical /release-control/setup', () => { it('Release Control includes Setup route under canonical /release-control/setup', () => {
const rc = component.navSections.find((s) => s.id === 'release-control')!; const rc = component.navSections.find((s) => s.id === 'release-control')!;
const setup = rc.children!.find((c) => c.id === 'rc-setup')!; const setup = rc.children!.find((c) => c.id === 'rc-setup')!;
@@ -126,22 +174,44 @@ describe('AppSidebarComponent nav model (navigation)', () => {
expect(policyItem.label).toBe('Policy Governance'); expect(policyItem.label).toBe('Policy Governance');
}); });
it('Administration includes Offline Settings shortcut', () => {
const admin = component.navSections.find((s) => s.id === 'administration')!;
const offlineItem = admin.children!.find((c) => c.id === 'adm-offline')!;
expect(offlineItem.label).toBe('Offline Settings');
expect(offlineItem.route).toBe('/administration/offline');
});
it('Platform Ops includes Data Integrity as the first submenu shortcut', () => {
const platformOps = component.navSections.find((s) => s.id === 'platform-ops')!;
expect(platformOps.children?.[0].id).toBe('ops-data-integrity');
expect(platformOps.children?.[0].route).toBe('/platform-ops/data-integrity');
});
it('Evidence & Audit keeps Evidence Packs and Evidence Bundles as distinct nav entries', () => {
const evidence = component.navSections.find((s) => s.id === 'evidence-audit')!;
const packs = evidence.children?.find((child) => child.id === 'ea-packs');
const bundles = evidence.children?.find((child) => child.id === 'ea-bundles');
expect(packs?.label).toBe('Evidence Packs');
expect(packs?.route).toBe('/evidence-audit/packs');
expect(bundles?.label).toBe('Evidence Bundles');
expect(bundles?.route).toBe('/evidence-audit/bundles');
});
it('no section root route uses a deprecated v1 prefix', () => { it('no section root route uses a deprecated v1 prefix', () => {
const legacyPrefixes = ['/security/', '/operations/', '/settings/', '/evidence/', '/policy/']; const legacyRootSegments = ['security', 'operations', 'settings', 'evidence', 'policy'];
for (const section of component.navSections) { for (const section of component.navSections) {
for (const prefix of legacyPrefixes) { const rootSegment = section.route.replace(/^\/+/, '').split('/')[0] ?? '';
expect(section.route + '/').not.toContain(prefix); expect(legacyRootSegments).not.toContain(rootSegment);
}
} }
}); });
it('no child route uses a deprecated v1 prefix', () => { it('no child route uses a deprecated v1 prefix', () => {
const legacyPrefixes = ['/security/', '/operations/', '/settings/', '/evidence/', '/policy/']; const legacyRootSegments = ['security', 'operations', 'settings', 'evidence', 'policy'];
for (const section of component.navSections) { for (const section of component.navSections) {
for (const child of section.children ?? []) { for (const child of section.children ?? []) {
for (const prefix of legacyPrefixes) { const rootSegment = child.route.replace(/^\/+/, '').split('/')[0] ?? '';
expect(child.route + '/').not.toContain(prefix); expect(legacyRootSegments).not.toContain(rootSegment);
}
} }
} }
}); });

Some files were not shown because too many files have changed in this diff Show More