ui pack redo
This commit is contained in:
@@ -92,6 +92,9 @@ This documentation set is intentionally consolidated and does not maintain compa
|
||||
| Architecture: data flows | `technical/architecture/data-flows.md` |
|
||||
| Architecture: schema mapping | `technical/architecture/schema-mapping.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -135,3 +135,7 @@ src/Remediation/
|
||||
- SPRINT_20260220_013: Matching, sources, policy
|
||||
- SPRINT_20260220_014: UI components
|
||||
- SPRINT_20260220_015: Documentation
|
||||
|
||||
## Related Contracts
|
||||
|
||||
- `docs/contracts/remediation-pr-v1.md`
|
||||
|
||||
@@ -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`.
|
||||
- Observability assets for this sprint: `operations/observability.md` and `operations/dashboards/telemetry-observability.json` (offline import).
|
||||
|
||||
## Related resources
|
||||
- ./operations/collector.md
|
||||
- ./operations/storage.md
|
||||
## Related resources
|
||||
- ./operations/collector.md
|
||||
- ./operations/storage.md
|
||||
- ./federation-architecture.md
|
||||
- ../../contracts/federated-consent-v1.md
|
||||
- ../../contracts/federated-telemetry-v1.md
|
||||
- ../../runbooks/federated-telemetry-operations.md
|
||||
|
||||
## Backlog references
|
||||
- TELEMETRY-OBS-50-001 … 50-004 in ../../TASKS.md.
|
||||
|
||||
25
docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md
Normal file
25
docs/modules/ui/v2-rewire/pack-conformity-diff-2026-02-20.md
Normal 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.
|
||||
@@ -116,6 +116,48 @@ public sealed class ConsoleAdminEndpointsTests
|
||||
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(
|
||||
FakeTimeProvider timeProvider,
|
||||
RecordingAuthEventSink sink,
|
||||
|
||||
@@ -33,6 +33,13 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
adminGroup.AddEndpointFilter(new TenantHeaderFilter());
|
||||
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
|
||||
var tenantGroup = adminGroup.MapGroup("/tenants");
|
||||
|
||||
@@ -79,6 +86,17 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
.WithName("AdminCreateUser")
|
||||
.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)
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityUsersWrite))
|
||||
.RequireFreshAuth()
|
||||
@@ -388,7 +406,12 @@ internal static class ConsoleAdminEndpointExtensions
|
||||
("user.id", createdSummary.Id)),
|
||||
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(
|
||||
|
||||
@@ -79,7 +79,8 @@ public sealed class PrivacyBudgetTrackerTests
|
||||
var snapshot = tracker.GetSnapshot();
|
||||
Assert.Equal(2, snapshot.QueriesThisPeriod);
|
||||
Assert.Equal(1, snapshot.SuppressedThisPeriod);
|
||||
Assert.True(snapshot.Exhausted);
|
||||
Assert.False(snapshot.Exhausted);
|
||||
Assert.Equal(0.1, snapshot.Remaining, precision: 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -121,6 +121,9 @@ public sealed class TelemetryAggregatorTests
|
||||
{
|
||||
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
|
||||
var facts = new List<TelemetryFact>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
|
||||
@@ -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;
|
||||
return -(sensitivity / epsilon) * Math.Sign(u) * Math.Log(1 - 2 * Math.Abs(u));
|
||||
|
||||
@@ -46,6 +46,11 @@ export const routes: Routes = [
|
||||
(m) => m.DASHBOARD_ROUTES
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'control-plane',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/',
|
||||
},
|
||||
|
||||
// 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',
|
||||
title: 'Security and Risk',
|
||||
title: 'Security & Risk',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Security and Risk' },
|
||||
data: { breadcrumb: 'Security & Risk' },
|
||||
loadChildren: () =>
|
||||
import('./routes/security-risk.routes').then(
|
||||
(m) => m.SECURITY_RISK_ROUTES
|
||||
@@ -74,9 +79,9 @@ export const routes: Routes = [
|
||||
// Domain 4: Evidence and Audit (formerly /evidence)
|
||||
{
|
||||
path: 'evidence-audit',
|
||||
title: 'Evidence and Audit',
|
||||
title: 'Evidence & Audit',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Evidence and Audit' },
|
||||
data: { breadcrumb: 'Evidence & Audit' },
|
||||
loadChildren: () =>
|
||||
import('./routes/evidence-audit.routes').then(
|
||||
(m) => m.EVIDENCE_AUDIT_ROUTES
|
||||
@@ -127,7 +132,7 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'environments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/environments',
|
||||
redirectTo: '/release-control/regions',
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
@@ -140,7 +145,7 @@ export const routes: Routes = [
|
||||
redirectTo: '/release-control/deployments',
|
||||
},
|
||||
|
||||
// Security and Risk domain alias
|
||||
// Security & Risk domain alias
|
||||
{
|
||||
path: 'security',
|
||||
pathMatch: 'full',
|
||||
@@ -166,8 +171,13 @@ export const routes: Routes = [
|
||||
// Platform Ops domain alias
|
||||
{
|
||||
path: 'operations',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/platform-ops',
|
||||
title: '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
|
||||
@@ -207,10 +217,8 @@ export const routes: Routes = [
|
||||
// Administration domain alias — settings
|
||||
{
|
||||
path: 'settings',
|
||||
title: 'Settings',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/administration',
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
@@ -242,6 +250,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'console/profile',
|
||||
title: 'Profile',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/console/console-profile.component').then(
|
||||
|
||||
@@ -182,7 +182,7 @@ export class SearchClient {
|
||||
title: `job-${item.id.substring(0, 8)}`,
|
||||
subtitle: `${item.type} (${item.status})`,
|
||||
description: item.artifactRef,
|
||||
route: `/orchestrator/jobs/${item.id}`,
|
||||
route: `/platform-ops/orchestrator/jobs/${item.id}`,
|
||||
matchScore: 100,
|
||||
}))
|
||||
),
|
||||
|
||||
@@ -127,7 +127,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
||||
shortcut: '>jobs',
|
||||
description: 'Navigate to job list',
|
||||
icon: 'workflow',
|
||||
route: '/orchestrator/jobs',
|
||||
route: '/platform-ops/orchestrator/jobs',
|
||||
keywords: ['jobs', 'orchestrator', 'list'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -155,19 +155,19 @@ export class MockAuthService implements AuthService {
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
// Policy Studio access methods (UI-POLICY-20-003)
|
||||
@@ -210,6 +210,17 @@ export class MockAuthService implements AuthService {
|
||||
canAuditPolicies(): boolean {
|
||||
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
|
||||
|
||||
@@ -72,6 +72,44 @@ describe('AuthorityAuthAdapterService', () => {
|
||||
service.logout();
|
||||
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: {
|
||||
|
||||
@@ -79,19 +79,19 @@ export class AuthorityAuthAdapterService implements AuthService {
|
||||
}
|
||||
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
return this.hasAdminPrivilege() || this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
|
||||
canViewPolicies(): boolean {
|
||||
@@ -156,7 +156,7 @@ export class AuthorityAuthAdapterService implements AuthService {
|
||||
const identity = session.identity;
|
||||
const id = identity.subject?.trim() || 'unknown-user';
|
||||
const name = identity.name?.trim() || id;
|
||||
const email = identity.email?.trim() || `${id}@unknown.local`;
|
||||
const email = identity.email?.trim() ?? '';
|
||||
const roles = identity.roles ?? [];
|
||||
|
||||
return {
|
||||
@@ -175,4 +175,15 @@ export class AuthorityAuthAdapterService implements AuthService {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,13 @@ import { Routes } from '@angular/router';
|
||||
export const ANALYTICS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'sbom-lake',
|
||||
redirectTo: '/security-risk/sbom-lake',
|
||||
pathMatch: 'full',
|
||||
data: { breadcrumb: 'Analytics' },
|
||||
},
|
||||
{
|
||||
path: 'sbom-lake',
|
||||
loadComponent: () =>
|
||||
import('./sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
|
||||
title: 'SBOM Lake',
|
||||
data: { breadcrumb: 'SBOM Lake' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/security-risk/sbom-lake',
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
@@ -6,6 +6,8 @@ import { catchError, of } from 'rxjs';
|
||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
|
||||
|
||||
type DataIntegrityStatus = 'OK' | 'WARN' | 'FAIL';
|
||||
|
||||
/**
|
||||
* ApprovalsInboxComponent - Approval decision cockpit.
|
||||
* 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.
|
||||
</p>
|
||||
</div>
|
||||
<a routerLink="/docs" class="btn btn--secondary">Docs →</a>
|
||||
</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 -->
|
||||
<div class="approvals__filters">
|
||||
<div class="filter-group">
|
||||
@@ -82,7 +98,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
|
||||
@for (approval of approvals(); track approval.id) {
|
||||
<div class="approval-card">
|
||||
<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 }}
|
||||
</a>
|
||||
<span class="approval-card__flow">{{ approval.sourceEnvironment }} → {{ 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--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>
|
||||
} @empty {
|
||||
@@ -154,6 +170,62 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -443,6 +515,13 @@ export class ApprovalsInboxComponent implements OnInit {
|
||||
private readonly api = inject(APPROVAL_API);
|
||||
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 error = signal<string | null>(null);
|
||||
readonly approvals = signal<ApprovalRequest[]>([]);
|
||||
@@ -467,9 +546,17 @@ export class ApprovalsInboxComponent implements OnInit {
|
||||
searchQuery: string = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
if (sessionStorage.getItem('approvals.data-integrity-banner-dismissed') === '1') {
|
||||
this.dataIntegrityDismissed.set(true);
|
||||
}
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
dismissDataIntegrityBanner(): void {
|
||||
this.dataIntegrityDismissed.set(true);
|
||||
sessionStorage.setItem('approvals.data-integrity-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
onStatusChipClick(value: string): void {
|
||||
this.currentStatusFilter = value;
|
||||
this.loadApprovals();
|
||||
@@ -492,12 +579,12 @@ export class ApprovalsInboxComponent implements OnInit {
|
||||
approveRequest(id: string): void {
|
||||
// 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.
|
||||
this.router.navigate(['/approvals', id]);
|
||||
this.router.navigate(['/release-control/approvals', id]);
|
||||
}
|
||||
|
||||
rejectRequest(id: string): void {
|
||||
// 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 {
|
||||
|
||||
@@ -375,7 +375,9 @@ export class BundleVersionDetailComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
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()) {
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -30,6 +30,15 @@ export const BUNDLE_ROUTES: Routes = [
|
||||
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)
|
||||
{
|
||||
path: ':bundleId',
|
||||
@@ -41,10 +50,15 @@ export const BUNDLE_ROUTES: Routes = [
|
||||
|
||||
// B4-04/B4-06 — Bundle version detail (component selector, materialization)
|
||||
{
|
||||
path: ':bundleId/:version',
|
||||
path: ':bundleId/versions/:versionId',
|
||||
title: 'Bundle Version',
|
||||
data: { breadcrumb: 'Bundle Version' },
|
||||
loadComponent: () =>
|
||||
import('./bundle-version-detail.component').then((m) => m.BundleVersionDetailComponent),
|
||||
},
|
||||
{
|
||||
path: ':bundleId/:version',
|
||||
pathMatch: 'full',
|
||||
redirectTo: ':bundleId/versions/:version',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="console-profile">
|
||||
<header class="console-profile__header">
|
||||
<div>
|
||||
<h1>Console Session</h1>
|
||||
<h1>Profile</h1>
|
||||
<p class="console-profile__subtitle">
|
||||
Session details sourced from Authority console endpoints.
|
||||
Identity and tenant profile details sourced from Authority.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -11,7 +11,7 @@
|
||||
(click)="refresh()"
|
||||
[disabled]="loading()"
|
||||
[attr.aria-busy]="loading()"
|
||||
>
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
@@ -24,34 +24,15 @@
|
||||
|
||||
@if (loading()) {
|
||||
<div class="console-profile__loading">
|
||||
Loading console context…
|
||||
Loading profile context...
|
||||
</div>
|
||||
}
|
||||
|
||||
@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) {
|
||||
<section class="console-profile__card">
|
||||
<header>
|
||||
<h2>User Profile</h2>
|
||||
<h2>Profile Details</h2>
|
||||
<span class="tenant-chip">
|
||||
Tenant
|
||||
<strong>{{ profile.tenant }}</strong>
|
||||
@@ -59,28 +40,18 @@
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Display name</dt>
|
||||
<dt>Display Name</dt>
|
||||
<dd>{{ profile.displayName || 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Username</dt>
|
||||
<dd>{{ profile.username || 'n/a' }}</dd>
|
||||
</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>
|
||||
<dt>Roles</dt>
|
||||
<dd>
|
||||
@if (profile.roles.length) {
|
||||
<span>
|
||||
{{ profile.roles.join(', ') }}
|
||||
</span>
|
||||
<span>{{ profile.roles.join(', ') }}</span>
|
||||
} @else {
|
||||
n/a
|
||||
}
|
||||
@@ -90,9 +61,7 @@
|
||||
<dt>Scopes</dt>
|
||||
<dd>
|
||||
@if (profile.scopes.length) {
|
||||
<span>
|
||||
{{ profile.scopes.join(', ') }}
|
||||
</span>
|
||||
<span>{{ profile.scopes.join(', ') }}</span>
|
||||
} @else {
|
||||
n/a
|
||||
}
|
||||
@@ -102,111 +71,38 @@
|
||||
<dt>Audiences</dt>
|
||||
<dd>
|
||||
@if (profile.audiences.length) {
|
||||
<span>
|
||||
{{ profile.audiences.join(', ') }}
|
||||
</span>
|
||||
<span>{{ profile.audiences.join(', ') }}</span>
|
||||
} @else {
|
||||
n/a
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication methods</dt>
|
||||
<dt>Authentication Methods</dt>
|
||||
<dd>
|
||||
@if (profile.authenticationMethods.length) {
|
||||
<span
|
||||
>
|
||||
{{ profile.authenticationMethods.join(', ') }}
|
||||
</span>
|
||||
<span>{{ profile.authenticationMethods.join(', ') }}</span>
|
||||
} @else {
|
||||
n/a
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Issued at</dt>
|
||||
<dd>
|
||||
{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
<dt>Issued At</dt>
|
||||
<dd>{{ profile.issuedAt ? (profile.issuedAt | date : 'medium') : 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authentication time</dt>
|
||||
<dd>
|
||||
{{
|
||||
profile.authenticationTime
|
||||
? (profile.authenticationTime | date : 'medium')
|
||||
: 'n/a'
|
||||
}}
|
||||
</dd>
|
||||
<dt>Authentication Time</dt>
|
||||
<dd>{{ profile.authenticationTime ? (profile.authenticationTime | date : 'medium') : 'n/a' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires at</dt>
|
||||
<dd>
|
||||
{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}
|
||||
</dd>
|
||||
<dt>Expires At</dt>
|
||||
<dd>{{ profile.expiresAt ? (profile.expiresAt | date : 'medium') : 'n/a' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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) {
|
||||
<section class="console-profile__card">
|
||||
<header>
|
||||
@@ -215,20 +111,16 @@
|
||||
</header>
|
||||
<ul class="tenant-list">
|
||||
@for (tenant of tenants(); track tenant) {
|
||||
<li
|
||||
[class.tenant-list__item--active]="tenant.id === selectedTenantId()"
|
||||
>
|
||||
<li [class.tenant-list__item--active]="tenant.id === selectedTenantId()">
|
||||
<button type="button" (click)="selectTenant(tenant.id)">
|
||||
<div class="tenant-list__heading">
|
||||
<span class="tenant-name">{{ tenant.displayName }}</span>
|
||||
<span class="tenant-status">{{ tenant.status }}</span>
|
||||
</div>
|
||||
<div class="tenant-meta">
|
||||
Isolation: {{ tenant.isolationMode }} · Default roles:
|
||||
Isolation: {{ tenant.isolationMode }} - Default roles:
|
||||
@if (tenant.defaultRoles.length) {
|
||||
<span>
|
||||
{{ tenant.defaultRoles.join(', ') }}
|
||||
</span>
|
||||
<span>{{ tenant.defaultRoles.join(', ') }}</span>
|
||||
} @else {
|
||||
n/a
|
||||
}
|
||||
@@ -239,9 +131,10 @@
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!hasProfile() && tenantCount() === 0) {
|
||||
<p class="console-profile__empty">
|
||||
No console session data available for the current identity.
|
||||
No profile data is currently available for this identity.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +85,13 @@ describe('ConsoleProfileComponent', () => {
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Console Session'
|
||||
'Profile'
|
||||
);
|
||||
expect(compiled.querySelector('.tenant-name')?.textContent).toContain(
|
||||
'Tenant Default'
|
||||
);
|
||||
expect(compiled.querySelector('dd')?.textContent).toContain('Console User');
|
||||
expect(compiled.textContent).not.toContain('testing/auth-fixtures.ts');
|
||||
expect(service.loadConsoleContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -24,22 +24,11 @@ export class ConsoleProfileComponent implements OnInit {
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly profile = this.store.profile;
|
||||
readonly tokenInfo = this.store.tokenInfo;
|
||||
readonly tenants = this.store.tenants;
|
||||
readonly selectedTenantId = this.store.selectedTenantId;
|
||||
|
||||
readonly hasProfile = computed(() => this.profile() !== null);
|
||||
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> {
|
||||
if (!this.store.hasContext()) {
|
||||
|
||||
@@ -43,14 +43,14 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
||||
<!-- Hero header: ALWAYS visible regardless of data state -->
|
||||
<header class="dashboard__header">
|
||||
<div>
|
||||
<h1 class="dashboard__title">Control Plane</h1>
|
||||
<h1 class="dashboard__title">Dashboard</h1>
|
||||
<p class="dashboard__subtitle">
|
||||
Release governance with evidence. Promote by digest. Explain every decision.
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard__actions">
|
||||
<a routerLink="/releases" class="btn btn--secondary">Releases</a>
|
||||
<a routerLink="/approvals" class="btn btn--primary">Approvals</a>
|
||||
<a routerLink="/release-control/releases" class="btn btn--secondary">Releases</a>
|
||||
<a routerLink="/release-control/approvals" class="btn btn--primary">Approvals</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -122,7 +122,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
||||
@for (approval of pendingApprovals(); track approval.id) {
|
||||
<li class="card__item">
|
||||
<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 }}
|
||||
</a>
|
||||
<span class="card__urgency" [class]="'card__urgency--' + approval.urgency">
|
||||
@@ -135,7 +135,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
||||
</ul>
|
||||
}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -187,7 +187,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
||||
@for (release of recentReleases(); track release.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/releases', release.id]">{{ release.name }}</a>
|
||||
<a [routerLink]="['/release-control/releases', release.id]">{{ release.name }}</a>
|
||||
</td>
|
||||
<td>{{ release.version }}</td>
|
||||
<td>
|
||||
@@ -199,7 +199,7 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
||||
<td>{{ release.componentCount }}</td>
|
||||
<td>
|
||||
@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
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
signal,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { TitleCasePipe } from '@angular/common';
|
||||
import { TitleCasePipe, UpperCasePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface EnvironmentCard {
|
||||
@@ -23,10 +23,18 @@ interface EnvironmentCard {
|
||||
sbomFreshness: 'fresh' | 'stale' | 'missing';
|
||||
critRCount: number;
|
||||
highRCount: number;
|
||||
birCoverage: string;
|
||||
pendingApprovals: number;
|
||||
lastDeployedAt: string;
|
||||
}
|
||||
|
||||
interface NightlyOpsSignal {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface MissionSummary {
|
||||
activePromotions: number;
|
||||
blockedPromotions: number;
|
||||
@@ -37,15 +45,15 @@ interface MissionSummary {
|
||||
@Component({
|
||||
selector: 'app-dashboard-v3',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TitleCasePipe],
|
||||
imports: [RouterLink, TitleCasePipe, UpperCasePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="mission-board">
|
||||
<!-- Header: environment selector, date range filter, mission summary -->
|
||||
<header class="board-header">
|
||||
<div class="header-identity">
|
||||
<h1 class="board-title">Mission Board</h1>
|
||||
<p class="board-subtitle">Release pipeline health across all regions and environments</p>
|
||||
<h1 class="board-title">Dashboard</h1>
|
||||
<p class="board-subtitle">Mission board for release health across regions and environments</p>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
@@ -151,6 +159,10 @@ interface MissionSummary {
|
||||
{{ env.highRCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">B/I/R</span>
|
||||
<span class="metric-value">{{ env.birCoverage }}</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Pending</span>
|
||||
<span class="metric-value" [class.warning]="env.pendingApprovals > 0">
|
||||
@@ -181,38 +193,79 @@ interface MissionSummary {
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="cards-row">
|
||||
<!-- SBOM Snapshot Card -->
|
||||
<section class="domain-card" aria-label="SBOM snapshot">
|
||||
<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>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="snapshot-stat">
|
||||
<span class="stat-value">{{ sbomStats().totalComponents.toLocaleString() }}</span>
|
||||
<span class="stat-label">Total Components</span>
|
||||
<span class="stat-value">{{ sbomStats().criticalEnvCount }}</span>
|
||||
<span class="stat-label">Critical Reachable Environments</span>
|
||||
</div>
|
||||
<div class="snapshot-stat">
|
||||
<span class="stat-value danger">{{ sbomStats().criticalFindings }}</span>
|
||||
<span class="stat-label">Critical Findings</span>
|
||||
<span class="stat-value danger">{{ sbomStats().totalCritR }}</span>
|
||||
<span class="stat-label">Total Critical Reachable Findings</span>
|
||||
</div>
|
||||
<div class="snapshot-stat">
|
||||
<span class="stat-value" [class.warning]="sbomStats().staleCount > 0">
|
||||
{{ sbomStats().staleCount }}
|
||||
<span class="stat-value" [class.warning]="sbomStats().noIssueCount > 0">
|
||||
{{ sbomStats().noIssueCount }}
|
||||
</span>
|
||||
<span class="stat-label">Stale SBOMs</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>
|
||||
<span class="stat-label">Environments with No Critical Findings</span>
|
||||
</div>
|
||||
@if (sbomStats().totalCritR === 0) {
|
||||
<p class="card-note">No critical reachable issues detected in current scope.</p>
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -256,32 +309,25 @@ interface MissionSummary {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Integrity Summary Card -->
|
||||
<section class="domain-card" aria-label="Data integrity summary">
|
||||
<!-- Nightly Ops Signals Card -->
|
||||
<section class="domain-card" aria-label="Nightly ops signals">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Data Integrity</h2>
|
||||
<a routerLink="/platform-ops/data-integrity" class="card-link">Platform Ops detail</a>
|
||||
<h2 class="card-title">Nightly Ops Signals</h2>
|
||||
<a routerLink="/platform-ops/data-integrity" class="card-link">Open Data Integrity</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="integrity-stat" [class.warning]="dataIntegrityStats().staleFeedCount > 0">
|
||||
<span class="stat-value">{{ dataIntegrityStats().staleFeedCount }}</span>
|
||||
<span class="stat-label">Stale Feeds</span>
|
||||
</div>
|
||||
<div class="integrity-stat" [class.danger]="dataIntegrityStats().failedScans > 0">
|
||||
<span class="stat-value">{{ dataIntegrityStats().failedScans }}</span>
|
||||
<span class="stat-label">Failed Scans</span>
|
||||
</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 > Data Integrity</a>.
|
||||
</p>
|
||||
@for (signal of nightlyOpsSignals(); track signal.id) {
|
||||
<div class="integrity-stat" [class.warning]="signal.status === 'warn'" [class.danger]="signal.status === 'fail'">
|
||||
<span class="stat-label">{{ signal.label }}</span>
|
||||
<span class="integrity-status" [class]="'integrity-status--' + signal.status">
|
||||
{{ signal.status | uppercase }}
|
||||
</span>
|
||||
<span class="integrity-detail">{{ signal.detail }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
@@ -511,7 +557,7 @@ interface MissionSummary {
|
||||
|
||||
.env-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -567,6 +613,47 @@ interface MissionSummary {
|
||||
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 {
|
||||
display: grid;
|
||||
@@ -694,18 +781,45 @@ interface MissionSummary {
|
||||
|
||||
/* Data Integrity */
|
||||
.integrity-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
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.danger .stat-value { color: var(--color-status-error); }
|
||||
.integrity-stat:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.integrity-ownership-note a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
.integrity-status {
|
||||
font-size: 0.7rem;
|
||||
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 */
|
||||
@@ -791,6 +905,7 @@ export class DashboardV3Component {
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 0,
|
||||
highRCount: 2,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: '2h ago',
|
||||
},
|
||||
@@ -802,6 +917,7 @@ export class DashboardV3Component {
|
||||
sbomFreshness: 'stale',
|
||||
critRCount: 1,
|
||||
highRCount: 5,
|
||||
birCoverage: '2/3',
|
||||
pendingApprovals: 2,
|
||||
lastDeployedAt: '6h ago',
|
||||
},
|
||||
@@ -813,6 +929,7 @@ export class DashboardV3Component {
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 3,
|
||||
highRCount: 8,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 1,
|
||||
lastDeployedAt: '1d ago',
|
||||
},
|
||||
@@ -824,6 +941,7 @@ export class DashboardV3Component {
|
||||
sbomFreshness: 'fresh',
|
||||
critRCount: 0,
|
||||
highRCount: 1,
|
||||
birCoverage: '3/3',
|
||||
pendingApprovals: 0,
|
||||
lastDeployedAt: '3h ago',
|
||||
},
|
||||
@@ -835,6 +953,7 @@ export class DashboardV3Component {
|
||||
sbomFreshness: 'missing',
|
||||
critRCount: 5,
|
||||
highRCount: 12,
|
||||
birCoverage: '1/3',
|
||||
pendingApprovals: 3,
|
||||
lastDeployedAt: '3d ago',
|
||||
},
|
||||
@@ -848,12 +967,26 @@ export class DashboardV3Component {
|
||||
);
|
||||
});
|
||||
|
||||
// Placeholder SBOM stats
|
||||
readonly sbomStats = signal({
|
||||
totalComponents: 24_850,
|
||||
criticalFindings: 8,
|
||||
staleCount: 2,
|
||||
missingCount: 1,
|
||||
readonly riskEnvironments = computed(() =>
|
||||
this.filteredEnvironments().filter((env) => {
|
||||
const isSbomRisk = env.sbomFreshness === 'stale' || env.sbomFreshness === 'missing';
|
||||
const isReachabilityRisk = env.critRCount > 0;
|
||||
const isDeployRisk = env.deployStatus === 'degraded' || env.deployStatus === 'blocked';
|
||||
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
|
||||
@@ -863,12 +996,32 @@ export class DashboardV3Component {
|
||||
rCoverage: 61,
|
||||
});
|
||||
|
||||
// Placeholder data integrity stats
|
||||
readonly dataIntegrityStats = signal({
|
||||
staleFeedCount: 1,
|
||||
failedScans: 0,
|
||||
dlqDepth: 3,
|
||||
});
|
||||
readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([
|
||||
{
|
||||
id: 'sbom-rescan',
|
||||
label: 'SBOM rescan',
|
||||
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 {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
@@ -35,11 +35,11 @@ import {
|
||||
(click)="replayAllRetryable()"
|
||||
[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 }})
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
}
|
||||
@if (entry()?.state === 'replayed') {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -654,7 +654,7 @@ export class DeadLetterEntryDetailComponent implements OnInit, OnDestroy {
|
||||
next: (response) => {
|
||||
this.hideReplayDialog();
|
||||
if (response.success && response.newJobId) {
|
||||
window.location.href = `/orchestrator/jobs/${response.newJobId}`;
|
||||
window.location.href = `/platform-ops/orchestrator/jobs/${response.newJobId}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,47 +9,52 @@ import { Routes } from '@angular/router';
|
||||
export const evidenceExportRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'bundles',
|
||||
redirectTo: 'export',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'bundles',
|
||||
title: 'Evidence Bundles',
|
||||
data: { breadcrumb: 'Evidence Bundles' },
|
||||
loadComponent: () =>
|
||||
import('./evidence-bundles.component').then(
|
||||
(m) => m.EvidenceBundlesComponent
|
||||
),
|
||||
data: { title: 'Evidence Bundles' },
|
||||
},
|
||||
{
|
||||
path: 'export',
|
||||
title: 'Export Center',
|
||||
data: { breadcrumb: 'Export Center' },
|
||||
loadComponent: () =>
|
||||
import('./export-center.component').then(
|
||||
(m) => m.ExportCenterComponent
|
||||
),
|
||||
data: { title: 'Export Center' },
|
||||
},
|
||||
{
|
||||
path: 'replay',
|
||||
title: 'Verdict Replay',
|
||||
data: { breadcrumb: 'Verdict Replay' },
|
||||
loadComponent: () =>
|
||||
import('./replay-controls.component').then(
|
||||
(m) => m.ReplayControlsComponent
|
||||
),
|
||||
data: { title: 'Verdict Replay' },
|
||||
},
|
||||
{
|
||||
path: 'proof-chains',
|
||||
title: 'Proof Chains',
|
||||
data: { breadcrumb: 'Proof Chains' },
|
||||
loadComponent: () =>
|
||||
import('../proof-chain/proof-chain.component').then(
|
||||
(m) => m.ProofChainComponent
|
||||
),
|
||||
data: { title: 'Proof Chains' },
|
||||
},
|
||||
{
|
||||
path: 'provenance',
|
||||
title: 'Evidence Provenance',
|
||||
data: { breadcrumb: 'Evidence Provenance' },
|
||||
loadComponent: () =>
|
||||
import('./provenance-visualization.component').then(
|
||||
(m) => m.ProvenanceVisualizationComponent
|
||||
),
|
||||
data: { title: 'Evidence Provenance' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="dashboard-subtitle">Manage policy exceptions with auditable workflows.</p>
|
||||
</div>
|
||||
<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-primary" (click)="openWizard()">+ New Exception</button>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,9 @@ describe('ExceptionDashboardComponent', () => {
|
||||
component.selectException(viewException);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ export class ExceptionDashboardComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
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.subscribeToEvents();
|
||||
@@ -140,12 +140,12 @@ export class ExceptionDashboardComponent implements OnInit, OnDestroy {
|
||||
|
||||
selectException(exception: Exception): void {
|
||||
this.selectedExceptionId.set(exception.id);
|
||||
this.router.navigate(['/exceptions', exception.id]);
|
||||
this.router.navigate([exception.id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.selectedExceptionId.set(null);
|
||||
this.router.navigate(['/exceptions']);
|
||||
this.router.navigate(['../'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
async handleTransition(payload: { exception: Exception; to: ExceptionStatus }): Promise<void> {
|
||||
|
||||
@@ -57,7 +57,19 @@ import { IntegrationType } from './integration.models';
|
||||
|
||||
<section class="hub-summary">
|
||||
<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>
|
||||
</div>
|
||||
`,
|
||||
@@ -160,9 +172,38 @@ import { IntegrationType } from './integration.models';
|
||||
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);
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -71,6 +71,11 @@ export const integrationHubRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
{
|
||||
path: 'ci-cd',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'ci',
|
||||
},
|
||||
|
||||
// Category: Targets / Runtimes
|
||||
{
|
||||
@@ -85,6 +90,11 @@ export const integrationHubRoutes: Routes = [
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'hosts',
|
||||
},
|
||||
{
|
||||
path: 'targets',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'hosts',
|
||||
},
|
||||
|
||||
// Category: Secrets Managers
|
||||
{
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
@for (integration of integrations; track integration.integrationId) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/settings/integrations', integration.integrationId]">{{ integration.name }}</a>
|
||||
<a [routerLink]="['/integrations', integration.integrationId]">{{ integration.name }}</a>
|
||||
</td>
|
||||
<td>{{ getProviderName(integration.provider) }}</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)="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>
|
||||
<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>
|
||||
</tr>
|
||||
}
|
||||
@@ -331,12 +331,12 @@ export class IntegrationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 {
|
||||
void this.router.navigate(
|
||||
['/settings/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
|
||||
['/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,14 +23,14 @@ import { AUTH_SERVICE, AuthService } from '../../core/auth';
|
||||
</header>
|
||||
|
||||
<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-title">Jobs</span>
|
||||
<span class="orch-dashboard__card-desc">View job status and history</span>
|
||||
</a>
|
||||
|
||||
@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-title">Quotas</span>
|
||||
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RouterLink } from '@angular/router';
|
||||
template: `
|
||||
<div class="orch-job-detail">
|
||||
<header class="orch-job-detail__header">
|
||||
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">← Back to Jobs</a>
|
||||
<a routerLink="/platform-ops/orchestrator/jobs" class="orch-job-detail__back">← Back to Jobs</a>
|
||||
<h1 class="orch-job-detail__title">Job Detail</h1>
|
||||
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
|
||||
<div class="orch-job-detail__actions">
|
||||
|
||||
@@ -150,7 +150,7 @@ interface OrchestratorJob {
|
||||
@if (job.parentJobId) {
|
||||
<div class="job-parent">
|
||||
<span class="label">Parent Job:</span>
|
||||
<a [routerLink]="['/orchestrator/jobs', job.parentJobId]">
|
||||
<a [routerLink]="['/platform-ops/orchestrator/jobs', job.parentJobId]">
|
||||
{{ job.parentJobId }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@ interface OrchestratorJob {
|
||||
<span class="label">Child Jobs ({{ job.childJobIds.length }}):</span>
|
||||
<div class="children-list">
|
||||
@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>
|
||||
@@ -190,7 +190,7 @@ interface OrchestratorJob {
|
||||
}
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
[routerLink]="['/orchestrator/jobs', job.id]"
|
||||
[routerLink]="['/platform-ops/orchestrator/jobs', job.id]"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
View Details
|
||||
@@ -198,7 +198,7 @@ interface OrchestratorJob {
|
||||
@if (job.childJobIds.length > 0) {
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
[routerLink]="['/orchestrator/jobs', job.id, 'dag']"
|
||||
[routerLink]="['/platform-ops/orchestrator/jobs', job.id, 'dag']"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
View DAG
|
||||
|
||||
@@ -1,254 +1,371 @@
|
||||
/**
|
||||
* 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.
|
||||
* Provides overview and drilldown links for:
|
||||
* - 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)
|
||||
* Platform Ops source of truth for feed freshness, scan health,
|
||||
* reachability ingest, integration connectivity, and DLQ impact.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { UpperCasePipe } from '@angular/common';
|
||||
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;
|
||||
title: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
route: string;
|
||||
ownerNote?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-data-integrity-overview',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
imports: [RouterLink, UpperCasePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="di-overview">
|
||||
<header class="di-overview__header">
|
||||
<h1 class="di-overview__title">Data Integrity</h1>
|
||||
<p class="di-overview__subtitle">
|
||||
Platform Ops source of truth for feed freshness, pipeline health, and data quality SLOs.
|
||||
</p>
|
||||
<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 & Risk</a>.
|
||||
<div class="data-integrity-overview">
|
||||
<header class="header">
|
||||
<h1>Data Integrity</h1>
|
||||
<p>
|
||||
Release-confidence view for feed freshness, scan pipeline health, reachability ingest,
|
||||
integrations, and DLQ safety.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="di-overview__grid">
|
||||
@for (section of sections; track section.id) {
|
||||
<a class="di-card" [routerLink]="section.route">
|
||||
<div class="di-card__body">
|
||||
<h2 class="di-card__title">{{ section.title }}</h2>
|
||||
<p class="di-card__description">{{ section.description }}</p>
|
||||
@if (section.ownerNote) {
|
||||
<span class="di-card__owner">{{ section.ownerNote }}</span>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
<section class="filters" aria-label="Scope filters">
|
||||
<label>
|
||||
Region
|
||||
<select [value]="region()" (change)="onRegionChange($event)">
|
||||
<option value="all">All regions</option>
|
||||
<option value="eu-west">EU West</option>
|
||||
<option value="us-east">US East</option>
|
||||
<option value="ap-south">AP South</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<section class="di-overview__related">
|
||||
<h2 class="di-overview__section-heading">Related Operational Controls</h2>
|
||||
<ul class="di-overview__links">
|
||||
<li><a routerLink="/platform-ops/feeds">Feeds & Mirrors</a> — feed source management</li>
|
||||
<li><a routerLink="/platform-ops/dead-letter">Dead-Letter Queue</a> — failed message replay</li>
|
||||
<li><a routerLink="/platform-ops/slo">SLO Monitoring</a> — burn-rate and error budgets</li>
|
||||
<li><a routerLink="/platform-ops/doctor">Diagnostics</a> — registry connectivity checks</li>
|
||||
</ul>
|
||||
<h2 class="di-overview__section-heading">Security & Risk Consumers</h2>
|
||||
<ul class="di-overview__links">
|
||||
<li>
|
||||
<a routerLink="/security-risk/advisory-sources">Advisory Sources</a> — gating impact from fresh data
|
||||
</li>
|
||||
</ul>
|
||||
<section class="panel" aria-label="Drilldowns">
|
||||
<h2>Drilldowns</h2>
|
||||
<div class="drilldowns">
|
||||
<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="/platform-ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
|
||||
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
|
||||
<a routerLink="/platform-ops/data-integrity/integration-connectivity">Integration Connectivity</a>
|
||||
<a routerLink="/platform-ops/data-integrity/dlq">DLQ and Replays</a>
|
||||
<a routerLink="/platform-ops/data-integrity/slos">Data Quality SLOs</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.di-overview {
|
||||
.data-integrity-overview {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.di-overview__links li {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
.list li {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.di-overview__links a {
|
||||
color: var(--color-brand-primary, #4f46e5);
|
||||
.list a,
|
||||
.drilldowns a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.di-overview__links a:hover {
|
||||
text-decoration: underline;
|
||||
.list span {
|
||||
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 {
|
||||
readonly sections: IntegritySection[] = [
|
||||
readonly region = signal('all');
|
||||
readonly timeWindow = signal('24h');
|
||||
|
||||
readonly trustSignals: TrustSignal[] = [
|
||||
{
|
||||
id: 'nightly-report',
|
||||
title: 'Nightly Data Quality Report',
|
||||
description: 'Aggregated quality metrics, freshness scores, and anomaly flags from last run.',
|
||||
route: '/platform-ops/health',
|
||||
ownerNote: 'Platform Ops — source of truth',
|
||||
id: 'feeds',
|
||||
label: 'Feeds Freshness',
|
||||
status: 'warn',
|
||||
detail: 'NVD feed stale by 3h 12m',
|
||||
route: '/platform-ops/data-integrity/feeds-freshness',
|
||||
},
|
||||
{
|
||||
id: 'feeds-freshness',
|
||||
title: 'Feeds Freshness',
|
||||
description: 'Advisory feed source staleness, last-sync timestamps, and delta alerts.',
|
||||
route: '/platform-ops/feeds',
|
||||
ownerNote: 'Platform Ops — connectivity ownership',
|
||||
id: 'scan',
|
||||
label: 'SBOM Pipeline',
|
||||
status: 'ok',
|
||||
detail: 'Nightly rescan completed',
|
||||
route: '/platform-ops/data-integrity/scan-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'scan-pipeline',
|
||||
title: 'Scan Pipeline Health',
|
||||
description: 'Scanner queue depth, throughput rates, and error rates.',
|
||||
route: '/platform-ops/health',
|
||||
ownerNote: 'Platform Ops — pipeline operations',
|
||||
id: 'reachability',
|
||||
label: 'Reachability Ingest',
|
||||
status: 'warn',
|
||||
detail: 'Runtime backlog elevated',
|
||||
route: '/platform-ops/data-integrity/reachability-ingest',
|
||||
},
|
||||
{
|
||||
id: 'reachability-ingest',
|
||||
title: 'Reachability Ingest Health',
|
||||
description: 'Reachability graph ingestion status and stale-graph detection.',
|
||||
route: '/platform-ops/health',
|
||||
ownerNote: 'Platform Ops — ingest operations',
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
status: 'ok',
|
||||
detail: 'Core connectors are reachable',
|
||||
route: '/platform-ops/data-integrity/integration-connectivity',
|
||||
},
|
||||
{
|
||||
id: 'integration-connectivity',
|
||||
title: 'Integration Connectivity',
|
||||
description: 'Live connectivity status for all registered integration connectors.',
|
||||
route: '/integrations',
|
||||
ownerNote: 'Integrations — connector ownership',
|
||||
},
|
||||
{
|
||||
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',
|
||||
id: 'dlq',
|
||||
label: 'DLQ',
|
||||
status: 'warn',
|
||||
detail: '3 items pending replay',
|
||||
route: '/platform-ops/data-integrity/dlq',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="proof-chain-container" [class.expanded]="expandedView">
|
||||
<div class="proof-chain-header">
|
||||
<h2>Evidence Chain</h2>
|
||||
<h2>Proof Chains</h2>
|
||||
<div class="proof-chain-actions">
|
||||
@if (hasData()) {
|
||||
<button class="btn-icon" (click)="refresh()" [disabled]="loading()" title="Refresh proof chain">
|
||||
@@ -10,6 +10,27 @@
|
||||
</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()) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
@@ -54,8 +75,8 @@
|
||||
<div class="proof-chain-graph">
|
||||
@if (nodeCount() === 0) {
|
||||
<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-description">Refresh to check whether new attestations were published.</p>
|
||||
<p class="empty-graph-title">No proof chain found for this subject.</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>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -35,6 +35,31 @@
|
||||
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 {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProofChainService } from './proof-chain.service';
|
||||
import { ProofNode, ProofChainResponse } from './proof-chain.models';
|
||||
|
||||
@@ -43,7 +46,7 @@ import { ProofNode, ProofChainResponse } from './proof-chain.models';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-proof-chain',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './proof-chain.component.html',
|
||||
styleUrls: ['./proof-chain.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -64,6 +67,8 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly proofChain = signal<ProofChainResponse | null>(null);
|
||||
readonly selectedNode = signal<ProofNode | null>(null);
|
||||
readonly inputDigest = signal('');
|
||||
readonly validationError = signal<string | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly hasData = computed(() => this.proofChain() !== null);
|
||||
@@ -72,6 +77,7 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
private cytoscapeInstance: any = null;
|
||||
private readonly proofChainService: ProofChainService;
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
constructor(private readonly service: ProofChainService) {
|
||||
this.proofChainService = service;
|
||||
@@ -86,7 +92,14 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -107,26 +120,45 @@ export class ProofChainComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* Load proof chain from API
|
||||
*/
|
||||
loadProofChain(): void {
|
||||
if (!this.subjectDigest) {
|
||||
this.error.set('Subject digest is required');
|
||||
const digest = this.subjectDigest.trim();
|
||||
if (!digest) {
|
||||
this.validationError.set('A subject digest is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
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) => {
|
||||
this.proofChain.set(chain);
|
||||
this.loading.set(false);
|
||||
},
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
* Note: This is a placeholder implementation. In production, install cytoscape via npm.
|
||||
|
||||
@@ -25,15 +25,15 @@ import {
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -289,7 +289,7 @@ import {
|
||||
}
|
||||
@if (!recentViolations().length) {
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 > 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'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ import {
|
||||
<div class="environment-card" [class.is-production]="env.isProduction">
|
||||
<div class="card-header">
|
||||
<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 }}
|
||||
</a>
|
||||
@if (env.isProduction) {
|
||||
@@ -69,8 +69,8 @@ import {
|
||||
<button class="menu-btn" (click)="toggleMenu(env.id, $event)">...</button>
|
||||
@if (openMenuId === env.id) {
|
||||
<div class="dropdown-menu">
|
||||
<a [routerLink]="['/release-orchestrator/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]">View Details</a>
|
||||
<a [routerLink]="['/release-control/regions', 'global', 'environments', env.id, 'settings']">Settings</a>
|
||||
<hr />
|
||||
<button class="danger" (click)="confirmDelete(env)">Delete</button>
|
||||
</div>
|
||||
|
||||
@@ -16,31 +16,40 @@ export const ENVIRONMENT_ROUTES: Routes = [
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
data: {
|
||||
breadcrumb: 'Environment Detail',
|
||||
tabs: [
|
||||
'overview',
|
||||
'deployments',
|
||||
'sbom',
|
||||
'reachability',
|
||||
'inputs',
|
||||
'promotions',
|
||||
'data-integrity',
|
||||
'evidence',
|
||||
],
|
||||
},
|
||||
path: ':region/:env',
|
||||
title: 'Environment Detail',
|
||||
loadComponent: () =>
|
||||
import('./environment-detail/environment-detail.component').then(
|
||||
(m) => m.EnvironmentDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':id/settings',
|
||||
data: { breadcrumb: 'Environment Settings', tab: 'settings' },
|
||||
path: ':region/:env/settings',
|
||||
title: 'Environment Detail',
|
||||
data: { initialTab: 'inputs' },
|
||||
loadComponent: () =>
|
||||
import('./environment-detail/environment-detail.component').then(
|
||||
(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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.
|
||||
<div class="schedule-management">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<a routerLink="/scheduler/runs" class="back-link">← Back to Runs</a>
|
||||
<a routerLink="/platform-ops/scheduler/runs" class="back-link">← Back to Runs</a>
|
||||
<h1>Schedule Management</h1>
|
||||
<p>Create, edit, and manage scheduled tasks.</p>
|
||||
</div>
|
||||
|
||||
@@ -28,10 +28,10 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
|
||||
<p>Monitor and manage scheduled task executions.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" routerLink="/scheduler/schedules">
|
||||
<button class="btn btn-secondary" routerLink="/platform-ops/scheduler/schedules">
|
||||
Manage Schedules
|
||||
</button>
|
||||
<button class="btn btn-secondary" routerLink="/scheduler/workers">
|
||||
<button class="btn btn-secondary" routerLink="/platform-ops/scheduler/workers">
|
||||
Worker Fleet
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ import { SchedulerRun, SchedulerRunStatus } from './scheduler-ops.models';
|
||||
</button>
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
[routerLink]="['/scheduler/runs', run.id, 'stream']"
|
||||
[routerLink]="['/platform-ops/scheduler/runs', run.id, 'stream']"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
Live Stream
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
<div class="worker-fleet">
|
||||
<header class="page-header">
|
||||
<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>
|
||||
<p>Monitor worker status, load distribution, and health.</p>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ interface AdvisorySummaryVm {
|
||||
<div>
|
||||
<h1>Advisory Sources</h1>
|
||||
<p>
|
||||
Security and Risk decision-impact surface. Connectivity belongs to Integrations. Mirror
|
||||
Security & Risk decision-impact surface. Connectivity belongs to Integrations. Mirror
|
||||
and freshness operations belong to Platform Ops.
|
||||
</p>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ interface AdvisorySummaryVm {
|
||||
@if (rows().length === 0) {
|
||||
<section class="empty" aria-label="No advisory sources">
|
||||
<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 & Risk can evaluate impact.</p>
|
||||
<a routerLink="/integrations/feeds">Open Integrations Feeds</a>
|
||||
</section>
|
||||
} @else {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -38,6 +38,12 @@ interface RiskSummaryCard {
|
||||
</div>
|
||||
</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 -->
|
||||
<section class="cards-grid primary-cards" aria-label="Security risk summary">
|
||||
<!-- Risk Score Card -->
|
||||
@@ -92,6 +98,29 @@ interface RiskSummaryCard {
|
||||
</a>
|
||||
</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 & Exceptions</h2>
|
||||
<p>Statements {{ vexPosture().statements }} | Expiring exceptions {{ vexPosture().expiringExceptions }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Contextual navigation links -->
|
||||
<section class="context-links" aria-label="Related surfaces">
|
||||
<h2 class="context-links-title">More in Security & Risk</h2>
|
||||
@@ -149,6 +178,25 @@ interface RiskSummaryCard {
|
||||
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 */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
@@ -163,6 +211,63 @@ interface RiskSummaryCard {
|
||||
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 */
|
||||
.card {
|
||||
display: flex;
|
||||
@@ -359,4 +464,21 @@ export class SecurityRiskOverviewComponent {
|
||||
link: '/security-risk/reachability',
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-sbom-graph-page',
|
||||
imports: [],
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
@@ -16,10 +17,114 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
<h1 class="page-title">SBOM Graph</h1>
|
||||
<p class="page-subtitle">Visualize dependency relationships and component impact.</p>
|
||||
</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>
|
||||
</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 {}
|
||||
|
||||
@@ -619,7 +619,7 @@ export class VulnerabilityDetailPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
createRemediationTask(): void {
|
||||
void this.router.navigate(['/operations/orchestrator/jobs'], {
|
||||
void this.router.navigate(['/platform-ops/orchestrator/jobs'], {
|
||||
queryParams: {
|
||||
action: 'remediate',
|
||||
cveId: this.vuln().id,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<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>
|
||||
|
||||
<div class="settings-grid">
|
||||
|
||||
@@ -3,40 +3,110 @@
|
||||
* 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { IntegrationService } from '../../integration-hub/integration.service';
|
||||
import {
|
||||
Integration,
|
||||
getIntegrationStatusLabel,
|
||||
getIntegrationTypeLabel,
|
||||
} from '../../integration-hub/integration.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-integration-detail-page',
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
selector: 'app-integration-detail-page',
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="integration-detail">
|
||||
<header class="page-header">
|
||||
<a routerLink="../" class="back-link">← Back to Integrations</a>
|
||||
<h1 class="page-title">Integration Detail</h1>
|
||||
<p class="page-subtitle">Integration ID: {{ integrationId() }}</p>
|
||||
<a routerLink="/settings/integrations" class="back-link">Back to Integrations</a>
|
||||
<h1 class="page-title">{{ pageTitle() }}</h1>
|
||||
<p class="page-subtitle">{{ pageSubtitle() }}</p>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab tab--active">Overview</button>
|
||||
<button type="button" class="tab">Health</button>
|
||||
<button type="button" class="tab">Activity</button>
|
||||
<button type="button" class="tab">Permissions</button>
|
||||
<button type="button" class="tab">Secrets</button>
|
||||
<button type="button" class="tab">Webhooks</button>
|
||||
</div>
|
||||
@if (loading()) {
|
||||
<section class="state-card" role="status">Loading integration details...</section>
|
||||
} @else if (error(); as errorMessage) {
|
||||
<section class="state-card state-card--error" role="alert">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="btn btn--secondary" (click)="reload()">Retry</button>
|
||||
</section>
|
||||
} @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">
|
||||
<div class="card">
|
||||
<h3>Integration Overview</h3>
|
||||
<p>Configure and monitor this integration's connection status, permissions, and activity.</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="Integration detail tabs">
|
||||
@for (tab of tabs; track tab) {
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
[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>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.integration-detail {
|
||||
max-width: 1000px;
|
||||
}
|
||||
@@ -64,11 +134,38 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -92,33 +189,151 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
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 {
|
||||
padding: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-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 {
|
||||
margin: 0 0 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
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 {
|
||||
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() {
|
||||
this.route.params.subscribe(params => {
|
||||
this.integrationId.set(params['id'] || '');
|
||||
});
|
||||
this.route.paramMap
|
||||
.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.');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,40 +4,41 @@
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-control-settings-page',
|
||||
imports: [],
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="release-control-settings">
|
||||
<h1 class="page-title">Release Control</h1>
|
||||
<p class="page-subtitle">Configure environments, targets, agents, and workflows</p>
|
||||
<h1 class="page-title">Release Control Settings</h1>
|
||||
<p class="page-subtitle">Configure environments, targets, agents, and workflows.</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="settings-section">
|
||||
<h2>Environments</h2>
|
||||
<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 class="settings-section">
|
||||
<h2>Targets</h2>
|
||||
<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 class="settings-section">
|
||||
<h2>Agents</h2>
|
||||
<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 class="settings-section">
|
||||
<h2>Workflows</h2>
|
||||
<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>
|
||||
</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 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 { 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); }
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Routes } from '@angular/router';
|
||||
export const SETTINGS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Settings',
|
||||
loadComponent: () =>
|
||||
import('./settings-page.component').then(m => m.SettingsPageComponent),
|
||||
data: { breadcrumb: 'Settings' },
|
||||
@@ -19,96 +20,112 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
title: 'Integrations',
|
||||
loadComponent: () =>
|
||||
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Integrations' },
|
||||
},
|
||||
{
|
||||
path: 'integrations/:id',
|
||||
title: 'Integration Detail',
|
||||
loadComponent: () =>
|
||||
import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent),
|
||||
data: { breadcrumb: 'Integration Detail' },
|
||||
},
|
||||
{
|
||||
path: 'configuration-pane',
|
||||
title: 'Configuration Pane',
|
||||
loadComponent: () =>
|
||||
import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent),
|
||||
data: { breadcrumb: 'Configuration Pane' },
|
||||
},
|
||||
{
|
||||
path: 'release-control',
|
||||
title: 'Release Control',
|
||||
loadComponent: () =>
|
||||
import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent),
|
||||
data: { breadcrumb: 'Release Control' },
|
||||
},
|
||||
{
|
||||
path: 'trust',
|
||||
title: 'Trust & Signing',
|
||||
loadComponent: () =>
|
||||
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
loadComponent: () =>
|
||||
import('./trust/trust-settings-page.component').then(m => m.TrustSettingsPageComponent),
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
},
|
||||
{
|
||||
path: 'security-data',
|
||||
title: 'Security Data',
|
||||
loadComponent: () =>
|
||||
import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent),
|
||||
data: { breadcrumb: 'Security Data' },
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
title: 'Identity & Access',
|
||||
loadComponent: () =>
|
||||
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
|
||||
data: { breadcrumb: 'Identity & Access' },
|
||||
},
|
||||
{
|
||||
path: 'admin/:page',
|
||||
title: 'Identity & Access',
|
||||
loadComponent: () =>
|
||||
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
|
||||
data: { breadcrumb: 'Identity & Access' },
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
title: 'Tenant & Branding',
|
||||
loadComponent: () =>
|
||||
import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent),
|
||||
data: { breadcrumb: 'Branding' },
|
||||
data: { breadcrumb: 'Tenant & Branding' },
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
title: 'Usage & Limits',
|
||||
loadComponent: () =>
|
||||
import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent),
|
||||
data: { breadcrumb: 'Usage & Limits' },
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
loadComponent: () =>
|
||||
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
},
|
||||
{
|
||||
path: 'ai-preferences',
|
||||
title: 'AI Preferences',
|
||||
loadComponent: () =>
|
||||
import('./ai-preferences-workbench.component').then(m => m.AiPreferencesWorkbenchComponent),
|
||||
data: { breadcrumb: 'AI Preferences' },
|
||||
},
|
||||
{
|
||||
path: 'policy',
|
||||
title: 'Policy Governance',
|
||||
loadComponent: () =>
|
||||
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
|
||||
data: { breadcrumb: 'Policy Governance' },
|
||||
},
|
||||
{
|
||||
path: 'offline',
|
||||
title: 'Offline Settings',
|
||||
loadComponent: () =>
|
||||
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
|
||||
data: { breadcrumb: 'Offline Settings' },
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
title: 'System',
|
||||
loadComponent: () =>
|
||||
import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent),
|
||||
data: { breadcrumb: 'System' },
|
||||
|
||||
@@ -29,11 +29,14 @@ describe('AppSidebarComponent', () => {
|
||||
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]);
|
||||
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 {
|
||||
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
computed,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
DestroyRef,
|
||||
} from '@angular/core';
|
||||
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AUTH_SERVICE, AuthService } 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 { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
@@ -316,6 +320,8 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
|
||||
|
||||
@Input() collapsed = false;
|
||||
@Output() toggleCollapse = new EventEmitter<void>();
|
||||
@@ -324,8 +330,9 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
/** Whether sidebar is temporarily expanded due to hover */
|
||||
readonly hoverExpanded = signal(false);
|
||||
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']));
|
||||
|
||||
/**
|
||||
@@ -392,9 +399,21 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
{
|
||||
id: 'rc-environments',
|
||||
label: 'Regions & Environments',
|
||||
route: '/release-control/environments',
|
||||
route: '/release-control/regions',
|
||||
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',
|
||||
label: 'Setup',
|
||||
@@ -404,20 +423,22 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
],
|
||||
},
|
||||
|
||||
// 3. Security and Risk
|
||||
// 3. Security & Risk
|
||||
{
|
||||
id: 'security-risk',
|
||||
label: 'Security and Risk',
|
||||
label: 'Security & Risk',
|
||||
icon: 'shield',
|
||||
route: '/security-risk',
|
||||
children: [
|
||||
{ id: 'sr-overview', label: '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-overview', label: 'Risk Overview', route: '/security-risk', icon: 'chart' },
|
||||
{ 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-marketplace', label: 'Symbol Marketplace', route: '/security-risk/symbol-marketplace', icon: 'shopping-bag' },
|
||||
{ id: 'sr-remediation', label: 'Remediation', route: '/security-risk/remediation', icon: 'tool' },
|
||||
@@ -427,16 +448,20 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
// 4. Evidence and Audit
|
||||
{
|
||||
id: 'evidence-audit',
|
||||
label: 'Evidence and Audit',
|
||||
label: 'Evidence & Audit',
|
||||
icon: 'file-text',
|
||||
route: '/evidence-audit',
|
||||
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-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-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: [
|
||||
{ id: 'int-hub', label: 'Hub', route: '/integrations', icon: 'grid' },
|
||||
{ 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-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' },
|
||||
],
|
||||
},
|
||||
@@ -488,7 +513,7 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
{ id: 'adm-notifications', label: 'Notifications', route: '/administration/notifications', icon: 'bell' },
|
||||
{ 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-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' },
|
||||
],
|
||||
},
|
||||
@@ -501,6 +526,17 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
.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 {
|
||||
if (section.requiredScopes && !this.hasAllScopes(section.requiredScopes)) {
|
||||
return null;
|
||||
@@ -522,9 +558,22 @@ export class AppSidebarComponent implements OnDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = visibleChildren.map((child) => this.withDynamicChildState(child));
|
||||
|
||||
return {
|
||||
...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);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!this.collapsed || this.hoverExpanded()) return;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface NavItem {
|
||||
[class.nav-item--child]="isChild"
|
||||
[routerLink]="route"
|
||||
routerLinkActive="nav-item--active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[routerLinkActiveOptions]="{ exact: !isChild }"
|
||||
[attr.title]="collapsed ? label : null"
|
||||
>
|
||||
<span class="nav-item__icon" [attr.aria-hidden]="true">
|
||||
|
||||
@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--fresh]="!isStale()"
|
||||
[class.chip--stale]="isStale()"
|
||||
routerLink="/operations/feeds"
|
||||
routerLink="/platform-ops/feeds"
|
||||
[attr.title]="tooltip()"
|
||||
>
|
||||
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
class="chip"
|
||||
[class.chip--ok]="status() === 'ok'"
|
||||
[class.chip--degraded]="status() === 'degraded'"
|
||||
routerLink="/settings/offline"
|
||||
routerLink="/administration/offline"
|
||||
[attr.title]="tooltip()"
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -235,6 +235,15 @@ export const ADMINISTRATION_ROUTES: Routes = [
|
||||
(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)
|
||||
{
|
||||
path: 'configuration-pane',
|
||||
|
||||
@@ -5,26 +5,132 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
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: 'packs', title: 'Evidence Packs', data: { breadcrumb: 'Evidence Packs' },
|
||||
loadComponent: () => import('../features/evidence-pack/evidence-pack-list.component').then(m => m.EvidencePackListComponent) },
|
||||
{ path: 'packs/:packId', title: 'Evidence Pack', data: { breadcrumb: 'Evidence Pack' },
|
||||
loadComponent: () => import('../features/evidence-pack/evidence-pack-viewer.component').then(m => m.EvidencePackViewerComponent) },
|
||||
{ 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', title: 'Audit Log', data: { breadcrumb: 'Audit Log' },
|
||||
loadChildren: () => import('../features/audit-log/audit-log.routes').then(m => m.auditLogRoutes) },
|
||||
{ path: 'change-trace', title: 'Change Trace', data: { breadcrumb: 'Change Trace' },
|
||||
loadChildren: () => import('../features/change-trace/change-trace.routes').then(m => m.changeTraceRoutes) },
|
||||
{ path: 'evidence', title: 'Evidence', data: { breadcrumb: 'Evidence' },
|
||||
loadChildren: () => import('../features/evidence-export/evidence-export.routes').then(m => m.evidenceExportRoutes) },
|
||||
{
|
||||
path: '',
|
||||
title: 'Evidence & Audit',
|
||||
data: { breadcrumb: 'Evidence & Audit' },
|
||||
loadComponent: () =>
|
||||
import('../features/evidence-audit/evidence-audit-overview.component').then(
|
||||
(m) => m.EvidenceAuditOverviewComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '',
|
||||
},
|
||||
{
|
||||
path: 'packs',
|
||||
title: 'Evidence Packs',
|
||||
data: { breadcrumb: 'Evidence Packs' },
|
||||
loadComponent: () =>
|
||||
import('../features/evidence-pack/evidence-pack-list.component').then(
|
||||
(m) => m.EvidencePackListComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
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
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -60,6 +60,14 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
|
||||
// ===========================================
|
||||
{ path: 'findings', redirectTo: '/security-risk/findings', 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: 'vulnerabilities', redirectTo: '/security-risk/vulnerabilities', 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: 'analyze/unknowns', redirectTo: '/security-risk/unknowns', 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' },
|
||||
|
||||
// ===========================================
|
||||
@@ -154,11 +164,28 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
|
||||
// ===========================================
|
||||
{ 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
|
||||
// ===========================================
|
||||
{ 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/approvals', redirectTo: '/release-control/approvals', 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
|
||||
// ===========================================
|
||||
{ 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/:packId', redirectTo: '/evidence-audit/packs/:packId', pathMatch: 'full' },
|
||||
// Keep /proofs/* as permanent short alias for convenience
|
||||
|
||||
@@ -145,9 +145,9 @@ export const PLATFORM_OPS_ROUTES: Routes = [
|
||||
path: 'data-integrity',
|
||||
title: 'Data Integrity',
|
||||
data: { breadcrumb: 'Data Integrity' },
|
||||
loadComponent: () =>
|
||||
import('../features/platform-ops/data-integrity-overview.component').then(
|
||||
(m) => m.DataIntegrityOverviewComponent
|
||||
loadChildren: () =>
|
||||
import('../features/platform-ops/data-integrity/data-integrity.routes').then(
|
||||
(m) => m.dataIntegrityRoutes
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,10 +11,21 @@ import { Routes } from '@angular/router';
|
||||
export const RELEASE_CONTROL_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'releases',
|
||||
redirectTo: 'control-plane',
|
||||
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)
|
||||
{
|
||||
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',
|
||||
data: { breadcrumb: 'Regions & Environments' },
|
||||
loadChildren: () =>
|
||||
import('../features/release-orchestrator/environments/environments.routes').then(
|
||||
(m) => m.ENVIRONMENT_ROUTES
|
||||
loadComponent: () =>
|
||||
import('../features/release-control/regions/regions-overview.component').then(
|
||||
(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
|
||||
{
|
||||
@@ -158,4 +221,26 @@ export const RELEASE_CONTROL_ROUTES: 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
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,58 +5,246 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
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: 'findings', title: 'Findings', data: { breadcrumb: 'Findings' },
|
||||
loadComponent: () => import('../features/findings/container/findings-container.component').then(m => m.FindingsContainerComponent) },
|
||||
{ path: 'advisory-sources', title: 'Advisory Sources', data: { breadcrumb: 'Advisory Sources' },
|
||||
loadComponent: () => import('../features/security-risk/advisory-sources.component').then(m => m.AdvisorySourcesComponent) },
|
||||
{ path: 'vulnerabilities', title: 'Vulnerabilities', data: { breadcrumb: 'Vulnerabilities' },
|
||||
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' },
|
||||
loadComponent: () => import('../features/scans/scan-detail-page.component').then(m => m.ScanDetailPageComponent) },
|
||||
{ path: 'sbom', title: 'SBOM', data: { breadcrumb: 'SBOM' },
|
||||
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: '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: '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', 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) },
|
||||
{
|
||||
path: '',
|
||||
title: 'Risk Overview',
|
||||
data: { breadcrumb: 'Risk Overview' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/security-risk-overview.component').then(
|
||||
(m) => m.SecurityRiskOverviewComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
title: 'Findings Explorer',
|
||||
data: { breadcrumb: 'Findings Explorer' },
|
||||
loadComponent: () =>
|
||||
import('../features/findings/container/findings-container.component').then(
|
||||
(m) => m.FindingsContainerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'findings/:findingId',
|
||||
title: 'Finding Detail',
|
||||
data: { breadcrumb: 'Finding Detail' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/finding-detail-page.component').then(
|
||||
(m) => m.FindingDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'advisory-sources',
|
||||
title: 'Advisory Sources',
|
||||
data: { breadcrumb: 'Advisory Sources' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/advisory-sources.component').then(
|
||||
(m) => m.AdvisorySourcesComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities',
|
||||
title: 'Vulnerabilities Explorer',
|
||||
data: { breadcrumb: 'Vulnerabilities Explorer' },
|
||||
loadComponent: () =>
|
||||
import('../features/vulnerabilities/vulnerability-explorer.component').then(
|
||||
(m) => m.VulnerabilityExplorerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities/:cveId',
|
||||
title: 'Vulnerability Detail',
|
||||
data: { breadcrumb: 'Vulnerability Detail' },
|
||||
loadComponent: () =>
|
||||
import('../features/security-risk/vulnerability-detail-page.component').then(
|
||||
(m) => m.VulnerabilityDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
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
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -52,22 +52,22 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
`,
|
||||
styles: [`
|
||||
.freshness-widget {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid var(--color-text-primary);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.freshness-widget.fresh {
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
border-color: var(--color-status-success-border);
|
||||
}
|
||||
|
||||
.freshness-widget.stale {
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
border-color: var(--color-status-warning-border);
|
||||
}
|
||||
|
||||
.freshness-widget.expired {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: var(--color-status-error-border);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@@ -85,17 +85,17 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
|
||||
.fresh .status-dot {
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
.age-value {
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-border-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.age-unit {
|
||||
@@ -165,20 +165,21 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
|
||||
.freshness-message {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
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);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.stale .freshness-message {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-border);
|
||||
}
|
||||
|
||||
.expired .freshness-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-border);
|
||||
}
|
||||
|
||||
@@ -199,7 +200,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-border-primary);
|
||||
background: var(--color-brand-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -209,7 +210,7 @@ import { BundleFreshness, BundleFreshnessInfo } from '../../core/api/offline-kit
|
||||
top: -4px;
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
background: var(--color-text-secondary);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.marker-7 {
|
||||
|
||||
@@ -345,7 +345,7 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
|
||||
|
||||
viewAllResults(type: string): void {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ import { NavigationService } from '../../../core/navigation';
|
||||
<!-- User Info -->
|
||||
<div class="user-menu__info">
|
||||
<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 class="user-menu__divider"></div>
|
||||
@@ -178,6 +178,32 @@ export class UserMenuComponent {
|
||||
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 {
|
||||
this.menuOpen.update(open => !open);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const CANONICAL_PATHS = [
|
||||
'usage', // A4
|
||||
'policy-governance', // A5
|
||||
'trust-signing', // A6
|
||||
'offline', // A6.5
|
||||
'system', // A7
|
||||
];
|
||||
|
||||
@@ -76,6 +77,11 @@ describe('ADMINISTRATION_ROUTES (administration)', () => {
|
||||
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', () => {
|
||||
const legacyRoots = ['settings', 'operations', 'security', 'evidence', 'policy'];
|
||||
const canonicalPaths = new Set(CANONICAL_PATHS.filter((path) => path.length > 0));
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
|
||||
let params$: BehaviorSubject<Record<string, string>>;
|
||||
|
||||
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({
|
||||
imports: [ApprovalDetailPageComponent],
|
||||
@@ -28,38 +28,91 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('binds route id and opens witness details from security diff rows', () => {
|
||||
expect(component.approvalId()).toBe('APPR-2026-045');
|
||||
it('binds route id and renders all eight approval-detail tabs', () => {
|
||||
expect(component.approval().id).toBe('apr-2026-045');
|
||||
|
||||
component.openWitness('CVE-2026-1234');
|
||||
const witness = component.selectedWitness();
|
||||
|
||||
expect(witness).toBeTruthy();
|
||||
expect(witness!.findingId).toBe('CVE-2026-1234');
|
||||
expect(witness!.state).toBe('reachable');
|
||||
const labels = component.tabs.map((tab) => tab.label);
|
||||
expect(labels).toEqual([
|
||||
'Overview',
|
||||
'Gates',
|
||||
'Security',
|
||||
'Reachability',
|
||||
'Ops/Data',
|
||||
'Evidence',
|
||||
'Replay/Verify',
|
||||
'History',
|
||||
]);
|
||||
expect(component.activeTab()).toBe('overview');
|
||||
});
|
||||
|
||||
it('supports witness close and approval decision lifecycle actions', () => {
|
||||
component.openWitness('CVE-2026-5678');
|
||||
expect(component.selectedWitness()).toBeTruthy();
|
||||
it('shows standardized readiness header fields and digest metadata', () => {
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
|
||||
component.closeWitness();
|
||||
expect(component.selectedWitness()).toBeNull();
|
||||
expect(text).toContain('Manifest Digest');
|
||||
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();
|
||||
expect(component.approval().status).toBe('approved');
|
||||
expect(component.approval().decidedBy).toBe('Current User');
|
||||
expect(component.approval().status).toBe('pending');
|
||||
});
|
||||
|
||||
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();
|
||||
expect(component.approval().status).toBe('rejected');
|
||||
expect(component.approval().decidedBy).toBe('Current User');
|
||||
});
|
||||
|
||||
it('adds comments and clears input', () => {
|
||||
component.newComment = 'Reviewed witness details and accepted risk.';
|
||||
component.addComment();
|
||||
it('renders gates tab with decision digest and expandable gate trace', () => {
|
||||
component.setActiveTab('gates');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.comments.length).toBe(1);
|
||||
expect(component.comments[0].body).toContain('Reviewed witness details');
|
||||
expect(component.newComment).toBe('');
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Decision digest');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,10 @@ describe('ApprovalsInboxComponent (approvals)', () => {
|
||||
let fixture: ComponentFixture<ApprovalsInboxComponent>;
|
||||
let component: ApprovalsInboxComponent;
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem('approvals.data-integrity-banner-dismissed');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ApprovalsInboxComponent],
|
||||
@@ -140,4 +144,46 @@ describe('ApprovalsInboxComponent (approvals)', () => {
|
||||
expect(text).toContain('API Gateway v1.2.5');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,10 +99,20 @@ const anomaliesFixture: AuditAnomalyAlert[] = [
|
||||
];
|
||||
|
||||
describe('unified-audit-log-viewer behavior', () => {
|
||||
it('declares admin/audit app route and expected unified-audit child routes', () => {
|
||||
const appRoute = routes.find((route) => route.path === 'admin/audit');
|
||||
expect(appRoute).toBeDefined();
|
||||
expect(typeof appRoute?.loadChildren).toBe('function');
|
||||
it('declares canonical evidence-audit route and keeps admin/audit redirect alias', () => {
|
||||
const legacyAlias = routes.find((route) => route.path === 'admin/audit');
|
||||
expect(legacyAlias).toBeDefined();
|
||||
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);
|
||||
expect(childPaths).toEqual([
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('ControlPlaneDashboardComponent (control_plane)', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const errorMessage = fixture.nativeElement.querySelector(
|
||||
'.dashboard__error-message'
|
||||
'.dashboard__error-banner-detail'
|
||||
) as HTMLElement;
|
||||
expect(errorMessage.textContent).toContain('dashboard unavailable');
|
||||
});
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -93,4 +93,15 @@ describe('DeadLetterDashboardComponent (deadletter)', () => {
|
||||
expect(component.canResolve({ ...entry, state: 'failed' })).toBeTrue();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,12 +24,16 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
|
||||
expect(allPaths).toContain('packs');
|
||||
});
|
||||
|
||||
it('contains the bundles list route', () => {
|
||||
expect(allPaths).toContain('bundles');
|
||||
});
|
||||
|
||||
it('contains the pack detail route', () => {
|
||||
expect(allPaths).toContain('packs/:packId');
|
||||
});
|
||||
|
||||
it('contains the audit log route', () => {
|
||||
expect(allPaths).toContain('audit');
|
||||
it('contains the audit-log route', () => {
|
||||
expect(allPaths).toContain('audit-log');
|
||||
});
|
||||
|
||||
it('contains the change-trace route', () => {
|
||||
@@ -64,15 +68,15 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
|
||||
// Overview route breadcrumb
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
it('overview route has "Evidence and Audit" breadcrumb', () => {
|
||||
it('overview route has "Evidence & Audit" breadcrumb', () => {
|
||||
const overviewRoute = getRouteByPath('');
|
||||
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('');
|
||||
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', () => {
|
||||
for (const route of EVIDENCE_AUDIT_ROUTES) {
|
||||
for (const route of EVIDENCE_AUDIT_ROUTES.filter((r) => r.redirectTo === undefined)) {
|
||||
expect(route.data?.['breadcrumb']).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -98,7 +102,7 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -118,7 +122,7 @@ describe('EVIDENCE_AUDIT_ROUTES', () => {
|
||||
});
|
||||
|
||||
it('replay route has "Replay / Verify" breadcrumb', () => {
|
||||
expect(getRouteByPath('replay')?.data?.['breadcrumb']).toBe('Replay / Verify');
|
||||
expect(getRouteByPath('replay')?.data?.['breadcrumb']).toBe('Replay & Verify');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('Integration Hub UI (integration_hub)', () => {
|
||||
const totals = new Map<IntegrationType, number>([
|
||||
[IntegrationType.Registry, 5],
|
||||
[IntegrationType.Scm, 3],
|
||||
[IntegrationType.Ci, 2],
|
||||
[IntegrationType.Host, 4],
|
||||
[IntegrationType.Feed, 1],
|
||||
[IntegrationType.CiCd, 2],
|
||||
[IntegrationType.RuntimeHost, 4],
|
||||
[IntegrationType.FeedMirror, 1],
|
||||
]);
|
||||
|
||||
return of({
|
||||
@@ -91,6 +91,12 @@ describe('Integration Hub UI (integration_hub)', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
|
||||
import { AUTH_SERVICE } from '../../app/core/auth';
|
||||
import { APPROVAL_API } from '../../app/core/api/approval.client';
|
||||
|
||||
const CANONICAL_DOMAIN_IDS = [
|
||||
'dashboard',
|
||||
@@ -40,8 +42,8 @@ const CANONICAL_DOMAIN_ROUTES = [
|
||||
const EXPECTED_SECTION_LABELS: Record<string, string> = {
|
||||
'dashboard': 'Dashboard',
|
||||
'release-control': 'Release Control',
|
||||
'security-risk': 'Security and Risk',
|
||||
'evidence-audit': 'Evidence and Audit',
|
||||
'security-risk': 'Security & Risk',
|
||||
'evidence-audit': 'Evidence & Audit',
|
||||
'integrations': 'Integrations',
|
||||
'platform-ops': 'Platform Ops',
|
||||
'administration': 'Administration',
|
||||
@@ -54,12 +56,52 @@ describe('AppSidebarComponent nav model (navigation)', () => {
|
||||
const authSpy = jasmine.createSpyObj('AuthService', ['hasAllScopes', 'hasAnyScope']);
|
||||
authSpy.hasAllScopes.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({
|
||||
imports: [AppSidebarComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_SERVICE, useValue: authSpy },
|
||||
{ provide: APPROVAL_API, useValue: approvalApiSpy },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -107,6 +149,12 @@ describe('AppSidebarComponent nav model (navigation)', () => {
|
||||
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', () => {
|
||||
const rc = component.navSections.find((s) => s.id === 'release-control')!;
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const legacyPrefixes = ['/security/', '/operations/', '/settings/', '/evidence/', '/policy/'];
|
||||
const legacyRootSegments = ['security', 'operations', 'settings', 'evidence', 'policy'];
|
||||
for (const section of component.navSections) {
|
||||
for (const prefix of legacyPrefixes) {
|
||||
expect(section.route + '/').not.toContain(prefix);
|
||||
}
|
||||
const rootSegment = section.route.replace(/^\/+/, '').split('/')[0] ?? '';
|
||||
expect(legacyRootSegments).not.toContain(rootSegment);
|
||||
}
|
||||
});
|
||||
|
||||
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 child of section.children ?? []) {
|
||||
for (const prefix of legacyPrefixes) {
|
||||
expect(child.route + '/').not.toContain(prefix);
|
||||
}
|
||||
const rootSegment = child.route.replace(/^\/+/, '').split('/')[0] ?? '';
|
||||
expect(legacyRootSegments).not.toContain(rootSegment);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user