Add signal contracts for reachability, exploitability, trust, and unknown symbols
- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties. - Implemented JSON serialization attributes for proper data interchange. - Created project files for the new signal contracts library and corresponding test projects. - Added deterministic test fixtures for micro-interaction testing. - Included cryptographic keys for secure operations with cosign.
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
@@ -100,13 +100,32 @@
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"port": 4600,
|
||||
"quiet": true,
|
||||
"ci": true
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"outputDir": "storybook-static",
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9057
src/Web/StellaOps.Web/package-lock.json
generated
9057
src/Web/StellaOps.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,8 @@
|
||||
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
|
||||
"verify:chromium": "node ./scripts/verify-chromium.js",
|
||||
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
|
||||
"storybook": "storybook dev -p 4600",
|
||||
"storybook:build": "storybook build",
|
||||
"storybook": "ng run stellaops-web:storybook",
|
||||
"storybook:build": "ng run stellaops-web:build-storybook",
|
||||
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
|
||||
},
|
||||
"engines": {
|
||||
@@ -45,7 +45,6 @@
|
||||
"@storybook/addon-essentials": "8.1.0",
|
||||
"@storybook/addon-interactions": "8.1.0",
|
||||
"@storybook/angular": "8.1.0",
|
||||
"@storybook/angular-renderer": "8.1.0",
|
||||
"@storybook/test": "8.1.0",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"storybook": "8.1.0",
|
||||
|
||||
50
src/Web/StellaOps.Web/scripts/storybook.js
Normal file
50
src/Web/StellaOps.Web/scripts/storybook.js
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal Storybook wrapper to avoid missing legacy CLI bins.
|
||||
* Supports:
|
||||
* node scripts/storybook.js dev --ci --quiet --port 4600
|
||||
* node scripts/storybook.js build --quiet
|
||||
*/
|
||||
const { dev, build } = require('@storybook/core-server');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const mode = args.shift() ?? 'dev';
|
||||
|
||||
const hasFlag = (flag) => args.includes(flag);
|
||||
const getFlagValue = (flag) => {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx >= 0 ? args[idx + 1] : undefined;
|
||||
};
|
||||
|
||||
const ci = hasFlag('--ci') || process.env.CI === 'true';
|
||||
const quiet = hasFlag('--quiet') || hasFlag('-q');
|
||||
const port = Number(getFlagValue('--port') ?? process.env.STORYBOOK_PORT ?? 4600);
|
||||
const host = process.env.STORYBOOK_HOST ?? '127.0.0.1';
|
||||
const configDir = process.env.STORYBOOK_CONFIG_DIR ?? '.storybook';
|
||||
const outputDir = process.env.STORYBOOK_OUTPUT_DIR ?? 'storybook-static';
|
||||
|
||||
async function run() {
|
||||
if (mode === 'build') {
|
||||
await build({
|
||||
configDir,
|
||||
outputDir,
|
||||
quiet,
|
||||
loglevel: quiet ? 'warn' : 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await dev({
|
||||
configDir,
|
||||
port,
|
||||
host,
|
||||
ci,
|
||||
quiet,
|
||||
loglevel: quiet ? 'warn' : 'info',
|
||||
});
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import {
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
} from './core/auth';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'dashboard/sources',
|
||||
@@ -15,6 +20,46 @@ export const routes: Routes = [
|
||||
(m) => m.ConsoleProfileComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'console/status',
|
||||
loadComponent: () =>
|
||||
import('./features/console/console-status.component').then(
|
||||
(m) => m.ConsoleStatusComponent
|
||||
),
|
||||
},
|
||||
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
|
||||
{
|
||||
path: 'orchestrator',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-dashboard.component').then(
|
||||
(m) => m.OrchestratorDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/jobs',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-jobs.component').then(
|
||||
(m) => m.OrchestratorJobsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/jobs/:jobId',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-job-detail.component').then(
|
||||
(m) => m.OrchestratorJobDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/quotas',
|
||||
canMatch: [requireOrchOperatorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-quotas.component').then(
|
||||
(m) => m.OrchestratorQuotasComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
@@ -29,29 +74,29 @@ export const routes: Routes = [
|
||||
(m) => m.ScanDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
loadComponent: () =>
|
||||
import('./features/welcome/welcome-page.component').then(
|
||||
(m) => m.WelcomePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'risk',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/risk/risk-dashboard.component').then(
|
||||
(m) => m.RiskDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities/:vulnId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/vulnerabilities/vulnerability-detail.component').then(
|
||||
(m) => m.VulnerabilityDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
loadComponent: () =>
|
||||
import('./features/welcome/welcome-page.component').then(
|
||||
(m) => m.WelcomePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'risk',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/risk/risk-dashboard.component').then(
|
||||
(m) => m.RiskDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities/:vulnId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/vulnerabilities/vulnerability-detail.component').then(
|
||||
(m) => m.VulnerabilityDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -135,6 +135,81 @@ export interface AocChainEntry {
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
| 'CODE_NOT_REACHABLE'
|
||||
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
|
||||
| 'CONFIGURATION_NOT_AFFECTED'
|
||||
| 'OS_NOT_AFFECTED'
|
||||
| 'RUNTIME_MITIGATION_PRESENT'
|
||||
| 'COMPENSATING_CONTROLS'
|
||||
| 'ACCEPTED_BUSINESS_RISK'
|
||||
| 'OTHER';
|
||||
|
||||
export interface VexSubjectRef {
|
||||
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
|
||||
readonly name: string;
|
||||
readonly digest: Record<string, string>;
|
||||
readonly sbomNodeId?: string;
|
||||
}
|
||||
|
||||
export interface VexEvidenceRef {
|
||||
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
|
||||
readonly title?: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface VexScope {
|
||||
readonly environments?: readonly string[];
|
||||
readonly projects?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VexValidFor {
|
||||
readonly notBefore?: string;
|
||||
readonly notAfter?: string;
|
||||
}
|
||||
|
||||
export interface VexActorRef {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export interface VexDecision {
|
||||
readonly id: string;
|
||||
readonly vulnerabilityId: string;
|
||||
readonly subject: VexSubjectRef;
|
||||
readonly status: VexStatus;
|
||||
readonly justificationType: VexJustificationType;
|
||||
readonly justificationText?: string;
|
||||
readonly evidenceRefs?: readonly VexEvidenceRef[];
|
||||
readonly scope?: VexScope;
|
||||
readonly validFor?: VexValidFor;
|
||||
readonly supersedesDecisionId?: string;
|
||||
readonly createdBy: VexActorRef;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt?: string;
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
readonly vulnerabilityId: string;
|
||||
readonly conflictingStatuses: readonly VexStatus[];
|
||||
readonly decisionIds: readonly string[];
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
@@ -142,6 +217,8 @@ export interface EvidenceData {
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly vexDecisions?: readonly VexDecision[];
|
||||
readonly vexConflicts?: readonly VexConflict[];
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
@@ -155,6 +232,32 @@ export interface SourceInfo {
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
// Filter configuration for observations/linksets
|
||||
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
|
||||
|
||||
export interface ObservationFilters {
|
||||
readonly sources: readonly string[]; // Filter by source IDs
|
||||
readonly severityBucket: SeverityBucket; // Filter by severity level
|
||||
readonly conflictOnly: boolean; // Show only observations with conflicts
|
||||
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
|
||||
}
|
||||
|
||||
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
|
||||
sources: [],
|
||||
severityBucket: 'all',
|
||||
conflictOnly: false,
|
||||
hasCvssVector: null,
|
||||
};
|
||||
|
||||
// Pagination configuration
|
||||
export interface PaginationState {
|
||||
readonly pageSize: number;
|
||||
readonly currentPage: number;
|
||||
readonly totalItems: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject } from '@angular/core';
|
||||
import { CanMatchFn, Router } from '@angular/router';
|
||||
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import { StellaOpsScopes, type StellaOpsScope } from './scopes';
|
||||
|
||||
/**
|
||||
* Simple guard to prevent unauthenticated navigation to protected routes.
|
||||
@@ -13,3 +14,116 @@ export const requireAuthGuard: CanMatchFn = () => {
|
||||
const isAuthenticated = auth.isAuthenticated();
|
||||
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a guard that requires specific scopes.
|
||||
* Redirects to /welcome if not authenticated, or returns false if missing scopes.
|
||||
*
|
||||
* @param requiredScopes - Scopes that must all be present
|
||||
* @param redirectPath - Optional path to redirect to if scope check fails (default: none, just denies)
|
||||
*/
|
||||
export function requireScopesGuard(
|
||||
requiredScopes: readonly StellaOpsScope[],
|
||||
redirectPath?: string
|
||||
): CanMatchFn {
|
||||
return () => {
|
||||
const auth = inject(AuthSessionStore);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/welcome']);
|
||||
}
|
||||
|
||||
const session = auth.session();
|
||||
const userScopes = session?.scopes ?? [];
|
||||
|
||||
// Admin scope grants access to everything
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasAllRequired = requiredScopes.every((scope) =>
|
||||
userScopes.includes(scope)
|
||||
);
|
||||
|
||||
if (hasAllRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (redirectPath) {
|
||||
return router.createUrlTree([redirectPath]);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a guard that requires any of the specified scopes.
|
||||
* Redirects to /welcome if not authenticated, or returns false if no matching scopes.
|
||||
*
|
||||
* @param requiredScopes - At least one of these scopes must be present
|
||||
* @param redirectPath - Optional path to redirect to if scope check fails
|
||||
*/
|
||||
export function requireAnyScopeGuard(
|
||||
requiredScopes: readonly StellaOpsScope[],
|
||||
redirectPath?: string
|
||||
): CanMatchFn {
|
||||
return () => {
|
||||
const auth = inject(AuthSessionStore);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/welcome']);
|
||||
}
|
||||
|
||||
const session = auth.session();
|
||||
const userScopes = session?.scopes ?? [];
|
||||
|
||||
// Admin scope grants access to everything
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasAnyRequired = requiredScopes.some((scope) =>
|
||||
userScopes.includes(scope)
|
||||
);
|
||||
|
||||
if (hasAnyRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (redirectPath) {
|
||||
return router.createUrlTree([redirectPath]);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-built guards for common scope requirements (UI-ORCH-32-001)
|
||||
|
||||
/**
|
||||
* Guard requiring orch:read scope for Orchestrator dashboard access.
|
||||
* Redirects to /console/profile if user lacks Orchestrator viewer access.
|
||||
*/
|
||||
export const requireOrchViewerGuard: CanMatchFn = requireScopesGuard(
|
||||
[StellaOpsScopes.ORCH_READ],
|
||||
'/console/profile'
|
||||
);
|
||||
|
||||
/**
|
||||
* Guard requiring orch:operate scope for Orchestrator control actions.
|
||||
*/
|
||||
export const requireOrchOperatorGuard: CanMatchFn = requireScopesGuard(
|
||||
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_OPERATE],
|
||||
'/console/profile'
|
||||
);
|
||||
|
||||
/**
|
||||
* Guard requiring orch:quota scope for quota management.
|
||||
*/
|
||||
export const requireOrchQuotaGuard: CanMatchFn = requireScopesGuard(
|
||||
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_QUOTA],
|
||||
'/console/profile'
|
||||
);
|
||||
|
||||
@@ -41,6 +41,11 @@ export interface AuthService {
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
// Orchestrator access (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean;
|
||||
canOperateOrchestrator(): boolean;
|
||||
canManageOrchestratorQuotas(): boolean;
|
||||
canInitiateBackfill(): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -75,6 +80,10 @@ const MOCK_USER: AuthUser = {
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
// Orchestrator permissions (UI-ORCH-32-001)
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
// UI permissions
|
||||
StellaOpsScopes.UI_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -118,6 +127,23 @@ export class MockAuthService implements AuthService {
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
|
||||
// Orchestrator access methods (UI-ORCH-32-001)
|
||||
canViewOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_READ);
|
||||
}
|
||||
|
||||
canOperateOrchestrator(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
|
||||
}
|
||||
|
||||
canManageOrchestratorQuotas(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
|
||||
}
|
||||
|
||||
canInitiateBackfill(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
|
||||
@@ -14,3 +14,12 @@ export {
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
|
||||
export {
|
||||
requireAuthGuard,
|
||||
requireScopesGuard,
|
||||
requireAnyScopeGuard,
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
requireOrchQuotaGuard,
|
||||
} from './auth.guard';
|
||||
|
||||
@@ -49,6 +49,15 @@ export const StellaOpsScopes = {
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Orchestrator scopes (UI-ORCH-32-001)
|
||||
ORCH_READ: 'orch:read',
|
||||
ORCH_OPERATE: 'orch:operate',
|
||||
ORCH_QUOTA: 'orch:quota',
|
||||
ORCH_BACKFILL: 'orch:backfill',
|
||||
|
||||
// UI scopes
|
||||
UI_READ: 'ui.read',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
@@ -99,6 +108,26 @@ export const ScopeGroups = {
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
|
||||
// Orchestrator scope groups (UI-ORCH-32-001)
|
||||
ORCH_VIEWER: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_OPERATOR: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
|
||||
ORCH_ADMIN: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
StellaOpsScopes.ORCH_QUOTA,
|
||||
StellaOpsScopes.ORCH_BACKFILL,
|
||||
StellaOpsScopes.UI_READ,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -129,6 +158,14 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
// Orchestrator scope labels (UI-ORCH-32-001)
|
||||
'orch:read': 'View Orchestrator Jobs',
|
||||
'orch:operate': 'Operate Orchestrator',
|
||||
'orch:quota': 'Manage Orchestrator Quotas',
|
||||
'orch:backfill': 'Initiate Backfill Runs',
|
||||
// UI scope labels
|
||||
'ui.read': 'Console Access',
|
||||
// Admin scope labels
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
};
|
||||
|
||||
@@ -5,16 +5,56 @@
|
||||
<h2 id="evidence-panel-title" class="evidence-panel__title">
|
||||
Evidence: {{ advisoryId() }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__close"
|
||||
(click)="onClose()"
|
||||
aria-label="Close evidence panel"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<div class="evidence-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__permalink-btn"
|
||||
(click)="togglePermalink()"
|
||||
[attr.aria-expanded]="showPermalink()"
|
||||
aria-controls="permalink-section"
|
||||
title="Share permalink"
|
||||
>
|
||||
<span aria-hidden="true">🔗</span>
|
||||
<span class="visually-hidden">Share permalink</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__close"
|
||||
(click)="onClose()"
|
||||
aria-label="Close evidence panel"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permalink Section -->
|
||||
@if (showPermalink()) {
|
||||
<div id="permalink-section" class="evidence-panel__permalink">
|
||||
<label for="permalink-input" class="visually-hidden">Permalink URL</label>
|
||||
<input
|
||||
id="permalink-input"
|
||||
type="text"
|
||||
readonly
|
||||
[value]="permalink()"
|
||||
class="evidence-panel__permalink-input"
|
||||
aria-describedby="permalink-hint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__copy-btn"
|
||||
[class.copied]="permalinkCopied()"
|
||||
(click)="copyPermalink()"
|
||||
[attr.aria-label]="permalinkCopied() ? 'Copied!' : 'Copy to clipboard'"
|
||||
>
|
||||
{{ permalinkCopied() ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<span id="permalink-hint" class="evidence-panel__permalink-hint">
|
||||
Share this link to navigate directly to this evidence view
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Policy Decision Summary -->
|
||||
@if (policyEvidence(); as policy) {
|
||||
<div class="evidence-panel__decision-summary" [class]="policyDecisionClass()">
|
||||
@@ -77,7 +117,7 @@
|
||||
[attr.aria-selected]="isActiveTab('observations')"
|
||||
(click)="setActiveTab('observations')"
|
||||
>
|
||||
Observations ({{ observations().length }})
|
||||
Observations ({{ filteredObservations().length }}@if (activeFilterCount() > 0) {/{{ observations().length }}})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,6 +130,21 @@
|
||||
>
|
||||
Linkset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="evidence-panel__tab"
|
||||
[class.active]="isActiveTab('vex')"
|
||||
[class.has-conflicts]="hasVexConflicts()"
|
||||
[attr.aria-selected]="isActiveTab('vex')"
|
||||
(click)="setActiveTab('vex')"
|
||||
[disabled]="!hasVexData()"
|
||||
>
|
||||
VEX ({{ vexDecisions().length }})
|
||||
@if (hasVexConflicts()) {
|
||||
<span class="conflict-indicator" aria-label="Has conflicts">!</span>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -123,26 +178,191 @@
|
||||
role="tabpanel"
|
||||
aria-label="Observations"
|
||||
>
|
||||
<!-- View Toggle -->
|
||||
<div class="evidence-panel__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'side-by-side'"
|
||||
(click)="setObservationView('side-by-side')"
|
||||
aria-label="Side by side view"
|
||||
>
|
||||
Side by Side
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'stacked'"
|
||||
(click)="setObservationView('stacked')"
|
||||
aria-label="Stacked view"
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
<!-- Toolbar: View Toggle + Filters -->
|
||||
<div class="evidence-panel__toolbar">
|
||||
<div class="evidence-panel__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'side-by-side'"
|
||||
(click)="setObservationView('side-by-side')"
|
||||
aria-label="Side by side view"
|
||||
>
|
||||
Side by Side
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'stacked'"
|
||||
(click)="setObservationView('stacked')"
|
||||
aria-label="Stacked view"
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="evidence-panel__filter-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle-btn"
|
||||
[class.active]="showFilters()"
|
||||
(click)="toggleFilters()"
|
||||
[attr.aria-expanded]="showFilters()"
|
||||
aria-controls="observation-filters"
|
||||
>
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<span class="filter-badge" aria-label="{{ activeFilterCount() }} active filters">
|
||||
{{ activeFilterCount() }}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
@if (activeFilterCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="filter-clear-btn"
|
||||
(click)="clearFilters()"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel -->
|
||||
@if (showFilters()) {
|
||||
<div id="observation-filters" class="evidence-panel__filters" role="group" aria-label="Observation filters">
|
||||
<!-- Source Filter -->
|
||||
<fieldset class="filter-group">
|
||||
<legend>Source</legend>
|
||||
<div class="filter-options">
|
||||
@for (source of availableSources(); track source.sourceId) {
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSourceSelected(source.sourceId)"
|
||||
(change)="toggleSourceFilter(source.sourceId)"
|
||||
/>
|
||||
<span>{{ source.name }}</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Severity Bucket Filter -->
|
||||
<fieldset class="filter-group">
|
||||
<legend>Severity</legend>
|
||||
<div class="filter-options filter-options--inline">
|
||||
<label class="filter-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="severity"
|
||||
[checked]="isSeverityBucketSelected('all')"
|
||||
(change)="updateSeverityBucket('all')"
|
||||
/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
<label class="filter-radio severity-critical">
|
||||
<input
|
||||
type="radio"
|
||||
name="severity"
|
||||
[checked]="isSeverityBucketSelected('critical')"
|
||||
(change)="updateSeverityBucket('critical')"
|
||||
/>
|
||||
<span>Critical</span>
|
||||
</label>
|
||||
<label class="filter-radio severity-high">
|
||||
<input
|
||||
type="radio"
|
||||
name="severity"
|
||||
[checked]="isSeverityBucketSelected('high')"
|
||||
(change)="updateSeverityBucket('high')"
|
||||
/>
|
||||
<span>High</span>
|
||||
</label>
|
||||
<label class="filter-radio severity-medium">
|
||||
<input
|
||||
type="radio"
|
||||
name="severity"
|
||||
[checked]="isSeverityBucketSelected('medium')"
|
||||
(change)="updateSeverityBucket('medium')"
|
||||
/>
|
||||
<span>Medium</span>
|
||||
</label>
|
||||
<label class="filter-radio severity-low">
|
||||
<input
|
||||
type="radio"
|
||||
name="severity"
|
||||
[checked]="isSeverityBucketSelected('low')"
|
||||
(change)="updateSeverityBucket('low')"
|
||||
/>
|
||||
<span>Low</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Conflict Only Filter -->
|
||||
@if (hasConflicts()) {
|
||||
<fieldset class="filter-group">
|
||||
<legend>Conflicts</legend>
|
||||
<div class="filter-options">
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="filters().conflictOnly"
|
||||
(change)="toggleConflictOnly()"
|
||||
/>
|
||||
<span>Show only conflicting sources</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
<!-- CVSS Vector Presence Filter -->
|
||||
<fieldset class="filter-group">
|
||||
<legend>CVSS Vector</legend>
|
||||
<div class="filter-options filter-options--inline">
|
||||
<label class="filter-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="cvss-vector"
|
||||
[checked]="filters().hasCvssVector === null"
|
||||
(change)="updateCvssVectorFilter(null)"
|
||||
/>
|
||||
<span>All</span>
|
||||
</label>
|
||||
<label class="filter-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="cvss-vector"
|
||||
[checked]="filters().hasCvssVector === true"
|
||||
(change)="updateCvssVectorFilter(true)"
|
||||
/>
|
||||
<span>Has Vector</span>
|
||||
</label>
|
||||
<label class="filter-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="cvss-vector"
|
||||
[checked]="filters().hasCvssVector === false"
|
||||
(change)="updateCvssVectorFilter(false)"
|
||||
/>
|
||||
<span>No Vector</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="evidence-panel__results-summary">
|
||||
<span>
|
||||
Showing {{ paginatedObservations().length }} of {{ filteredObservations().length }}
|
||||
@if (filteredObservations().length !== observations().length) {
|
||||
({{ observations().length }} total)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Observations Grid -->
|
||||
@@ -151,7 +371,7 @@
|
||||
[class.side-by-side]="observationView() === 'side-by-side'"
|
||||
[class.stacked]="observationView() === 'stacked'"
|
||||
>
|
||||
@for (obs of observations(); track trackByObservationId($index, obs)) {
|
||||
@for (obs of paginatedObservations(); track trackByObservationId($index, obs)) {
|
||||
<article
|
||||
class="observation-card"
|
||||
[class.expanded]="isObservationExpanded(obs.observationId)"
|
||||
@@ -299,6 +519,65 @@
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
@if (totalPages() > 1) {
|
||||
<nav class="evidence-panel__pagination" aria-label="Observation pagination">
|
||||
<div class="pagination-info">
|
||||
Page {{ currentPage() + 1 }} of {{ totalPages() }}
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn"
|
||||
[disabled]="!hasPreviousPage()"
|
||||
(click)="previousPage()"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<!-- Page number buttons (show max 5) -->
|
||||
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
|
||||
@let pageNum = currentPage() < 2 ? i : Math.min(currentPage() - 2 + i, totalPages() - 1);
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn pagination-btn--number"
|
||||
[class.active]="currentPage() === pageNum"
|
||||
(click)="goToPage(pageNum)"
|
||||
[attr.aria-current]="currentPage() === pageNum ? 'page' : null"
|
||||
aria-label="Page {{ pageNum + 1 }}"
|
||||
>
|
||||
{{ pageNum + 1 }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn"
|
||||
[disabled]="!hasNextPage()"
|
||||
(click)="nextPage()"
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pagination-size">
|
||||
<label for="page-size">Per page:</label>
|
||||
<select
|
||||
id="page-size"
|
||||
[value]="pageSize()"
|
||||
(change)="updatePageSize(+$any($event.target).value)"
|
||||
>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -436,6 +715,216 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- VEX Tab -->
|
||||
@if (isActiveTab('vex')) {
|
||||
<section
|
||||
class="evidence-panel__section"
|
||||
role="tabpanel"
|
||||
aria-label="VEX Decisions"
|
||||
>
|
||||
<div class="vex-panel">
|
||||
<!-- Header with export actions -->
|
||||
<header class="vex-panel__header">
|
||||
<div class="vex-panel__title">
|
||||
<h3>VEX Decisions</h3>
|
||||
<p class="vex-panel__description">
|
||||
Vulnerability exploitability decisions for this advisory
|
||||
</p>
|
||||
</div>
|
||||
<div class="vex-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="vex-export-btn"
|
||||
(click)="onExportVex('json')"
|
||||
title="Export as JSON"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vex-export-btn"
|
||||
(click)="onExportVex('openvex')"
|
||||
title="Export as OpenVEX"
|
||||
>
|
||||
Export OpenVEX
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vex-export-btn"
|
||||
(click)="onExportVex('csaf')"
|
||||
title="Export as CSAF VEX"
|
||||
>
|
||||
Export CSAF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Status Summary Cards -->
|
||||
<div class="vex-panel__summary">
|
||||
<div class="vex-summary-card vex-summary-card--not-affected">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
||||
<span class="vex-summary-card__label">Not Affected</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--mitigated">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
|
||||
<span class="vex-summary-card__label">Mitigated</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--unmitigated">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
|
||||
<span class="vex-summary-card__label">Unmitigated</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--fixed">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().fixed }}</span>
|
||||
<span class="vex-summary-card__label">Fixed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VEX Conflicts Warning -->
|
||||
@if (hasVexConflicts()) {
|
||||
<div class="vex-panel__conflicts" role="alert">
|
||||
<header class="vex-conflicts__header">
|
||||
<span class="vex-conflicts__icon" aria-hidden="true">!</span>
|
||||
<span class="vex-conflicts__title">
|
||||
{{ vexConflicts().length }} Conflicting Decision(s) Detected
|
||||
</span>
|
||||
</header>
|
||||
<ul class="vex-conflicts__list">
|
||||
@for (conflict of vexConflicts(); track trackByVexConflictId($index, conflict)) {
|
||||
<li class="vex-conflicts__item">
|
||||
<strong>{{ conflict.vulnerabilityId }}:</strong>
|
||||
{{ conflict.reason }}
|
||||
<span class="vex-conflicts__statuses">
|
||||
({{ conflict.conflictingStatuses.map(getVexStatusLabel).join(' vs ') }})
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- VEX Decisions List -->
|
||||
<div class="vex-panel__decisions">
|
||||
<h4>Decisions ({{ vexDecisions().length }})</h4>
|
||||
@for (decision of vexDecisions(); track trackByVexDecisionId($index, decision)) {
|
||||
<article
|
||||
class="vex-decision-card"
|
||||
[class.expired]="isVexDecisionExpired(decision)"
|
||||
[class.pending]="isVexDecisionPending(decision)"
|
||||
>
|
||||
<header class="vex-decision-card__header">
|
||||
<div class="vex-decision-card__status">
|
||||
<span
|
||||
class="vex-status-badge"
|
||||
[class]="getVexStatusClass(decision.status)"
|
||||
>
|
||||
{{ getVexStatusLabel(decision.status) }}
|
||||
</span>
|
||||
@if (isVexDecisionExpired(decision)) {
|
||||
<span class="vex-expired-badge">Expired</span>
|
||||
}
|
||||
@if (isVexDecisionPending(decision)) {
|
||||
<span class="vex-pending-badge">Pending</span>
|
||||
}
|
||||
</div>
|
||||
<code class="vex-decision-card__vuln-id">{{ decision.vulnerabilityId }}</code>
|
||||
</header>
|
||||
|
||||
<div class="vex-decision-card__body">
|
||||
<!-- Subject -->
|
||||
<div class="vex-decision-card__section">
|
||||
<dt>Subject:</dt>
|
||||
<dd>
|
||||
<span class="vex-subject-type">{{ decision.subject.type }}</span>
|
||||
<code class="vex-subject-name">{{ decision.subject.name }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Justification -->
|
||||
<div class="vex-decision-card__section">
|
||||
<dt>Justification:</dt>
|
||||
<dd>
|
||||
<span class="vex-justification-type">
|
||||
{{ getVexJustificationLabel(decision.justificationType) }}
|
||||
</span>
|
||||
@if (decision.justificationText) {
|
||||
<p class="vex-justification-text">{{ decision.justificationText }}</p>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Scope -->
|
||||
@if (decision.scope) {
|
||||
<div class="vex-decision-card__section">
|
||||
<dt>Scope:</dt>
|
||||
<dd>
|
||||
@if (decision.scope.environments && decision.scope.environments.length > 0) {
|
||||
<span class="vex-scope-label">Environments:</span>
|
||||
<span class="vex-scope-values">{{ decision.scope.environments.join(', ') }}</span>
|
||||
}
|
||||
@if (decision.scope.projects && decision.scope.projects.length > 0) {
|
||||
<span class="vex-scope-label">Projects:</span>
|
||||
<span class="vex-scope-values">{{ decision.scope.projects.join(', ') }}</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Validity -->
|
||||
@if (decision.validFor) {
|
||||
<div class="vex-decision-card__section">
|
||||
<dt>Valid:</dt>
|
||||
<dd>
|
||||
@if (decision.validFor.notBefore) {
|
||||
<span>From {{ formatDate(decision.validFor.notBefore) }}</span>
|
||||
}
|
||||
@if (decision.validFor.notAfter) {
|
||||
<span>Until {{ formatDate(decision.validFor.notAfter) }}</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Evidence References -->
|
||||
@if (decision.evidenceRefs && decision.evidenceRefs.length > 0) {
|
||||
<div class="vex-decision-card__section">
|
||||
<dt>Evidence:</dt>
|
||||
<dd>
|
||||
<ul class="vex-evidence-list">
|
||||
@for (ref of decision.evidenceRefs; track ref.url) {
|
||||
<li>
|
||||
<span class="vex-evidence-type">{{ ref.type }}</span>
|
||||
<a
|
||||
[href]="ref.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="vex-evidence-link"
|
||||
>
|
||||
{{ ref.title || ref.url }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Created By -->
|
||||
<div class="vex-decision-card__footer">
|
||||
<span class="vex-decision-card__author">
|
||||
By {{ decision.createdBy.displayName }}
|
||||
</span>
|
||||
<span class="vex-decision-card__date">
|
||||
{{ formatDate(decision.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Policy Tab -->
|
||||
@if (isActiveTab('policy') && policyEvidence(); as policy) {
|
||||
<section
|
||||
|
||||
@@ -44,6 +44,98 @@ $color-text-muted: #6b7280;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__permalink-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__permalink {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $color-border;
|
||||
}
|
||||
|
||||
&__permalink-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
color: #374151;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
background: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
&__permalink-hint {
|
||||
flex-basis: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -257,10 +349,18 @@ $color-text-muted: #6b7280;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__view-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -288,6 +388,245 @@ $color-text-muted: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__filter-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
.filter-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 10px;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-clear-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 0.8125rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: $color-bg-muted;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
.filter-group {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 150px;
|
||||
|
||||
legend {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
|
||||
&--inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-checkbox,
|
||||
.filter-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
&.severity-critical span {
|
||||
color: $color-critical;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.severity-high span {
|
||||
color: $color-high;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.severity-medium span {
|
||||
color: #a16207;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.severity-low span {
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__results-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid $color-border;
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #9ca3af;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--number {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
|
||||
select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observations Grid
|
||||
@@ -994,6 +1333,412 @@ $color-text-muted: #6b7280;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
// VEX Panel
|
||||
.vex-panel {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__conflicts {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
&__decisions {
|
||||
h4 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vex-export-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8125rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.vex-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
&__count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-muted;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--not-affected {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--mitigated {
|
||||
background: #fef9c3;
|
||||
border-color: #fde047;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #a16207;
|
||||
}
|
||||
}
|
||||
|
||||
&--unmitigated {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
&--fixed {
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vex-conflicts {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&__list {
|
||||
margin: 0;
|
||||
padding-left: 2rem;
|
||||
font-size: 0.875rem;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
&__item {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
&__statuses {
|
||||
color: $color-text-muted;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.vex-decision-card {
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&.expired {
|
||||
opacity: 0.7;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
border-color: #fde047;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: $color-bg-muted;
|
||||
border-bottom: 1px solid $color-border;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__vuln-id {
|
||||
font-size: 0.875rem;
|
||||
background: #f3f4f6;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: $color-text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid $color-border;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.vex-status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.vex-status--not-affected {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.vex-status--mitigated {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.vex-status--unmitigated {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.vex-status--fixed {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.vex-expired-badge,
|
||||
.vex-pending-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vex-expired-badge {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.vex-pending-badge {
|
||||
background: #fef9c3;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.vex-subject-type {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.vex-subject-name {
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.vex-justification-type {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vex-justification-text {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vex-scope-label {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-muted;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.vex-scope-values {
|
||||
font-weight: 500;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.vex-evidence-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
li {
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vex-evidence-type {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.vex-evidence-link {
|
||||
color: #3b82f6;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab conflict indicator
|
||||
.evidence-panel__tab {
|
||||
.conflict-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.has-conflicts {
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
// Animation
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
@@ -1010,3 +1755,16 @@ $color-text-muted: #6b7280;
|
||||
code {
|
||||
font-family: 'Monaco', 'Consolas', 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
// Accessibility utility - visually hidden but accessible to screen readers
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@@ -11,19 +11,28 @@ import {
|
||||
|
||||
import {
|
||||
AocChainEntry,
|
||||
DEFAULT_OBSERVATION_FILTERS,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
LinksetConflict,
|
||||
Observation,
|
||||
ObservationFilters,
|
||||
PolicyDecision,
|
||||
PolicyEvidence,
|
||||
PolicyRuleResult,
|
||||
SeverityBucket,
|
||||
SOURCE_INFO,
|
||||
SourceInfo,
|
||||
VexConflict,
|
||||
VexDecision,
|
||||
VexJustificationType,
|
||||
VexStatus,
|
||||
VexStatusSummary,
|
||||
} from '../../core/api/evidence.models';
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
|
||||
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
|
||||
type TabId = 'observations' | 'linkset' | 'vex' | 'policy' | 'aoc';
|
||||
type ObservationView = 'side-by-side' | 'stacked';
|
||||
|
||||
@Component({
|
||||
@@ -37,6 +46,9 @@ type ObservationView = 'side-by-side' | 'stacked';
|
||||
export class EvidencePanelComponent {
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
// Expose Math for template usage
|
||||
readonly Math = Math;
|
||||
|
||||
// Inputs
|
||||
readonly advisoryId = input.required<string>();
|
||||
readonly evidenceData = input<EvidenceData | null>(null);
|
||||
@@ -52,6 +64,14 @@ export class EvidencePanelComponent {
|
||||
readonly expandedAocEntry = signal<string | null>(null);
|
||||
readonly showConflictDetails = signal(false);
|
||||
|
||||
// Filter state
|
||||
readonly filters = signal<ObservationFilters>(DEFAULT_OBSERVATION_FILTERS);
|
||||
readonly showFilters = signal(false);
|
||||
|
||||
// Pagination state
|
||||
readonly pageSize = signal(DEFAULT_PAGE_SIZE);
|
||||
readonly currentPage = signal(0);
|
||||
|
||||
// Loading/error state
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -83,6 +103,130 @@ export class EvidencePanelComponent {
|
||||
return obs.map((o) => this.getSourceInfo(o.source));
|
||||
});
|
||||
|
||||
// Available sources for filter dropdown
|
||||
readonly availableSources = computed(() => {
|
||||
const obs = this.observations();
|
||||
const sourceIds = [...new Set(obs.map((o) => o.source))];
|
||||
return sourceIds.map((id) => this.getSourceInfo(id));
|
||||
});
|
||||
|
||||
// Filtered observations based on current filters
|
||||
readonly filteredObservations = computed(() => {
|
||||
const obs = this.observations();
|
||||
const f = this.filters();
|
||||
const linkset = this.linkset();
|
||||
|
||||
return obs.filter((o) => {
|
||||
// Source filter
|
||||
if (f.sources.length > 0 && !f.sources.includes(o.source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Severity bucket filter
|
||||
if (f.severityBucket !== 'all') {
|
||||
const maxScore = Math.max(...o.severities.map((s) => s.score), 0);
|
||||
if (!this.matchesSeverityBucket(maxScore, f.severityBucket)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict-only filter
|
||||
if (f.conflictOnly && linkset) {
|
||||
const isInConflict = linkset.conflicts.some((c) =>
|
||||
c.sourceIds?.includes(o.source)
|
||||
);
|
||||
if (!isInConflict) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// CVSS vector presence filter
|
||||
if (f.hasCvssVector !== null) {
|
||||
const hasVector = o.severities.some((s) => !!s.vector);
|
||||
if (f.hasCvssVector !== hasVector) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Paginated observations
|
||||
readonly paginatedObservations = computed(() => {
|
||||
const filtered = this.filteredObservations();
|
||||
const page = this.currentPage();
|
||||
const size = this.pageSize();
|
||||
const start = page * size;
|
||||
return filtered.slice(start, start + size);
|
||||
});
|
||||
|
||||
// Total pages for pagination
|
||||
readonly totalPages = computed(() => {
|
||||
const total = this.filteredObservations().length;
|
||||
const size = this.pageSize();
|
||||
return Math.ceil(total / size);
|
||||
});
|
||||
|
||||
// Whether there are more pages
|
||||
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
|
||||
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
|
||||
|
||||
// Active filter count for badge
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const f = this.filters();
|
||||
let count = 0;
|
||||
if (f.sources.length > 0) count++;
|
||||
if (f.severityBucket !== 'all') count++;
|
||||
if (f.conflictOnly) count++;
|
||||
if (f.hasCvssVector !== null) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
// VEX computed values
|
||||
readonly vexDecisions = computed(() => this.evidenceData()?.vexDecisions ?? []);
|
||||
readonly vexConflicts = computed(() => this.evidenceData()?.vexConflicts ?? []);
|
||||
readonly hasVexData = computed(() => this.vexDecisions().length > 0);
|
||||
readonly hasVexConflicts = computed(() => this.vexConflicts().length > 0);
|
||||
|
||||
// Permalink state
|
||||
readonly showPermalink = signal(false);
|
||||
readonly permalinkCopied = signal(false);
|
||||
|
||||
readonly vexStatusSummary = computed((): VexStatusSummary => {
|
||||
const decisions = this.vexDecisions();
|
||||
return {
|
||||
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
|
||||
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
|
||||
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
||||
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
||||
total: decisions.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Permalink computed value
|
||||
readonly permalink = computed(() => {
|
||||
const advisoryId = this.advisoryId();
|
||||
const tab = this.activeTab();
|
||||
const linkset = this.linkset();
|
||||
const policy = this.policyEvidence();
|
||||
|
||||
// Build query params for current state
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', tab);
|
||||
|
||||
if (linkset) {
|
||||
params.set('linkset', linkset.linksetId);
|
||||
}
|
||||
if (policy) {
|
||||
params.set('policy', policy.policyId);
|
||||
}
|
||||
|
||||
// Base URL with advisory path and query string
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${baseUrl}/evidence/${encodeURIComponent(advisoryId)}?${params.toString()}`;
|
||||
});
|
||||
|
||||
// Tab methods
|
||||
setActiveTab(tab: TabId): void {
|
||||
this.activeTab.set(tab);
|
||||
@@ -106,6 +250,94 @@ export class EvidencePanelComponent {
|
||||
return this.expandedObservation() === observationId;
|
||||
}
|
||||
|
||||
// Filter methods
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update((v) => !v);
|
||||
}
|
||||
|
||||
updateSourceFilter(sources: readonly string[]): void {
|
||||
this.filters.update((f) => ({ ...f, sources }));
|
||||
this.currentPage.set(0); // Reset to first page on filter change
|
||||
}
|
||||
|
||||
toggleSourceFilter(sourceId: string): void {
|
||||
this.filters.update((f) => {
|
||||
const sources = f.sources.includes(sourceId)
|
||||
? f.sources.filter((s) => s !== sourceId)
|
||||
: [...f.sources, sourceId];
|
||||
return { ...f, sources };
|
||||
});
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
updateSeverityBucket(bucket: SeverityBucket): void {
|
||||
this.filters.update((f) => ({ ...f, severityBucket: bucket }));
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
toggleConflictOnly(): void {
|
||||
this.filters.update((f) => ({ ...f, conflictOnly: !f.conflictOnly }));
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
updateCvssVectorFilter(value: boolean | null): void {
|
||||
this.filters.update((f) => ({ ...f, hasCvssVector: value }));
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.filters.set(DEFAULT_OBSERVATION_FILTERS);
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
isSourceSelected(sourceId: string): boolean {
|
||||
return this.filters().sources.includes(sourceId);
|
||||
}
|
||||
|
||||
isSeverityBucketSelected(bucket: SeverityBucket): boolean {
|
||||
return this.filters().severityBucket === bucket;
|
||||
}
|
||||
|
||||
// Severity bucket matching helper
|
||||
matchesSeverityBucket(score: number, bucket: SeverityBucket): boolean {
|
||||
switch (bucket) {
|
||||
case 'critical':
|
||||
return score >= 9.0;
|
||||
case 'high':
|
||||
return score >= 7.0 && score < 9.0;
|
||||
case 'medium':
|
||||
return score >= 4.0 && score < 7.0;
|
||||
case 'low':
|
||||
return score < 4.0;
|
||||
case 'all':
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination methods
|
||||
goToPage(page: number): void {
|
||||
const maxPage = Math.max(0, this.totalPages() - 1);
|
||||
this.currentPage.set(Math.max(0, Math.min(page, maxPage)));
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.hasNextPage()) {
|
||||
this.currentPage.update((p) => p + 1);
|
||||
}
|
||||
}
|
||||
|
||||
previousPage(): void {
|
||||
if (this.hasPreviousPage()) {
|
||||
this.currentPage.update((p) => p - 1);
|
||||
}
|
||||
}
|
||||
|
||||
updatePageSize(size: number): void {
|
||||
this.pageSize.set(size);
|
||||
this.currentPage.set(0);
|
||||
}
|
||||
|
||||
// AOC chain methods
|
||||
toggleAocEntry(attestationId: string): void {
|
||||
const current = this.expandedAocEntry();
|
||||
@@ -205,6 +437,105 @@ export class EvidencePanelComponent {
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
// VEX helpers
|
||||
getVexStatusLabel(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'Not Affected';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'Affected (Mitigated)';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'Affected (Unmitigated)';
|
||||
case 'FIXED':
|
||||
return 'Fixed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
getVexStatusClass(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'vex-status--not-affected';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'vex-status--mitigated';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'vex-status--unmitigated';
|
||||
case 'FIXED':
|
||||
return 'vex-status--fixed';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getVexJustificationLabel(type: VexJustificationType): string {
|
||||
const labels: Record<VexJustificationType, string> = {
|
||||
CODE_NOT_PRESENT: 'Code Not Present',
|
||||
CODE_NOT_REACHABLE: 'Code Not Reachable',
|
||||
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH: 'Vulnerable Code Not In Execute Path',
|
||||
CONFIGURATION_NOT_AFFECTED: 'Configuration Not Affected',
|
||||
OS_NOT_AFFECTED: 'OS Not Affected',
|
||||
RUNTIME_MITIGATION_PRESENT: 'Runtime Mitigation Present',
|
||||
COMPENSATING_CONTROLS: 'Compensating Controls',
|
||||
ACCEPTED_BUSINESS_RISK: 'Accepted Business Risk',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
isVexDecisionExpired(decision: VexDecision): boolean {
|
||||
if (!decision.validFor?.notAfter) return false;
|
||||
return new Date(decision.validFor.notAfter) < new Date();
|
||||
}
|
||||
|
||||
isVexDecisionPending(decision: VexDecision): boolean {
|
||||
if (!decision.validFor?.notBefore) return false;
|
||||
return new Date(decision.validFor.notBefore) > new Date();
|
||||
}
|
||||
|
||||
// VEX export handlers
|
||||
readonly exportVex = output<{ format: 'json' | 'csaf' | 'openvex' }>();
|
||||
|
||||
onExportVex(format: 'json' | 'csaf' | 'openvex'): void {
|
||||
this.exportVex.emit({ format });
|
||||
}
|
||||
|
||||
// Permalink methods
|
||||
togglePermalink(): void {
|
||||
this.showPermalink.update((v) => !v);
|
||||
this.permalinkCopied.set(false);
|
||||
}
|
||||
|
||||
async copyPermalink(): Promise<void> {
|
||||
const link = this.permalink();
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
this.permalinkCopied.set(true);
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => this.permalinkCopied.set(false), 2000);
|
||||
} catch (err) {
|
||||
// Fallback for browsers without clipboard API
|
||||
this.fallbackCopyToClipboard(link);
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackCopyToClipboard(text: string): void {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
this.permalinkCopied.set(true);
|
||||
setTimeout(() => this.permalinkCopied.set(false), 2000);
|
||||
} catch {
|
||||
console.error('Fallback: Unable to copy');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// Download handlers
|
||||
onDownloadObservation(observationId: string): void {
|
||||
this.downloadDocument.emit({ type: 'observation', id: observationId });
|
||||
@@ -252,4 +583,12 @@ export class EvidencePanelComponent {
|
||||
trackByRuleId(_: number, rule: PolicyRuleResult): string {
|
||||
return rule.ruleId;
|
||||
}
|
||||
|
||||
trackByVexDecisionId(_: number, decision: VexDecision): string {
|
||||
return decision.id;
|
||||
}
|
||||
|
||||
trackByVexConflictId(_: number, conflict: VexConflict): string {
|
||||
return conflict.vulnerabilityId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../core/auth';
|
||||
|
||||
/**
|
||||
* Orchestrator Dashboard - Main landing page for Orchestrator features.
|
||||
* Requires orch:read scope for access (gated by requireOrchViewerGuard).
|
||||
*
|
||||
* @see UI-ORCH-32-001
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-orchestrator-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="orch-dashboard">
|
||||
<header class="orch-dashboard__header">
|
||||
<h1 class="orch-dashboard__title">Orchestrator Dashboard</h1>
|
||||
<p class="orch-dashboard__description">
|
||||
Monitor and manage orchestrated jobs, quotas, and backfill operations.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="orch-dashboard__nav">
|
||||
<a routerLink="/orchestrator/jobs" class="orch-dashboard__card">
|
||||
<span class="orch-dashboard__card-icon">📋</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">
|
||||
<span class="orch-dashboard__card-icon">⚙</span>
|
||||
<span class="orch-dashboard__card-title">Quotas</span>
|
||||
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<section class="orch-dashboard__scope-info">
|
||||
<h2>Your Orchestrator Access</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>View Jobs:</strong>
|
||||
{{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Operate:</strong>
|
||||
{{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Manage Quotas:</strong>
|
||||
{{ authService.canManageOrchestratorQuotas() ? 'Granted' : 'Denied' }}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Initiate Backfill:</strong>
|
||||
{{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.orch-dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.orch-dashboard__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.orch-dashboard__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.orch-dashboard__description {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.orch-dashboard__nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.orch-dashboard__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-dashboard__card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.orch-dashboard__card-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.orch-dashboard__card-desc {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.orch-dashboard__scope-info {
|
||||
padding: 1.5rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class OrchestratorDashboardComponent {
|
||||
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Orchestrator Job Detail - Shows details for a specific job.
|
||||
* Requires orch:read scope for access.
|
||||
*
|
||||
* @see UI-ORCH-32-001
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-orchestrator-job-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
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>
|
||||
<h1 class="orch-job-detail__title">Job Detail</h1>
|
||||
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
|
||||
</header>
|
||||
|
||||
<div class="orch-job-detail__placeholder">
|
||||
<p>Job details will be implemented when Orchestrator API contract is finalized.</p>
|
||||
<p class="orch-job-detail__hint">This page requires the <code>orch:read</code> scope.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.orch-job-detail {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.orch-job-detail__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.orch-job-detail__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-job-detail__title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.orch-job-detail__id {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.orch-job-detail__placeholder {
|
||||
padding: 3rem;
|
||||
background: #f9fafb;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-job-detail__hint {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class OrchestratorJobDetailComponent {
|
||||
@Input() jobId: string = '';
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Orchestrator Jobs List - Shows all orchestrator jobs.
|
||||
* Requires orch:read scope for access.
|
||||
*
|
||||
* @see UI-ORCH-32-001
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-orchestrator-jobs',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="orch-jobs">
|
||||
<header class="orch-jobs__header">
|
||||
<a routerLink="/orchestrator" class="orch-jobs__back">← Back to Dashboard</a>
|
||||
<h1 class="orch-jobs__title">Orchestrator Jobs</h1>
|
||||
</header>
|
||||
|
||||
<div class="orch-jobs__placeholder">
|
||||
<p>Job list will be implemented when Orchestrator API contract is finalized.</p>
|
||||
<p class="orch-jobs__hint">This page requires the <code>orch:read</code> scope.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.orch-jobs {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.orch-jobs__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.orch-jobs__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-jobs__title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.orch-jobs__placeholder {
|
||||
padding: 3rem;
|
||||
background: #f9fafb;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-jobs__hint {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class OrchestratorJobsComponent {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Orchestrator Quotas Management - Manage resource quotas.
|
||||
* Requires orch:read + orch:operate scopes for access.
|
||||
*
|
||||
* @see UI-ORCH-32-001
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-orchestrator-quotas',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="orch-quotas">
|
||||
<header class="orch-quotas__header">
|
||||
<a routerLink="/orchestrator" class="orch-quotas__back">← Back to Dashboard</a>
|
||||
<h1 class="orch-quotas__title">Orchestrator Quotas</h1>
|
||||
</header>
|
||||
|
||||
<div class="orch-quotas__placeholder">
|
||||
<p>Quota management will be implemented when Orchestrator API contract is finalized.</p>
|
||||
<p class="orch-quotas__hint">
|
||||
This page requires the <code>orch:read</code> and <code>orch:operate</code> scopes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.orch-quotas {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.orch-quotas__header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.orch-quotas__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-quotas__title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.orch-quotas__placeholder {
|
||||
padding: 3rem;
|
||||
background: #f9fafb;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e5e7eb;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.orch-quotas__hint {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class OrchestratorQuotasComponent {}
|
||||
86
src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json
Normal file
86
src/Web/StellaOps.Web/src/i18n/micro-interactions.en.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://stella-ops.org/i18n/micro-interactions.schema.json",
|
||||
"_meta": {
|
||||
"version": "1.0",
|
||||
"locale": "en-US",
|
||||
"description": "Micro-interaction copy for StellaOps Console (MI9)"
|
||||
},
|
||||
"loading": {
|
||||
"skeleton": "Loading...",
|
||||
"spinner": "Please wait...",
|
||||
"progress": "Loading {percent}%",
|
||||
"slow": "This is taking longer than expected...",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"error": {
|
||||
"generic": "Something went wrong",
|
||||
"network": "Network error. Check your connection.",
|
||||
"timeout": "Request timed out. Please try again.",
|
||||
"notFound": "The requested resource was not found.",
|
||||
"unauthorized": "You don't have permission to view this.",
|
||||
"serverError": "Server error. Please try again later.",
|
||||
"tryAgain": "Try again",
|
||||
"goBack": "Go back",
|
||||
"contactSupport": "Contact support"
|
||||
},
|
||||
"offline": {
|
||||
"banner": "You're offline",
|
||||
"description": "Some features may be unavailable.",
|
||||
"lastSync": "Last synced {time}",
|
||||
"reconnecting": "Reconnecting...",
|
||||
"reconnected": "Back online"
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"dismiss": "Dismiss",
|
||||
"undo": "Undo",
|
||||
"undoCountdown": "Undo ({seconds}s)"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"deleted": "Deleted",
|
||||
"submit": "Submit",
|
||||
"submitting": "Submitting...",
|
||||
"submitted": "Submitted",
|
||||
"close": "Close",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
"invalid": "Invalid value",
|
||||
"tooLong": "Maximum {max} characters allowed",
|
||||
"tooShort": "Minimum {min} characters required",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"invalidUrl": "Please enter a valid URL"
|
||||
},
|
||||
"accessibility": {
|
||||
"loading": "Content is loading",
|
||||
"loaded": "Content loaded",
|
||||
"error": "An error occurred",
|
||||
"expanded": "Expanded",
|
||||
"collapsed": "Collapsed",
|
||||
"selected": "Selected",
|
||||
"deselected": "Deselected",
|
||||
"required": "Required field",
|
||||
"optional": "Optional",
|
||||
"menu": "Menu",
|
||||
"dialog": "Dialog",
|
||||
"alert": "Alert"
|
||||
},
|
||||
"motion": {
|
||||
"reducedMotion": "Animations reduced",
|
||||
"motionEnabled": "Animations enabled"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user