stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -3,9 +3,7 @@ import type { StorybookConfig } from '@storybook/angular';
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/stories/**/*.@(mdx|stories.@(ts))'],
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/angular',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["storybook__angular", "node"]
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"../src/**/*.ts",
|
||||
"../.storybook/**/*.ts",
|
||||
"../src/**/*.stories.ts"
|
||||
"../src/stories/**/*.ts",
|
||||
"../.storybook/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,11 +7,22 @@ Design and build the StellaOps web user experience that surfaces backend capabil
|
||||
- **UX Specialist** ??? defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
|
||||
- **Angular Engineers** ??? implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
|
||||
|
||||
## Technology Stack
|
||||
- **Framework**: Angular 21 (standalone components, signals, built-in control flow)
|
||||
- **Language**: TypeScript 5.9
|
||||
- **UI Library**: Angular Material 21 + Angular CDK 21
|
||||
- **State**: Angular Signals
|
||||
- **Build**: `@angular/build:application` (esbuild-based)
|
||||
- **Unit Tests**: Vitest via `@angular/build:unit-test` builder (Jasmine compatibility shim in `src/test-setup.ts`)
|
||||
- **E2E Tests**: Playwright
|
||||
- **Storybook**: Storybook 10 with `@storybook/angular`
|
||||
- **Node.js**: ^20.19.0 || ^22.12.0 || ^24.0.0
|
||||
|
||||
## Operating Principles
|
||||
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
|
||||
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
|
||||
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
|
||||
- Coordinate cross-module changes via docs/implplan/SPRINT*.md files updates and PR descriptions.
|
||||
- Coordinate cross-module changes via docs/implplan/SPRINT*.md files updates and PR descriptions.
|
||||
- Console admin flows use Authority `/console/admin/*` APIs and enforce fresh-auth for privileged actions.
|
||||
- Branding uses Authority `/console/branding` and applies only whitelisted CSS variables.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/stellaops-web",
|
||||
"index": "src/index.html",
|
||||
@@ -70,7 +70,12 @@
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"port": 10000,
|
||||
"ssl": true,
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "stellaops-web:build:production"
|
||||
@@ -82,20 +87,18 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "stellaops-web:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"builder": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"buildTarget": "stellaops-web:build:development",
|
||||
"runner": "vitest",
|
||||
"setupFiles": ["src/test-setup.ts"],
|
||||
"exclude": [
|
||||
"**/*.e2e.spec.ts",
|
||||
"src/app/core/api/vex-hub.client.spec.ts",
|
||||
@@ -103,32 +106,7 @@
|
||||
"src/app/features/**/*.spec.ts",
|
||||
"src/app/shared/components/**/*.spec.ts",
|
||||
"src/app/layout/**/*.spec.ts"
|
||||
],
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src/styles"
|
||||
]
|
||||
},
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/features/policy-studio/editor/monaco-loader.service.ts",
|
||||
"with": "src/app/features/policy-studio/editor/monaco-loader.service.stub.ts"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
@@ -154,5 +132,31 @@
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
404
src/Web/StellaOps.Web/e2e/workflow-visualizer.visual.spec.ts
Normal file
404
src/Web/StellaOps.Web/e2e/workflow-visualizer.visual.spec.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// workflow-visualizer.visual.spec.ts
|
||||
// Sprint: SPRINT_20260117_032_ReleaseOrchestrator_workflow_visualization
|
||||
// Task: TASK-032-12 - Visual Regression Tests for DAG Visualization
|
||||
// Description: Playwright visual regression tests for workflow visualization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Workflow DAG Visualization', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to test workflow page
|
||||
await page.goto('/workflows/test-run-001');
|
||||
await page.waitForSelector('.workflow-visualizer');
|
||||
});
|
||||
|
||||
test.describe('Node Rendering', () => {
|
||||
test('renders nodes at various complexities', async ({ page }) => {
|
||||
// Test with 10 nodes
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
await page.waitForSelector('.node', { timeout: 5000 });
|
||||
await expect(page.locator('.node')).toHaveCount(10);
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-10-nodes.png');
|
||||
|
||||
// Test with 50 nodes
|
||||
await page.goto('/workflows/test-50-nodes');
|
||||
await page.waitForSelector('.node', { timeout: 10000 });
|
||||
await expect(page.locator('.node')).toHaveCount(50);
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-50-nodes.png');
|
||||
});
|
||||
|
||||
test('renders large workflow (100+ nodes) with acceptable performance', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/workflows/test-100-nodes');
|
||||
await page.waitForSelector('.node', { timeout: 15000 });
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
expect(loadTime).toBeLessThan(10000); // Should load within 10 seconds
|
||||
|
||||
await expect(page.locator('.node')).toHaveCount(100);
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-100-nodes.png', {
|
||||
maxDiffPixelRatio: 0.05 // Allow 5% variance for large graphs
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Node Status States', () => {
|
||||
test('displays pending node correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-pending');
|
||||
const pendingNode = page.locator('.node-pending').first();
|
||||
await expect(pendingNode).toBeVisible();
|
||||
await expect(pendingNode).toHaveScreenshot('node-pending.png');
|
||||
});
|
||||
|
||||
test('displays running node with animation', async ({ page }) => {
|
||||
await page.goto('/workflows/test-running');
|
||||
const runningNode = page.locator('.node-running').first();
|
||||
await expect(runningNode).toBeVisible();
|
||||
|
||||
// Check for pulse animation
|
||||
const statusIndicator = runningNode.locator('.pulse');
|
||||
await expect(statusIndicator).toBeVisible();
|
||||
|
||||
// Capture animation frame
|
||||
await expect(runningNode).toHaveScreenshot('node-running.png');
|
||||
});
|
||||
|
||||
test('displays succeeded node correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-succeeded');
|
||||
const succeededNode = page.locator('.node-succeeded').first();
|
||||
await expect(succeededNode).toBeVisible();
|
||||
await expect(succeededNode).toHaveScreenshot('node-succeeded.png');
|
||||
});
|
||||
|
||||
test('displays failed node correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-failed');
|
||||
const failedNode = page.locator('.node-failed').first();
|
||||
await expect(failedNode).toBeVisible();
|
||||
await expect(failedNode).toHaveScreenshot('node-failed.png');
|
||||
});
|
||||
|
||||
test('displays skipped node correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-skipped');
|
||||
const skippedNode = page.locator('.node-skipped').first();
|
||||
await expect(skippedNode).toBeVisible();
|
||||
await expect(skippedNode).toHaveScreenshot('node-skipped.png');
|
||||
});
|
||||
|
||||
test('displays cancelled node correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-cancelled');
|
||||
const cancelledNode = page.locator('.node-cancelled').first();
|
||||
await expect(cancelledNode).toBeVisible();
|
||||
await expect(cancelledNode).toHaveScreenshot('node-cancelled.png');
|
||||
});
|
||||
|
||||
test('node state transition animation', async ({ page }) => {
|
||||
await page.goto('/workflows/test-transition');
|
||||
|
||||
// Initial state - pending
|
||||
const node = page.locator('[data-step-id="step-1"]');
|
||||
await expect(node).toHaveClass(/node-pending/);
|
||||
|
||||
// Trigger transition
|
||||
await page.click('[data-action="start-workflow"]');
|
||||
|
||||
// Wait for running state
|
||||
await expect(node).toHaveClass(/node-running/, { timeout: 5000 });
|
||||
await expect(node).toHaveScreenshot('node-transition-running.png');
|
||||
|
||||
// Wait for completed state
|
||||
await expect(node).toHaveClass(/node-succeeded/, { timeout: 10000 });
|
||||
await expect(node).toHaveScreenshot('node-transition-succeeded.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Edge Rendering', () => {
|
||||
test('renders static edges correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-static');
|
||||
const edges = page.locator('.edge-path');
|
||||
await expect(edges.first()).toBeVisible();
|
||||
await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-static.png');
|
||||
});
|
||||
|
||||
test('renders animated edges for in-progress steps', async ({ page }) => {
|
||||
await page.goto('/workflows/test-running');
|
||||
const animatedEdge = page.locator('.edge.animated');
|
||||
await expect(animatedEdge).toBeVisible();
|
||||
|
||||
// Verify animation is present (dash animation)
|
||||
const edgePath = animatedEdge.locator('.edge-path');
|
||||
const strokeDasharray = await edgePath.evaluate(el =>
|
||||
window.getComputedStyle(el).getPropertyValue('stroke-dasharray')
|
||||
);
|
||||
expect(strokeDasharray).not.toBe('none');
|
||||
});
|
||||
|
||||
test('highlights critical path edges', async ({ page }) => {
|
||||
await page.goto('/workflows/test-completed');
|
||||
|
||||
// Enable critical path
|
||||
await page.click('button:has-text("Critical Path")');
|
||||
|
||||
const criticalEdge = page.locator('.edge.critical');
|
||||
await expect(criticalEdge.first()).toBeVisible();
|
||||
await expect(page.locator('.edges-layer')).toHaveScreenshot('edges-critical-path.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Layout Algorithms', () => {
|
||||
test('dagre layout renders correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-layout');
|
||||
await page.selectOption('.layout-selector select', 'dagre');
|
||||
await page.waitForTimeout(500); // Wait for layout animation
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-dagre.png');
|
||||
});
|
||||
|
||||
test('elk layout renders correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-layout');
|
||||
await page.selectOption('.layout-selector select', 'elk');
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-elk.png');
|
||||
});
|
||||
|
||||
test('force-directed layout renders correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-layout');
|
||||
await page.selectOption('.layout-selector select', 'force');
|
||||
await page.waitForTimeout(1000); // Force layout needs more time
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('layout-force.png', {
|
||||
maxDiffPixelRatio: 0.1 // Force layout may have slight variations
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Zoom and Pan', () => {
|
||||
test('zoom controls work correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
|
||||
// Zoom in
|
||||
await page.click('button[title="Zoom In"]');
|
||||
await page.click('button[title="Zoom In"]');
|
||||
await expect(page.locator('.zoom-label')).toContainText('150%');
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-150.png');
|
||||
|
||||
// Zoom out
|
||||
await page.click('button[title="Zoom Out"]');
|
||||
await page.click('button[title="Zoom Out"]');
|
||||
await page.click('button[title="Zoom Out"]');
|
||||
await expect(page.locator('.zoom-label')).toContainText('75%');
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-75.png');
|
||||
});
|
||||
|
||||
test('fit to view resets viewport', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
|
||||
// Pan and zoom
|
||||
await page.click('button[title="Zoom In"]');
|
||||
await page.click('button[title="Zoom In"]');
|
||||
|
||||
// Fit to view
|
||||
await page.click('button[title="Fit to View"]');
|
||||
await expect(page.locator('.zoom-label')).toContainText('100%');
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('zoom-fit.png');
|
||||
});
|
||||
|
||||
test('mouse wheel zooms', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
const canvas = page.locator('.canvas-container');
|
||||
|
||||
// Scroll to zoom
|
||||
await canvas.hover();
|
||||
await page.mouse.wheel(0, -100); // Zoom in
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const zoomLabel = await page.locator('.zoom-label').textContent();
|
||||
expect(parseInt(zoomLabel || '100')).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('drag to pan', async ({ page }) => {
|
||||
await page.goto('/workflows/test-50-nodes');
|
||||
const canvas = page.locator('.canvas-container');
|
||||
|
||||
// Get initial viewbox
|
||||
const initialViewBox = await page.locator('.dag-canvas').getAttribute('viewBox');
|
||||
|
||||
// Drag to pan
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 100, box.y + box.height / 2 + 50);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// Viewbox should have changed
|
||||
const newViewBox = await page.locator('.dag-canvas').getAttribute('viewBox');
|
||||
expect(newViewBox).not.toBe(initialViewBox);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Node Selection', () => {
|
||||
test('clicking node selects it', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
const node = page.locator('.node').first();
|
||||
|
||||
await node.click();
|
||||
await expect(node).toHaveClass(/selected/);
|
||||
await expect(node).toHaveScreenshot('node-selected.png');
|
||||
});
|
||||
|
||||
test('double-clicking node opens details', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
const node = page.locator('.node').first();
|
||||
|
||||
await node.dblclick();
|
||||
await expect(page.locator('.step-detail-panel')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Minimap', () => {
|
||||
test('minimap renders for large workflows', async ({ page }) => {
|
||||
await page.goto('/workflows/test-50-nodes');
|
||||
await expect(page.locator('.minimap')).toBeVisible();
|
||||
await expect(page.locator('.minimap')).toHaveScreenshot('minimap.png');
|
||||
});
|
||||
|
||||
test('minimap shows viewport indicator', async ({ page }) => {
|
||||
await page.goto('/workflows/test-50-nodes');
|
||||
await expect(page.locator('.viewport-indicator')).toBeVisible();
|
||||
});
|
||||
|
||||
test('minimap hidden for small workflows', async ({ page }) => {
|
||||
await page.goto('/workflows/test-5-nodes');
|
||||
await expect(page.locator('.minimap')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('mobile viewport adjusts layout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
|
||||
// Toolbar should wrap
|
||||
await expect(page.locator('.visualizer-toolbar')).toHaveScreenshot('toolbar-mobile.png');
|
||||
|
||||
// Minimap should be hidden
|
||||
await expect(page.locator('.minimap')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('tablet viewport renders correctly', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-tablet.png');
|
||||
});
|
||||
|
||||
test('desktop viewport renders correctly', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
await expect(page.locator('.workflow-visualizer')).toHaveScreenshot('visualizer-desktop.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dark Mode', () => {
|
||||
test('dark mode renders correctly', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes?theme=dark');
|
||||
await expect(page.locator('.workflow-visualizer')).toHaveClass(/dark-mode/);
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('dag-dark-mode.png');
|
||||
});
|
||||
|
||||
test('node states in dark mode', async ({ page }) => {
|
||||
await page.goto('/workflows/test-all-states?theme=dark');
|
||||
await expect(page.locator('.dag-canvas')).toHaveScreenshot('nodes-dark-mode.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Legend', () => {
|
||||
test('legend displays all states', async ({ page }) => {
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
const legend = page.locator('.legend');
|
||||
await expect(legend).toBeVisible();
|
||||
await expect(legend.locator('.legend-item')).toHaveCount(5);
|
||||
await expect(legend).toHaveScreenshot('legend.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Loading and Error States', () => {
|
||||
test('loading overlay displays correctly', async ({ page }) => {
|
||||
// Intercept API to delay response
|
||||
await page.route('**/api/v1/workflows/*/graph', async route => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
await expect(page.locator('.loading-overlay')).toBeVisible();
|
||||
await expect(page.locator('.loading-overlay')).toHaveScreenshot('loading-state.png');
|
||||
});
|
||||
|
||||
test('error overlay displays correctly', async ({ page }) => {
|
||||
// Mock API error
|
||||
await page.route('**/api/v1/workflows/*/graph', async route => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: 'Internal Server Error' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/workflows/test-10-nodes');
|
||||
await expect(page.locator('.error-overlay')).toBeVisible();
|
||||
await expect(page.locator('.error-overlay')).toHaveScreenshot('error-state.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Time-Travel Controls', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workflows/test-completed/debug');
|
||||
await page.waitForSelector('.time-travel-controls');
|
||||
});
|
||||
|
||||
test('controls render correctly', async ({ page }) => {
|
||||
await expect(page.locator('.time-travel-controls')).toHaveScreenshot('time-travel-controls.png');
|
||||
});
|
||||
|
||||
test('timeline with markers', async ({ page }) => {
|
||||
await expect(page.locator('.timeline-container')).toHaveScreenshot('timeline-markers.png');
|
||||
});
|
||||
|
||||
test('playhead position updates', async ({ page }) => {
|
||||
// Step forward
|
||||
await page.click('button[title*="Step Forward"]');
|
||||
await page.click('button[title*="Step Forward"]');
|
||||
|
||||
await expect(page.locator('.timeline')).toHaveScreenshot('timeline-stepped.png');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Step Detail Panel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workflows/test-completed');
|
||||
await page.locator('.node').first().click();
|
||||
await page.waitForSelector('.step-detail-panel');
|
||||
});
|
||||
|
||||
test('panel renders correctly', async ({ page }) => {
|
||||
await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel.png');
|
||||
});
|
||||
|
||||
test('logs tab renders correctly', async ({ page }) => {
|
||||
await page.click('.tab:has-text("Logs")');
|
||||
await expect(page.locator('.logs-tab')).toHaveScreenshot('logs-tab.png');
|
||||
});
|
||||
|
||||
test('timing tab renders correctly', async ({ page }) => {
|
||||
await page.click('.tab:has-text("Timing")');
|
||||
await expect(page.locator('.timing-tab')).toHaveScreenshot('timing-tab.png');
|
||||
});
|
||||
|
||||
test('error state panel', async ({ page }) => {
|
||||
await page.goto('/workflows/test-failed');
|
||||
await page.locator('.node-failed').first().click();
|
||||
await expect(page.locator('.step-detail-panel')).toHaveScreenshot('step-panel-error.png');
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
const { join } = require('path');
|
||||
const { resolveChromeBinary } = require('./scripts/chrome-path');
|
||||
|
||||
const { env } = process;
|
||||
|
||||
const chromeBin = resolveChromeBinary(__dirname);
|
||||
|
||||
if (chromeBin) {
|
||||
env.CHROME_BIN = chromeBin;
|
||||
} else if (!env.CHROME_BIN) {
|
||||
console.warn(
|
||||
'[karma] Unable to locate a Chromium binary automatically. ' +
|
||||
'Set CHROME_BIN or STELLAOPS_CHROMIUM_BIN, or place an offline build under .cache/chromium/. ' +
|
||||
'See docs/DeterministicInstall.md for bootstrap instructions.'
|
||||
);
|
||||
}
|
||||
|
||||
const isCI = env.CI === 'true' || env.CI === '1';
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: join(__dirname, './coverage/stellaops-web'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
browsers: ['ChromeHeadlessOffline'],
|
||||
customLaunchers: {
|
||||
ChromeHeadlessOffline: {
|
||||
base: 'ChromeHeadless',
|
||||
flags: [
|
||||
'--no-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-setuid-sandbox'
|
||||
]
|
||||
}
|
||||
},
|
||||
restartOnFileChange: false
|
||||
});
|
||||
};
|
||||
19815
src/Web/StellaOps.Web/package-lock.json
generated
19815
src/Web/StellaOps.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,17 @@
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"start": "node scripts/serve.js",
|
||||
"build": "ng build",
|
||||
"build:stats": "ng build --stats-json",
|
||||
"analyze": "ng build --stats-json && npx esbuild-visualizer --metadata dist/stellaops-web/browser/stats.json --open",
|
||||
"analyze:source-map": "ng build --source-map && npx source-map-explorer dist/stellaops-web/browser/*.js",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "npm run verify:chromium && ng test --watch=false",
|
||||
"test:watch": "ng test --watch",
|
||||
"test": "ng test --watch=false",
|
||||
"test:watch": "ng test",
|
||||
"test:ci": "npm run test",
|
||||
"test:e2e": "playwright test",
|
||||
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
|
||||
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1 --ssl",
|
||||
"verify:chromium": "node ./scripts/verify-chromium.js",
|
||||
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
|
||||
"storybook": "ng run stellaops-web:storybook",
|
||||
@@ -21,21 +21,21 @@
|
||||
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0",
|
||||
"node": "^20.19.0 || ^22.12.0 || ^24.0.0",
|
||||
"npm": ">=10.2.0"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/cdk": "^17.3.10",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/material": "^17.3.10",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"@angular/animations": "^21.1.2",
|
||||
"@angular/cdk": "^21.1.2",
|
||||
"@angular/common": "^21.1.2",
|
||||
"@angular/compiler": "^21.1.2",
|
||||
"@angular/core": "^21.1.2",
|
||||
"@angular/forms": "^21.1.2",
|
||||
"@angular/material": "^21.1.2",
|
||||
"@angular/platform-browser": "^21.1.2",
|
||||
"@angular/platform-browser-dynamic": "^21.1.2",
|
||||
"@angular/router": "^21.1.2",
|
||||
"@viz-js/viz": "^3.24.0",
|
||||
"d3": "^7.9.0",
|
||||
"mermaid": "^11.12.2",
|
||||
@@ -43,29 +43,26 @@
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zone.js": "~0.14.3"
|
||||
"zone.js": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.17",
|
||||
"@angular/cli": "^17.3.17",
|
||||
"@angular/compiler-cli": "^17.3.0",
|
||||
"@angular-devkit/build-angular": "^21.1.2",
|
||||
"@angular/cli": "^21.1.2",
|
||||
"@angular/compiler-cli": "^21.1.2",
|
||||
"@axe-core/playwright": "4.8.4",
|
||||
"@chromatic-com/storybook": "^1.9.0",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@storybook/addon-a11y": "8.1.0",
|
||||
"@storybook/addon-essentials": "8.1.0",
|
||||
"@storybook/addon-interactions": "8.1.0",
|
||||
"@storybook/angular": "8.1.0",
|
||||
"@storybook/test": "^8.1.0",
|
||||
"@storybook/addon-a11y": "^10.2.4",
|
||||
"@storybook/angular": "^10.2.4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"storybook": "^8.1.0",
|
||||
"typescript": "~5.4.2"
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"jsdom": "^28.0.0",
|
||||
"storybook": "^10.2.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"overrides": {
|
||||
"lodash-es": ">=4.17.21",
|
||||
"tar": ">=6.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Web/StellaOps.Web/proxy.conf.json
Normal file
42
src/Web/StellaOps.Web/proxy.conf.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"/envsettings.json": {
|
||||
"target": "https://localhost:10010",
|
||||
"secure": false
|
||||
},
|
||||
"/platform": {
|
||||
"target": "https://localhost:10010",
|
||||
"secure": false
|
||||
},
|
||||
"/authority": {
|
||||
"target": "https://localhost:10020",
|
||||
"secure": false
|
||||
},
|
||||
"/console": {
|
||||
"target": "https://localhost:10020",
|
||||
"secure": false
|
||||
},
|
||||
"/scanner": {
|
||||
"target": "https://localhost:10080",
|
||||
"secure": false
|
||||
},
|
||||
"/policy": {
|
||||
"target": "https://localhost:10140",
|
||||
"secure": false
|
||||
},
|
||||
"/concelier": {
|
||||
"target": "https://localhost:10090",
|
||||
"secure": false
|
||||
},
|
||||
"/attestor": {
|
||||
"target": "https://localhost:10040",
|
||||
"secure": false
|
||||
},
|
||||
"/gateway": {
|
||||
"target": "https://localhost:10030",
|
||||
"secure": false
|
||||
},
|
||||
"/healthz": {
|
||||
"target": "https://localhost:10010",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
146
src/Web/StellaOps.Web/scripts/serve.js
Normal file
146
src/Web/StellaOps.Web/scripts/serve.js
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Wrapper around `ng serve` that resolves the best binding:
|
||||
// 1. https://stella-ops.local (port 443) — if hostname resolves and port is free
|
||||
// 2. https://localhost:10000 — always available fallback
|
||||
//
|
||||
// Additionally binds http://stella-ops.local (port 80) as a redirect to HTTPS
|
||||
// when the hostname resolves and port 80 is available.
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const dns = require('dns');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
|
||||
const HOSTNAME = 'stella-ops.local';
|
||||
const HTTPS_PORT = 443;
|
||||
const HTTP_PORT = 80;
|
||||
const DEV_PORT = 10000;
|
||||
const SETUP_DOC = 'docs/technical/architecture/port-registry.md';
|
||||
|
||||
function isWindows() {
|
||||
return process.platform === 'win32';
|
||||
}
|
||||
|
||||
function hostsFilePath() {
|
||||
return isWindows()
|
||||
? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
|
||||
: '/etc/hosts';
|
||||
}
|
||||
|
||||
function resolveHostnameIp(hostname) {
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup(hostname, { family: 4 }, (err, address) => {
|
||||
if (err) return resolve(null);
|
||||
resolve(address);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port, ip) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, ip);
|
||||
});
|
||||
}
|
||||
|
||||
function startHttpRedirect(httpsHost, httpsPort, bindIp) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const location = `https://${httpsHost}${httpsPort === 443 ? '' : ':' + httpsPort}${req.url}`;
|
||||
res.writeHead(301, { Location: location });
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.warn(` HTTP redirect on port ${HTTP_PORT} failed: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(HTTP_PORT, bindIp, () => {
|
||||
console.log(` HTTP redirect active: http://${HOSTNAME} -> https://${httpsHost}${httpsPort === 443 ? '' : ':' + httpsPort}`);
|
||||
console.log(` Bound to ${bindIp}:${HTTP_PORT}`);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
const resolvedIp = await resolveHostnameIp(HOSTNAME);
|
||||
const hostnameOk = !!resolvedIp;
|
||||
const port443Free = hostnameOk ? await isPortAvailable(HTTPS_PORT, resolvedIp) : false;
|
||||
const port80Free = hostnameOk ? await isPortAvailable(HTTP_PORT, resolvedIp) : false;
|
||||
|
||||
let host, port;
|
||||
|
||||
if (hostnameOk && port443Free) {
|
||||
host = HOSTNAME;
|
||||
port = HTTPS_PORT;
|
||||
console.log('');
|
||||
console.log(` ${HOSTNAME} resolves to ${resolvedIp}; port ${HTTPS_PORT} is available.`);
|
||||
console.log(` Dev server binding to https://${HOSTNAME}`);
|
||||
console.log(` Also accessible at https://localhost:${DEV_PORT}`);
|
||||
|
||||
if (port80Free) {
|
||||
const redirectOk = await startHttpRedirect(HOSTNAME, HTTPS_PORT, resolvedIp);
|
||||
if (redirectOk) {
|
||||
console.log(` Also accessible at http://${HOSTNAME} (redirects to HTTPS)`);
|
||||
} else {
|
||||
console.warn(` Failed to start HTTP redirect on ${resolvedIp}:${HTTP_PORT}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(` Port ${HTTP_PORT} on ${resolvedIp} is unavailable; skipping http://${HOSTNAME} redirect.`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
} else if (hostnameOk) {
|
||||
host = HOSTNAME;
|
||||
port = DEV_PORT;
|
||||
console.warn('');
|
||||
console.warn(` ${HOSTNAME} resolves to ${resolvedIp} but port ${HTTPS_PORT} is unavailable`);
|
||||
console.warn(` (requires elevated privileges or is already in use).`);
|
||||
console.warn(` Dev server binding to https://${HOSTNAME}:${DEV_PORT}`);
|
||||
console.warn('');
|
||||
} else {
|
||||
host = 'localhost';
|
||||
port = DEV_PORT;
|
||||
console.warn('');
|
||||
console.warn(` WARNING: ${HOSTNAME} does not resolve.`);
|
||||
console.warn(` Dev server binding to https://localhost:${DEV_PORT}`);
|
||||
console.warn('');
|
||||
console.warn(` To use https://${HOSTNAME}, add to ${hostsFilePath()}:`);
|
||||
console.warn('');
|
||||
console.warn(` 127.1.0.1 ${HOSTNAME}`);
|
||||
console.warn('');
|
||||
console.warn(` See ${SETUP_DOC} for the full list of hostnames.`);
|
||||
console.warn('');
|
||||
}
|
||||
|
||||
runNgServe(host, port, extraArgs);
|
||||
}
|
||||
|
||||
function runNgServe(host, port, extraArgs) {
|
||||
const cwd = path.resolve(__dirname, '..');
|
||||
const ngCli = path.resolve(cwd, 'node_modules', '@angular', 'cli', 'bin', 'ng.js');
|
||||
// Pass the hostname (not IP) so Vite resolves it for both HTTPS and HMR websocket.
|
||||
// The hostname resolves to a unique loopback IP via the hosts file, so ports
|
||||
// won't collide with other services.
|
||||
const args = ['serve', '--host', host, '--port', String(port), '--ssl', ...extraArgs];
|
||||
|
||||
console.log(` ng serve binding to ${host}:${port}`);
|
||||
|
||||
const child = spawn(process.execPath, [ngCli, ...args], {
|
||||
stdio: 'inherit',
|
||||
cwd,
|
||||
});
|
||||
|
||||
child.on('exit', (code) => process.exit(code ?? 1));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,14 +1,4 @@
|
||||
<div class="app-shell">
|
||||
<section
|
||||
class="quickstart-banner"
|
||||
*ngIf="quickstartEnabled()"
|
||||
aria-label="Quickstart mode active"
|
||||
>
|
||||
<div>
|
||||
QUICKSTART MODE is enabled. Configuration and data shown are for demo/offline
|
||||
setup. See the <a routerLink="/welcome">welcome</a> page for details.
|
||||
</div>
|
||||
</section>
|
||||
<!-- Legacy URL Banner (ROUTE-003) -->
|
||||
@if (legacyRouteInfo(); as legacy) {
|
||||
<app-legacy-url-banner
|
||||
@@ -18,38 +8,48 @@
|
||||
></app-legacy-url-banner>
|
||||
}
|
||||
<header class="app-header">
|
||||
<a class="app-brand" routerLink="/">StellaOps Dashboard</a>
|
||||
<a class="app-brand" routerLink="/">
|
||||
<img class="app-brand__logo" src="assets/img/logo.png"
|
||||
alt="Stella Ops" width="28" height="28" />
|
||||
<span class="app-brand__text">Stella Ops</span>
|
||||
</a>
|
||||
|
||||
<!-- Main Navigation -->
|
||||
<app-navigation-menu></app-navigation-menu>
|
||||
<!-- Main Navigation (hidden on setup/auth pages) -->
|
||||
@if (showNavigation()) {
|
||||
<app-navigation-menu></app-navigation-menu>
|
||||
}
|
||||
|
||||
<!-- Right side: Auth section -->
|
||||
<div class="app-auth">
|
||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||
<span
|
||||
class="app-fresh"
|
||||
*ngIf="freshAuthSummary() as fresh"
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
<ng-container *ngIf="fresh.expiresAt">
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
</ng-container>
|
||||
</span>
|
||||
<span class="app-tenant" *ngIf="activeTenant() as tenant">
|
||||
{{ tenant }}
|
||||
</span>
|
||||
@if (isAuthenticated()) {
|
||||
@if (freshAuthSummary(); as fresh) {
|
||||
<span
|
||||
class="app-fresh"
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
@if (fresh.expiresAt) {
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@if (activeTenant(); as tenant) {
|
||||
<span class="app-tenant">
|
||||
{{ tenant }}
|
||||
</span>
|
||||
}
|
||||
<app-user-menu></app-user-menu>
|
||||
</ng-container>
|
||||
<ng-template #signIn>
|
||||
} @else if (showSignIn()) {
|
||||
<button type="button" class="app-auth__signin" (click)="onSignIn()">Sign in</button>
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-content">
|
||||
<app-breadcrumb *ngIf="showBreadcrumb()"></app-breadcrumb>
|
||||
@if (showBreadcrumb()) {
|
||||
<app-breadcrumb></app-breadcrumb>
|
||||
}
|
||||
<div class="page-container">
|
||||
<router-outlet />
|
||||
</div>
|
||||
|
||||
@@ -17,20 +17,6 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.quickstart-banner {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--font-size-sm);
|
||||
border-bottom: 1px solid var(--color-status-warning-border);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -51,6 +37,9 @@
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
@@ -58,8 +47,20 @@
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
&__logo {
|
||||
transition: transform var(--motion-duration-sm) var(--motion-ease-standard);
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&:hover &__logo {
|
||||
transform: rotate(8deg) scale(1.05);
|
||||
}
|
||||
|
||||
&__text {
|
||||
@media (max-width: 640px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +166,14 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.app-brand__logo {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.app-brand:hover .app-brand__logo {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-auth__signin {
|
||||
transition: none;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
@@ -10,6 +10,7 @@ import { AUTH_SERVICE, AuthService } from './core/auth';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { PolicyPackStore } from './features/policy-studio/services/policy-pack.store';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class AuthorityAuthServiceStub {
|
||||
beginLogin = jasmine.createSpy('beginLogin');
|
||||
@@ -19,24 +20,26 @@ class AuthorityAuthServiceStub {
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [AppComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
{ provide: AUTH_SERVICE, useValue: { canViewPolicies: () => true, canAuthorPolicies: () => true, canSimulatePolicies: () => true, canReviewPolicies: () => true } as AuthService },
|
||||
ConsoleSessionStore,
|
||||
{ provide: AppConfigService, useValue: { config: { quickstartMode: false, apiBaseUrls: { authority: '', policy: '' } } } },
|
||||
{ provide: AppConfigService, useValue: { config: { apiBaseUrls: { authority: '', policy: '' } }, configStatus: () => 'loaded', isConfigured: () => true } },
|
||||
{
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () =>
|
||||
of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
provide: PolicyPackStore,
|
||||
useValue: {
|
||||
getPacks: () => of([
|
||||
{ id: 'pack-1', name: 'Pack One', description: '', version: '1.0', status: 'active', createdAt: '', modifiedAt: '', createdBy: '', modifiedBy: '', tags: [] },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('creates the root component', () => {
|
||||
|
||||
@@ -3,16 +3,16 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Router, RouterLink, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { filter, map, startWith } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { filter, map, startWith, take } from 'rxjs/operators';
|
||||
|
||||
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { NavigationMenuComponent } from './shared/components/navigation-menu/navigation-menu.component';
|
||||
import { UserMenuComponent } from './shared/components/user-menu/user-menu.component';
|
||||
import { CommandPaletteComponent } from './shared/components/command-palette/command-palette.component';
|
||||
@@ -24,34 +24,52 @@ import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetr
|
||||
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
NavigationMenuComponent,
|
||||
UserMenuComponent,
|
||||
CommandPaletteComponent,
|
||||
ToastContainerComponent,
|
||||
BreadcrumbComponent,
|
||||
KeyboardShortcutsComponent,
|
||||
LegacyUrlBannerComponent,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
NavigationMenuComponent,
|
||||
UserMenuComponent,
|
||||
CommandPaletteComponent,
|
||||
ToastContainerComponent,
|
||||
BreadcrumbComponent,
|
||||
KeyboardShortcutsComponent,
|
||||
LegacyUrlBannerComponent,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly config = inject(AppConfigService);
|
||||
private readonly brandingService = inject(BrandingService);
|
||||
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor() {
|
||||
// Remove the inline splash screen once the first route resolves.
|
||||
// This keeps the splash visible while route guards (e.g. backend probe)
|
||||
// are still pending, avoiding a blank screen.
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
||||
take(1),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
const splash = document.getElementById('stella-splash');
|
||||
if (splash) {
|
||||
splash.style.opacity = '0';
|
||||
splash.style.transition = 'opacity 0.3s ease-out';
|
||||
setTimeout(() => splash.remove(), 350);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize branding on app start
|
||||
this.brandingService.fetchBranding().subscribe();
|
||||
|
||||
@@ -73,16 +91,9 @@ export class AppComponent {
|
||||
};
|
||||
});
|
||||
|
||||
readonly quickstartEnabled = computed(
|
||||
() => this.config.config.quickstartMode ?? false
|
||||
);
|
||||
|
||||
// Legacy route info for banner (ROUTE-003)
|
||||
readonly legacyRouteInfo = this.legacyRouteTelemetry.currentLegacyRoute;
|
||||
|
||||
// Routes where breadcrumb should not be shown (home, auth pages)
|
||||
private readonly hideBreadcrumbRoutes = ['/', '/welcome', '/callback', '/silent-refresh'];
|
||||
|
||||
private readonly currentUrl$ = this.router.events.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
map(event => event.urlAfterRedirects.split('?')[0]),
|
||||
@@ -93,8 +104,28 @@ export class AppComponent {
|
||||
|
||||
readonly showBreadcrumb = computed(() => {
|
||||
const url = this.currentUrl();
|
||||
// Don't show breadcrumb on home or simple routes
|
||||
return !this.hideBreadcrumbRoutes.includes(url) && url.split('/').filter(s => s).length > 0;
|
||||
const hideRoutes = ['/', '/welcome', '/setup', '/callback', '/silent-refresh'];
|
||||
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
|
||||
return false;
|
||||
}
|
||||
return url.split('/').filter(s => s).length > 0;
|
||||
});
|
||||
|
||||
/** Hide navigation on setup/auth pages and when not authenticated. */
|
||||
readonly showNavigation = computed(() => {
|
||||
const url = this.currentUrl();
|
||||
const hideRoutes = ['/setup', '/callback', '/silent-refresh'];
|
||||
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
|
||||
return false;
|
||||
}
|
||||
return this.isAuthenticated();
|
||||
});
|
||||
|
||||
/** Show sign-in only on pages where auth makes sense (not setup/callback). */
|
||||
readonly showSignIn = computed(() => {
|
||||
const url = this.currentUrl();
|
||||
const hideRoutes = ['/setup', '/callback', '/silent-refresh'];
|
||||
return !hideRoutes.some(route => url === route || url.startsWith(route + '/'));
|
||||
});
|
||||
|
||||
onSignIn(): void {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
|
||||
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
@@ -18,22 +19,22 @@ import {
|
||||
NOTIFY_API,
|
||||
NOTIFY_API_BASE_URL,
|
||||
NOTIFY_TENANT_ID,
|
||||
NotifyApiHttpClient,
|
||||
} from './core/api/notify.client';
|
||||
import {
|
||||
EXCEPTION_API,
|
||||
EXCEPTION_API_BASE_URL,
|
||||
ExceptionApiHttpClient,
|
||||
MockExceptionApiService,
|
||||
} from './core/api/exception.client';
|
||||
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
|
||||
import { VULNERABILITY_API } from './core/api/vulnerability.client';
|
||||
import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/vulnerability-http.client';
|
||||
import { RISK_API, MockRiskApi } from './core/api/risk.client';
|
||||
import { RISK_API } from './core/api/risk.client';
|
||||
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { BackendProbeService } from './core/config/backend-probe.service';
|
||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
|
||||
import { MockNotifyApiService } from './testing/mock-notify-api.service';
|
||||
import { seedAuthSession, type StubAuthSession } from './testing';
|
||||
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
|
||||
import { AUTH_SERVICE } from './core/auth';
|
||||
@@ -42,137 +43,118 @@ import {
|
||||
ADVISORY_AI_API,
|
||||
ADVISORY_AI_API_BASE_URL,
|
||||
AdvisoryAiApiHttpClient,
|
||||
MockAdvisoryAiClient,
|
||||
} from './core/api/advisory-ai.client';
|
||||
import {
|
||||
ADVISORY_API,
|
||||
ADVISORY_API_BASE_URL,
|
||||
AdvisoryApiHttpClient,
|
||||
MockAdvisoryApiService,
|
||||
} from './core/api/advisories.client';
|
||||
import {
|
||||
VEX_EVIDENCE_API,
|
||||
VEX_EVIDENCE_API_BASE_URL,
|
||||
VexEvidenceHttpClient,
|
||||
MockVexEvidenceClient,
|
||||
} from './core/api/vex-evidence.client';
|
||||
import {
|
||||
VEX_DECISIONS_API,
|
||||
VEX_DECISIONS_API_BASE_URL,
|
||||
VexDecisionsHttpClient,
|
||||
MockVexDecisionsClient,
|
||||
} from './core/api/vex-decisions.client';
|
||||
import { VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL } from './core/api/vex-hub.client';
|
||||
import {
|
||||
AUDIT_BUNDLES_API,
|
||||
AUDIT_BUNDLES_API_BASE_URL,
|
||||
AuditBundlesHttpClient,
|
||||
MockAuditBundlesClient,
|
||||
} from './core/api/audit-bundles.client';
|
||||
import {
|
||||
POLICY_EXCEPTIONS_API,
|
||||
POLICY_EXCEPTIONS_API_BASE_URL,
|
||||
PolicyExceptionsHttpClient,
|
||||
MockPolicyExceptionsApiService,
|
||||
} from './core/api/policy-exceptions.client';
|
||||
import {
|
||||
POLICY_EVIDENCE_API,
|
||||
PolicyEvidenceCompositeClient,
|
||||
MockPolicyEvidenceApiService,
|
||||
} from './core/api/policy-evidence.client';
|
||||
import {
|
||||
ORCHESTRATOR_API,
|
||||
ORCHESTRATOR_API_BASE_URL,
|
||||
OrchestratorHttpClient,
|
||||
MockOrchestratorClient,
|
||||
} from './core/api/orchestrator.client';
|
||||
import {
|
||||
ORCHESTRATOR_CONTROL_API,
|
||||
OrchestratorControlHttpClient,
|
||||
MockOrchestratorControlClient,
|
||||
} from './core/api/orchestrator-control.client';
|
||||
import {
|
||||
FIRST_SIGNAL_API,
|
||||
FirstSignalHttpClient,
|
||||
MockFirstSignalClient,
|
||||
} from './core/api/first-signal.client';
|
||||
import {
|
||||
EXCEPTION_EVENTS_API,
|
||||
EXCEPTION_EVENTS_API_BASE_URL,
|
||||
ExceptionEventsHttpClient,
|
||||
MockExceptionEventsApiService,
|
||||
} from './core/api/exception-events.client';
|
||||
import {
|
||||
EVIDENCE_PACK_API,
|
||||
EVIDENCE_PACK_API_BASE_URL,
|
||||
EvidencePackHttpClient,
|
||||
MockEvidencePackClient,
|
||||
} from './core/api/evidence-pack.client';
|
||||
import {
|
||||
AI_RUNS_API,
|
||||
AI_RUNS_API_BASE_URL,
|
||||
AiRunsHttpClient,
|
||||
MockAiRunsClient,
|
||||
} from './core/api/ai-runs.client';
|
||||
import {
|
||||
RELEASE_DASHBOARD_API,
|
||||
RELEASE_DASHBOARD_API_BASE_URL,
|
||||
ReleaseDashboardHttpClient,
|
||||
MockReleaseDashboardClient,
|
||||
} from './core/api/release-dashboard.client';
|
||||
import {
|
||||
RELEASE_ENVIRONMENT_API,
|
||||
RELEASE_ENVIRONMENT_API_BASE_URL,
|
||||
ReleaseEnvironmentHttpClient,
|
||||
MockReleaseEnvironmentClient,
|
||||
} from './core/api/release-environment.client';
|
||||
import {
|
||||
RELEASE_MANAGEMENT_API,
|
||||
ReleaseManagementHttpClient,
|
||||
MockReleaseManagementClient,
|
||||
} from './core/api/release-management.client';
|
||||
import {
|
||||
WORKFLOW_API,
|
||||
WorkflowHttpClient,
|
||||
MockWorkflowClient,
|
||||
} from './core/api/workflow.client';
|
||||
import {
|
||||
APPROVAL_API,
|
||||
ApprovalHttpClient,
|
||||
MockApprovalClient,
|
||||
} from './core/api/approval.client';
|
||||
import {
|
||||
DEPLOYMENT_API,
|
||||
DeploymentHttpClient,
|
||||
MockDeploymentClient,
|
||||
} from './core/api/deployment.client';
|
||||
import {
|
||||
RELEASE_EVIDENCE_API,
|
||||
ReleaseEvidenceHttpClient,
|
||||
MockReleaseEvidenceClient,
|
||||
} from './core/api/release-evidence.client';
|
||||
import {
|
||||
DOCTOR_API,
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
} from './features/doctor/services/doctor.client';
|
||||
import {
|
||||
WITNESS_API,
|
||||
WitnessHttpClient,
|
||||
WitnessMockClient,
|
||||
} from './core/api/witness.client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useFactory: (configService: AppConfigService) => () =>
|
||||
configService.load(),
|
||||
deps: [AppConfigService],
|
||||
},
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService) => async () => {
|
||||
await configService.load();
|
||||
if (configService.isConfigured()) {
|
||||
probeService.probe();
|
||||
}
|
||||
})(inject(AppConfigService), inject(BackendProbeService));
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthHttpInterceptor,
|
||||
@@ -207,11 +189,8 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: AUTHORITY_CONSOLE_API,
|
||||
useExisting: AuthorityConsoleApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
deps: [AuthSessionStore],
|
||||
useFactory: (store: AuthSessionStore) => () => {
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = ((store: AuthSessionStore) => () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined;
|
||||
if (!stub) return;
|
||||
@@ -220,8 +199,9 @@ export const appConfig: ApplicationConfig = {
|
||||
} catch (err) {
|
||||
console.warn('Failed to seed test session', err);
|
||||
}
|
||||
},
|
||||
},
|
||||
})(inject(AuthSessionStore));
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
provide: RISK_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
@@ -255,12 +235,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
RiskHttpClient,
|
||||
MockRiskApi,
|
||||
{
|
||||
provide: RISK_API,
|
||||
deps: [AppConfigService, RiskHttpClient, MockRiskApi],
|
||||
useFactory: (config: AppConfigService, http: RiskHttpClient, mock: MockRiskApi) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: RiskHttpClient,
|
||||
},
|
||||
{
|
||||
provide: VULNERABILITY_API_BASE_URL,
|
||||
@@ -278,15 +255,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
VulnerabilityHttpClient,
|
||||
MockVulnerabilityApiService,
|
||||
{
|
||||
provide: VULNERABILITY_API,
|
||||
deps: [AppConfigService, VulnerabilityHttpClient, MockVulnerabilityApiService],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: VulnerabilityHttpClient,
|
||||
mock: MockVulnerabilityApiService
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: VulnerabilityHttpClient,
|
||||
},
|
||||
{
|
||||
provide: NOTIFY_API_BASE_URL,
|
||||
@@ -315,12 +286,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
AdvisoryAiApiHttpClient,
|
||||
MockAdvisoryAiClient,
|
||||
{
|
||||
provide: ADVISORY_AI_API,
|
||||
deps: [AppConfigService, AdvisoryAiApiHttpClient, MockAdvisoryAiClient],
|
||||
useFactory: (config: AppConfigService, http: AdvisoryAiApiHttpClient, mock: MockAdvisoryAiClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: AdvisoryAiApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: ADVISORY_API_BASE_URL,
|
||||
@@ -331,12 +299,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
AdvisoryApiHttpClient,
|
||||
MockAdvisoryApiService,
|
||||
{
|
||||
provide: ADVISORY_API,
|
||||
deps: [AppConfigService, AdvisoryApiHttpClient, MockAdvisoryApiService],
|
||||
useFactory: (config: AppConfigService, http: AdvisoryApiHttpClient, mock: MockAdvisoryApiService) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: AdvisoryApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: VEX_EVIDENCE_API_BASE_URL,
|
||||
@@ -373,12 +338,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
VexEvidenceHttpClient,
|
||||
MockVexEvidenceClient,
|
||||
{
|
||||
provide: VEX_EVIDENCE_API,
|
||||
deps: [AppConfigService, VexEvidenceHttpClient, MockVexEvidenceClient],
|
||||
useFactory: (config: AppConfigService, http: VexEvidenceHttpClient, mock: MockVexEvidenceClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: VexEvidenceHttpClient,
|
||||
},
|
||||
{
|
||||
provide: VEX_DECISIONS_API_BASE_URL,
|
||||
@@ -389,12 +351,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
VexDecisionsHttpClient,
|
||||
MockVexDecisionsClient,
|
||||
{
|
||||
provide: VEX_DECISIONS_API,
|
||||
deps: [AppConfigService, VexDecisionsHttpClient, MockVexDecisionsClient],
|
||||
useFactory: (config: AppConfigService, http: VexDecisionsHttpClient, mock: MockVexDecisionsClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: VexDecisionsHttpClient,
|
||||
},
|
||||
{
|
||||
provide: AUDIT_BUNDLES_API_BASE_URL,
|
||||
@@ -405,12 +364,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
AuditBundlesHttpClient,
|
||||
MockAuditBundlesClient,
|
||||
{
|
||||
provide: AUDIT_BUNDLES_API,
|
||||
deps: [AppConfigService, AuditBundlesHttpClient, MockAuditBundlesClient],
|
||||
useFactory: (config: AppConfigService, http: AuditBundlesHttpClient, mock: MockAuditBundlesClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: AuditBundlesHttpClient,
|
||||
},
|
||||
{
|
||||
provide: POLICY_EXCEPTIONS_API_BASE_URL,
|
||||
@@ -421,23 +377,14 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
PolicyExceptionsHttpClient,
|
||||
MockPolicyExceptionsApiService,
|
||||
{
|
||||
provide: POLICY_EXCEPTIONS_API,
|
||||
deps: [AppConfigService, PolicyExceptionsHttpClient, MockPolicyExceptionsApiService],
|
||||
useFactory: (config: AppConfigService, http: PolicyExceptionsHttpClient, mock: MockPolicyExceptionsApiService) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: PolicyExceptionsHttpClient,
|
||||
},
|
||||
PolicyEvidenceCompositeClient,
|
||||
MockPolicyEvidenceApiService,
|
||||
{
|
||||
provide: POLICY_EVIDENCE_API,
|
||||
deps: [AppConfigService, PolicyEvidenceCompositeClient, MockPolicyEvidenceApiService],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
composite: PolicyEvidenceCompositeClient,
|
||||
mock: MockPolicyEvidenceApiService
|
||||
) => (config.config.quickstartMode ? mock : composite),
|
||||
useExisting: PolicyEvidenceCompositeClient,
|
||||
},
|
||||
{
|
||||
provide: ORCHESTRATOR_API_BASE_URL,
|
||||
@@ -448,34 +395,19 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
OrchestratorHttpClient,
|
||||
MockOrchestratorClient,
|
||||
{
|
||||
provide: ORCHESTRATOR_API,
|
||||
deps: [AppConfigService, OrchestratorHttpClient, MockOrchestratorClient],
|
||||
useFactory: (config: AppConfigService, http: OrchestratorHttpClient, mock: MockOrchestratorClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: OrchestratorHttpClient,
|
||||
},
|
||||
OrchestratorControlHttpClient,
|
||||
MockOrchestratorControlClient,
|
||||
{
|
||||
provide: ORCHESTRATOR_CONTROL_API,
|
||||
deps: [AppConfigService, OrchestratorControlHttpClient, MockOrchestratorControlClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: OrchestratorControlHttpClient,
|
||||
mock: MockOrchestratorControlClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: OrchestratorControlHttpClient,
|
||||
},
|
||||
FirstSignalHttpClient,
|
||||
MockFirstSignalClient,
|
||||
{
|
||||
provide: FIRST_SIGNAL_API,
|
||||
deps: [AppConfigService, FirstSignalHttpClient, MockFirstSignalClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: FirstSignalHttpClient,
|
||||
mock: MockFirstSignalClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: FirstSignalHttpClient,
|
||||
},
|
||||
{
|
||||
provide: EXCEPTION_EVENTS_API_BASE_URL,
|
||||
@@ -486,12 +418,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ExceptionEventsHttpClient,
|
||||
MockExceptionEventsApiService,
|
||||
{
|
||||
provide: EXCEPTION_EVENTS_API,
|
||||
deps: [AppConfigService, ExceptionEventsHttpClient, MockExceptionEventsApiService],
|
||||
useFactory: (config: AppConfigService, http: ExceptionEventsHttpClient, mock: MockExceptionEventsApiService) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: ExceptionEventsHttpClient,
|
||||
},
|
||||
{
|
||||
provide: EXCEPTION_API_BASE_URL,
|
||||
@@ -502,12 +431,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ExceptionApiHttpClient,
|
||||
MockExceptionApiService,
|
||||
{
|
||||
provide: EXCEPTION_API,
|
||||
deps: [AppConfigService, ExceptionApiHttpClient, MockExceptionApiService],
|
||||
useFactory: (config: AppConfigService, http: ExceptionApiHttpClient, mock: MockExceptionApiService) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: ExceptionApiHttpClient,
|
||||
},
|
||||
{
|
||||
provide: EVIDENCE_PACK_API_BASE_URL,
|
||||
@@ -523,12 +449,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
EvidencePackHttpClient,
|
||||
MockEvidencePackClient,
|
||||
{
|
||||
provide: EVIDENCE_PACK_API,
|
||||
deps: [AppConfigService, EvidencePackHttpClient, MockEvidencePackClient],
|
||||
useFactory: (config: AppConfigService, http: EvidencePackHttpClient, mock: MockEvidencePackClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: EvidencePackHttpClient,
|
||||
},
|
||||
{
|
||||
provide: AI_RUNS_API_BASE_URL,
|
||||
@@ -544,12 +467,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
AiRunsHttpClient,
|
||||
MockAiRunsClient,
|
||||
{
|
||||
provide: AI_RUNS_API,
|
||||
deps: [AppConfigService, AiRunsHttpClient, MockAiRunsClient],
|
||||
useFactory: (config: AppConfigService, http: AiRunsHttpClient, mock: MockAiRunsClient) =>
|
||||
config.config.quickstartMode ? mock : http,
|
||||
useExisting: AiRunsHttpClient,
|
||||
},
|
||||
{
|
||||
provide: CONSOLE_API_BASE_URL,
|
||||
@@ -574,10 +494,10 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: NOTIFY_TENANT_ID,
|
||||
useValue: 'tenant-dev',
|
||||
},
|
||||
MockNotifyApiService,
|
||||
NotifyApiHttpClient,
|
||||
{
|
||||
provide: NOTIFY_API,
|
||||
useExisting: MockNotifyApiService,
|
||||
useExisting: NotifyApiHttpClient,
|
||||
},
|
||||
// Release Dashboard API
|
||||
{
|
||||
@@ -594,15 +514,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ReleaseDashboardHttpClient,
|
||||
MockReleaseDashboardClient,
|
||||
{
|
||||
provide: RELEASE_DASHBOARD_API,
|
||||
deps: [AppConfigService, ReleaseDashboardHttpClient, MockReleaseDashboardClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: ReleaseDashboardHttpClient,
|
||||
mock: MockReleaseDashboardClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: ReleaseDashboardHttpClient,
|
||||
},
|
||||
// Release Environment API (Sprint 111_002)
|
||||
{
|
||||
@@ -619,99 +533,51 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ReleaseEnvironmentHttpClient,
|
||||
MockReleaseEnvironmentClient,
|
||||
{
|
||||
provide: RELEASE_ENVIRONMENT_API,
|
||||
deps: [AppConfigService, ReleaseEnvironmentHttpClient, MockReleaseEnvironmentClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: ReleaseEnvironmentHttpClient,
|
||||
mock: MockReleaseEnvironmentClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: ReleaseEnvironmentHttpClient,
|
||||
},
|
||||
// Release Management API (Sprint 111_003)
|
||||
ReleaseManagementHttpClient,
|
||||
MockReleaseManagementClient,
|
||||
{
|
||||
provide: RELEASE_MANAGEMENT_API,
|
||||
deps: [AppConfigService, ReleaseManagementHttpClient, MockReleaseManagementClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: ReleaseManagementHttpClient,
|
||||
mock: MockReleaseManagementClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: ReleaseManagementHttpClient,
|
||||
},
|
||||
// Workflow API (Sprint 111_004)
|
||||
WorkflowHttpClient,
|
||||
MockWorkflowClient,
|
||||
{
|
||||
provide: WORKFLOW_API,
|
||||
deps: [AppConfigService, WorkflowHttpClient, MockWorkflowClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: WorkflowHttpClient,
|
||||
mock: MockWorkflowClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: WorkflowHttpClient,
|
||||
},
|
||||
// Approval API (Sprint 111_005)
|
||||
ApprovalHttpClient,
|
||||
MockApprovalClient,
|
||||
{
|
||||
provide: APPROVAL_API,
|
||||
deps: [AppConfigService, ApprovalHttpClient, MockApprovalClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: ApprovalHttpClient,
|
||||
mock: MockApprovalClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: ApprovalHttpClient,
|
||||
},
|
||||
// Deployment API (Sprint 111_006)
|
||||
DeploymentHttpClient,
|
||||
MockDeploymentClient,
|
||||
{
|
||||
provide: DEPLOYMENT_API,
|
||||
deps: [AppConfigService, DeploymentHttpClient, MockDeploymentClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: DeploymentHttpClient,
|
||||
mock: MockDeploymentClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: DeploymentHttpClient,
|
||||
},
|
||||
// Release Evidence API (Sprint 111_007)
|
||||
ReleaseEvidenceHttpClient,
|
||||
MockReleaseEvidenceClient,
|
||||
{
|
||||
provide: RELEASE_EVIDENCE_API,
|
||||
deps: [AppConfigService, ReleaseEvidenceHttpClient, MockReleaseEvidenceClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: ReleaseEvidenceHttpClient,
|
||||
mock: MockReleaseEvidenceClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: ReleaseEvidenceHttpClient,
|
||||
},
|
||||
// Doctor API (Sprint 20260112_001_008)
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
{
|
||||
provide: DOCTOR_API,
|
||||
deps: [AppConfigService, HttpDoctorClient, MockDoctorClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: HttpDoctorClient,
|
||||
mock: MockDoctorClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: HttpDoctorClient,
|
||||
},
|
||||
// Witness API (Sprint 20260112_013_FE_witness_ui_wiring)
|
||||
WitnessHttpClient,
|
||||
WitnessMockClient,
|
||||
{
|
||||
provide: WITNESS_API,
|
||||
deps: [AppConfigService, WitnessHttpClient, WitnessMockClient],
|
||||
useFactory: (
|
||||
config: AppConfigService,
|
||||
http: WitnessHttpClient,
|
||||
mock: WitnessMockClient
|
||||
) => (config.config.quickstartMode ? mock : http),
|
||||
useExisting: WitnessHttpClient,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
requireAnalyticsViewerGuard,
|
||||
} from './core/auth';
|
||||
|
||||
import { requireConfigGuard } from './core/config/config.guard';
|
||||
import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard';
|
||||
import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
|
||||
|
||||
export const routes: Routes = [
|
||||
@@ -24,7 +26,7 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/control-plane/control-plane.routes').then(
|
||||
(m) => m.CONTROL_PLANE_ROUTES
|
||||
@@ -34,7 +36,7 @@ export const routes: Routes = [
|
||||
// Approvals - promotion decision cockpit
|
||||
{
|
||||
path: 'approvals',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/approvals/approvals.routes').then(
|
||||
(m) => m.APPROVALS_ROUTES
|
||||
@@ -44,7 +46,7 @@ export const routes: Routes = [
|
||||
// Security - consolidated security analysis (SEC-005, SEC-006)
|
||||
{
|
||||
path: 'security',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/security/security.routes').then(
|
||||
(m) => m.SECURITY_ROUTES
|
||||
@@ -54,7 +56,7 @@ export const routes: Routes = [
|
||||
// Analytics - SBOM and attestation insights (SPRINT_20260120_031)
|
||||
{
|
||||
path: 'analytics',
|
||||
canMatch: [requireAnalyticsViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAnalyticsViewerGuard],
|
||||
loadChildren: () =>
|
||||
import('./features/analytics/analytics.routes').then(
|
||||
(m) => m.ANALYTICS_ROUTES
|
||||
@@ -64,7 +66,7 @@ export const routes: Routes = [
|
||||
// Policy - governance and exceptions (SEC-007)
|
||||
{
|
||||
path: 'policy',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/policy/policy.routes').then(
|
||||
(m) => m.POLICY_ROUTES
|
||||
@@ -74,7 +76,7 @@ export const routes: Routes = [
|
||||
// Settings - consolidated configuration (SPRINT_20260118_002)
|
||||
{
|
||||
path: 'settings',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/settings/settings.routes').then(
|
||||
(m) => m.SETTINGS_ROUTES
|
||||
@@ -88,7 +90,7 @@ export const routes: Routes = [
|
||||
// Legacy Home Dashboard - redirects or will be removed
|
||||
{
|
||||
path: 'home',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/home/home-dashboard.component').then(
|
||||
(m) => m.HomeDashboardComponent
|
||||
@@ -96,6 +98,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'dashboard/sources',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/sources-dashboard.component').then(
|
||||
(m) => m.SourcesDashboardComponent
|
||||
@@ -103,6 +106,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'console/profile',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/console/console-profile.component').then(
|
||||
(m) => m.ConsoleProfileComponent
|
||||
@@ -110,6 +114,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'console/status',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/console/console-status.component').then(
|
||||
(m) => m.ConsoleStatusComponent
|
||||
@@ -118,6 +123,7 @@ export const routes: Routes = [
|
||||
// Console Admin routes - gated by ui.admin scope
|
||||
{
|
||||
path: 'console/admin',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadChildren: () =>
|
||||
import('./features/console-admin/console-admin.routes').then(
|
||||
(m) => m.consoleAdminRoutes
|
||||
@@ -126,7 +132,7 @@ export const routes: Routes = [
|
||||
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
|
||||
{
|
||||
path: 'orchestrator',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-dashboard.component').then(
|
||||
(m) => m.OrchestratorDashboardComponent
|
||||
@@ -134,7 +140,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/jobs',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-jobs.component').then(
|
||||
(m) => m.OrchestratorJobsComponent
|
||||
@@ -142,7 +148,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/jobs/:jobId',
|
||||
canMatch: [requireOrchViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireOrchViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-job-detail.component').then(
|
||||
(m) => m.OrchestratorJobDetailComponent
|
||||
@@ -150,7 +156,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'orchestrator/quotas',
|
||||
canMatch: [requireOrchOperatorGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireOrchOperatorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/orchestrator/orchestrator-quotas.component').then(
|
||||
(m) => m.OrchestratorQuotasComponent
|
||||
@@ -159,7 +165,7 @@ export const routes: Routes = [
|
||||
// Release Orchestrator - Dashboard and management UI (SPRINT_20260110_111_001)
|
||||
{
|
||||
path: 'release-orchestrator',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/release-orchestrator/dashboard/dashboard.routes').then(
|
||||
(m) => m.DASHBOARD_ROUTES
|
||||
@@ -167,7 +173,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/workspace/policy-workspace.component').then(
|
||||
(m) => m.PolicyWorkspaceComponent
|
||||
@@ -175,7 +181,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/editor',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/editor/policy-editor.component').then(
|
||||
(m) => m.PolicyEditorComponent
|
||||
@@ -183,7 +189,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/yaml',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/yaml/policy-yaml-editor.component').then(
|
||||
(m) => m.PolicyYamlEditorComponent
|
||||
@@ -191,7 +197,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/simulate',
|
||||
canMatch: [requirePolicySimulatorGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicySimulatorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/simulation/policy-simulation.component').then(
|
||||
(m) => m.PolicySimulationComponent
|
||||
@@ -199,7 +205,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/approvals',
|
||||
canMatch: [requirePolicyReviewOrApproveGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyReviewOrApproveGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/approvals/policy-approvals.component').then(
|
||||
(m) => m.PolicyApprovalsComponent
|
||||
@@ -207,7 +213,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/rules',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/rule-builder/policy-rule-builder.component').then(
|
||||
(m) => m.PolicyRuleBuilderComponent
|
||||
@@ -215,7 +221,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/explain/:runId',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/explain/policy-explain.component').then(
|
||||
(m) => m.PolicyExplainComponent
|
||||
@@ -223,7 +229,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/dashboard',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/dashboard/policy-dashboard.component').then(
|
||||
(m) => m.PolicyDashboardComponent
|
||||
@@ -231,6 +237,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
|
||||
(m) => m.TrivyDbSettingsPageComponent
|
||||
@@ -238,6 +245,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'scans/:scanId',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/scans/scan-detail-page.component').then(
|
||||
(m) => m.ScanDetailPageComponent
|
||||
@@ -245,6 +253,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/welcome/welcome-page.component').then(
|
||||
(m) => m.WelcomePageComponent
|
||||
@@ -252,7 +261,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'risk',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/risk/risk-dashboard.component').then(
|
||||
(m) => m.RiskDashboardComponent
|
||||
@@ -260,7 +269,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'graph',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/graph/graph-explorer.component').then(
|
||||
(m) => m.GraphExplorerComponent
|
||||
@@ -268,13 +277,13 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'lineage',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/lineage/lineage.routes').then((m) => m.lineageRoutes),
|
||||
},
|
||||
{
|
||||
path: 'reachability',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/reachability/reachability-center.component').then(
|
||||
(m) => m.ReachabilityCenterComponent
|
||||
@@ -282,7 +291,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/vulnerabilities/vulnerability-explorer.component').then(
|
||||
(m) => m.VulnerabilityExplorerComponent
|
||||
@@ -291,7 +300,7 @@ export const routes: Routes = [
|
||||
// Findings container with diff-first default (SPRINT_1227_0005_0001)
|
||||
{
|
||||
path: 'findings',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/findings/container/findings-container.component').then(
|
||||
(m) => m.FindingsContainerComponent
|
||||
@@ -299,7 +308,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'findings/:scanId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/findings/container/findings-container.component').then(
|
||||
(m) => m.FindingsContainerComponent
|
||||
@@ -307,7 +316,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'triage/artifacts',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/triage/triage-artifacts.component').then(
|
||||
(m) => m.TriageArtifactsComponent
|
||||
@@ -315,7 +324,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'triage/artifacts/:artifactId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/triage/triage-workspace.component').then(
|
||||
(m) => m.TriageWorkspaceComponent
|
||||
@@ -323,7 +332,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'triage/audit-bundles',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/triage/triage-audit-bundles.component').then(
|
||||
(m) => m.TriageAuditBundlesComponent
|
||||
@@ -331,7 +340,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'triage/audit-bundles/new',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/triage/triage-audit-bundle-new.component').then(
|
||||
(m) => m.TriageAuditBundleNewComponent
|
||||
@@ -339,7 +348,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'compare/:currentId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/compare/components/compare-view/compare-view.component').then(
|
||||
(m) => m.CompareViewComponent
|
||||
@@ -347,7 +356,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'proofs/:subjectDigest',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/proof-chain/proof-chain.component').then(
|
||||
(m) => m.ProofChainComponent
|
||||
@@ -355,7 +364,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'vulnerabilities/:vulnId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/vulnerabilities/vulnerability-detail.component').then(
|
||||
(m) => m.VulnerabilityDetailComponent
|
||||
@@ -363,12 +372,13 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'cvss/receipts/:receiptId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/cvss/cvss-receipt.component').then((m) => m.CvssReceiptComponent),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/notify/notify-panel.component').then(
|
||||
(m) => m.NotifyPanelComponent
|
||||
@@ -377,62 +387,62 @@ export const routes: Routes = [
|
||||
// Admin - VEX Hub (SPRINT_20251229_018a)
|
||||
{
|
||||
path: 'admin/vex-hub',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
|
||||
},
|
||||
// Admin - Notifications (SPRINT_20251229_018b)
|
||||
{
|
||||
path: 'admin/notifications',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes),
|
||||
},
|
||||
// Admin - Trust Management (SPRINT_20251229_018c)
|
||||
{
|
||||
path: 'admin/trust',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/trust-admin/trust-admin.routes').then((m) => m.trustAdminRoutes),
|
||||
},
|
||||
// Ops - Feed Mirror (SPRINT_20251229_020)
|
||||
{
|
||||
path: 'ops/feeds',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/feed-mirror/feed-mirror.routes').then((m) => m.feedMirrorRoutes),
|
||||
},
|
||||
{
|
||||
path: 'sbom-sources',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES),
|
||||
},
|
||||
// Admin - Policy Governance (SPRINT_20251229_021a)
|
||||
{
|
||||
path: 'admin/policy/governance',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/policy-governance/policy-governance.routes').then((m) => m.policyGovernanceRoutes),
|
||||
},
|
||||
// Admin - Policy Simulation (SPRINT_20251229_021b)
|
||||
{
|
||||
path: 'admin/policy/simulation',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/policy-simulation/policy-simulation.routes').then((m) => m.policySimulationRoutes),
|
||||
},
|
||||
// Evidence/Export/Replay (SPRINT_20251229_016)
|
||||
{
|
||||
path: 'evidence',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/evidence-export/evidence-export.routes').then((m) => m.evidenceExportRoutes),
|
||||
},
|
||||
// Scheduler Ops (SPRINT_20251229_017)
|
||||
{
|
||||
path: 'scheduler',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/scheduler-ops/scheduler-ops.routes').then((m) => m.schedulerOpsRoutes),
|
||||
},
|
||||
@@ -446,7 +456,7 @@ export const routes: Routes = [
|
||||
// Exceptions route
|
||||
{
|
||||
path: 'exceptions',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/triage/triage-artifacts.component').then(
|
||||
(m) => m.TriageArtifactsComponent
|
||||
@@ -455,105 +465,105 @@ export const routes: Routes = [
|
||||
// Integration Hub (SPRINT_20251229_011_FE_integration_hub_ui)
|
||||
{
|
||||
path: 'integrations',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
|
||||
},
|
||||
// Admin - Registry Token Service (SPRINT_20251229_023)
|
||||
{
|
||||
path: 'admin/registries',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/registry-admin/registry-admin.routes').then((m) => m.registryAdminRoutes),
|
||||
},
|
||||
// Admin - Issuer Trust (SPRINT_20251229_024)
|
||||
{
|
||||
path: 'admin/issuers',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/issuer-trust/issuer-trust.routes').then((m) => m.issuerTrustRoutes),
|
||||
},
|
||||
// Ops - Scanner Operations (SPRINT_20251229_025)
|
||||
{
|
||||
path: 'ops/scanner',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/scanner-ops/scanner-ops.routes').then((m) => m.scannerOpsRoutes),
|
||||
},
|
||||
// Ops - Offline Kit Management (SPRINT_20251229_026)
|
||||
{
|
||||
path: 'ops/offline-kit',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/offline-kit/offline-kit.routes').then((m) => m.offlineKitRoutes),
|
||||
},
|
||||
// Ops - AOC Compliance Dashboard (SPRINT_20251229_027)
|
||||
{
|
||||
path: 'ops/aoc',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/aoc-compliance/aoc-compliance.routes').then((m) => m.AOC_COMPLIANCE_ROUTES),
|
||||
},
|
||||
// Admin - Unified Audit Log (SPRINT_20251229_028)
|
||||
{
|
||||
path: 'admin/audit',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/audit-log/audit-log.routes').then((m) => m.auditLogRoutes),
|
||||
},
|
||||
// Ops - Quota Dashboard (SPRINT_20251229_029)
|
||||
{
|
||||
path: 'ops/quotas',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/quota-dashboard/quota.routes').then((m) => m.quotaRoutes),
|
||||
},
|
||||
// Ops - Dead-Letter Management (SPRINT_20251229_030)
|
||||
{
|
||||
path: 'ops/orchestrator/dead-letter',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/deadletter/deadletter.routes').then((m) => m.deadletterRoutes),
|
||||
},
|
||||
// Ops - SLO Burn Rate Monitoring (SPRINT_20251229_031)
|
||||
{
|
||||
path: 'ops/orchestrator/slo',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/slo-monitoring/slo.routes').then((m) => m.sloRoutes),
|
||||
},
|
||||
// Ops - Platform Health Dashboard (SPRINT_20251229_032)
|
||||
{
|
||||
path: 'ops/health',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/platform-health/platform-health.routes').then((m) => m.platformHealthRoutes),
|
||||
},
|
||||
// Ops - Doctor Diagnostics (SPRINT_20260112_001_008)
|
||||
{
|
||||
path: 'ops/doctor',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
|
||||
},
|
||||
// Ops - Agent Fleet (SPRINT_20260118_023_FE)
|
||||
{
|
||||
path: 'ops/agents',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/agents/agents.routes').then((m) => m.AGENTS_ROUTES),
|
||||
},
|
||||
// Analyze - Unknowns Tracking (SPRINT_20251229_033)
|
||||
{
|
||||
path: 'analyze/unknowns',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/unknowns-tracking/unknowns.routes').then((m) => m.unknownsRoutes),
|
||||
},
|
||||
// Analyze - Patch Map Explorer (SPRINT_20260103_003_FE_patch_map_explorer)
|
||||
{
|
||||
path: 'analyze/patch-map',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/binary-index/patch-map.component').then(
|
||||
(m) => m.PatchMapComponent
|
||||
@@ -562,7 +572,7 @@ export const routes: Routes = [
|
||||
// Evidence Packs (SPRINT_20260109_011_005)
|
||||
{
|
||||
path: 'evidence-packs',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/evidence-pack/evidence-pack-list.component').then(
|
||||
(m) => m.EvidencePackListComponent
|
||||
@@ -570,7 +580,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'evidence-packs/:packId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/evidence-pack/evidence-pack-viewer.component').then(
|
||||
(m) => m.EvidencePackViewerComponent
|
||||
@@ -579,7 +589,7 @@ export const routes: Routes = [
|
||||
// AI Runs (SPRINT_20260109_011_003)
|
||||
{
|
||||
path: 'ai-runs',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/ai-runs/ai-runs-list.component').then(
|
||||
(m) => m.AiRunsListComponent
|
||||
@@ -587,7 +597,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'ai-runs/:runId',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadComponent: () =>
|
||||
import('./features/ai-runs/ai-run-viewer.component').then(
|
||||
(m) => m.AiRunViewerComponent
|
||||
@@ -596,11 +606,11 @@ export const routes: Routes = [
|
||||
// Change Trace (SPRINT_20260112_200_007)
|
||||
{
|
||||
path: 'change-trace',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/change-trace/change-trace.routes').then((m) => m.changeTraceRoutes),
|
||||
},
|
||||
// Setup Wizard (Sprint 4: UI Wizard Core)
|
||||
// Setup Wizard (Sprint 4: UI Wizard Core) — NO config guard (must work without config)
|
||||
{
|
||||
path: 'setup',
|
||||
loadChildren: () =>
|
||||
@@ -609,28 +619,28 @@ export const routes: Routes = [
|
||||
// Configuration Pane (Sprint 6: Configuration Pane)
|
||||
{
|
||||
path: 'console/configuration',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/configuration-pane/configuration-pane.routes').then((m) => m.CONFIGURATION_PANE_ROUTES),
|
||||
},
|
||||
// SBOM Diff View (SPRINT_0127_0001_FE - FE-PERSONA-02)
|
||||
{
|
||||
path: 'sbom/diff',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/sbom-diff/sbom-diff.routes').then((m) => m.SBOM_DIFF_ROUTES),
|
||||
},
|
||||
// VEX Timeline (SPRINT_0127_0001_FE - FE-PERSONA-03)
|
||||
{
|
||||
path: 'vex/timeline',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/vex-timeline/vex-timeline.routes').then((m) => m.VEX_TIMELINE_ROUTES),
|
||||
},
|
||||
// Developer Workspace (SPRINT_0127_0001_FE - FE-PERSONA-04)
|
||||
{
|
||||
path: 'workspace/dev',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/workspaces/developer/developer-workspace.routes').then(
|
||||
(m) => m.DEVELOPER_WORKSPACE_ROUTES
|
||||
@@ -639,7 +649,7 @@ export const routes: Routes = [
|
||||
// Auditor Workspace (SPRINT_0127_0001_FE - FE-PERSONA-05)
|
||||
{
|
||||
path: 'workspace/audit',
|
||||
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, () => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
|
||||
loadChildren: () =>
|
||||
import('./features/workspaces/auditor/auditor-workspace.routes').then(
|
||||
(m) => m.AUDITOR_WORKSPACE_ROUTES
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import {
|
||||
EvidencePanelMetricsService,
|
||||
EvidencePanelAction,
|
||||
} from './evidence-panel-metrics.service';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('EvidencePanelMetricsService', () => {
|
||||
let service: EvidencePanelMetricsService;
|
||||
@@ -38,12 +39,14 @@ describe('EvidencePanelMetricsService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
EvidencePanelMetricsService,
|
||||
{ provide: APP_CONFIG, useValue: mockConfig },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(EvidencePanelMetricsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function computeDigest(
|
||||
}
|
||||
|
||||
const data = toUint8(payload);
|
||||
const digestBuffer = await globalThis.crypto.subtle.digest(algorithm, data);
|
||||
const digestBuffer = await globalThis.crypto.subtle.digest(algorithm, data as unknown as ArrayBuffer);
|
||||
const hex = toHex(digestBuffer);
|
||||
const prefix = algorithm.toLowerCase().replace('-', '');
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('Provenance utilities', () => {
|
||||
|
||||
const payloadBytes = new TextEncoder().encode('{"sub":"example"}');
|
||||
const pae = dssePreAuthEncode('application/json', payloadBytes);
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, pae);
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, pae as unknown as ArrayBuffer);
|
||||
const pem = await exportPublicKeyPem(keyPair.publicKey);
|
||||
|
||||
const result = await verifyDsseSignature({
|
||||
|
||||
@@ -57,14 +57,21 @@ function toUint8(payload: string | ArrayBuffer | Uint8Array): Uint8Array {
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Create a fresh ArrayBuffer copy, avoiding cross-realm and SharedArrayBuffer issues. */
|
||||
function toArrayBuffer(input: ArrayBuffer | Uint8Array): ArrayBuffer {
|
||||
if (input instanceof Uint8Array) {
|
||||
const copy = new ArrayBuffer(input.byteLength);
|
||||
new Uint8Array(copy).set(input);
|
||||
return copy;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function normalizeSignature(sig: ArrayBuffer | Uint8Array | string): ArrayBuffer {
|
||||
if (typeof sig === 'string') {
|
||||
return base64ToArrayBuffer(sig);
|
||||
}
|
||||
if (sig instanceof ArrayBuffer) {
|
||||
return sig;
|
||||
}
|
||||
return sig.buffer;
|
||||
return toArrayBuffer(sig);
|
||||
}
|
||||
|
||||
export async function verifyCmsSignature(options: VerifyOptions): Promise<VerificationResult> {
|
||||
@@ -83,7 +90,7 @@ export async function verifyCmsSignature(options: VerifyOptions): Promise<Verifi
|
||||
{ name: algorithm, saltLength: options.saltLength ?? 32 },
|
||||
key,
|
||||
signature,
|
||||
payload
|
||||
toArrayBuffer(payload)
|
||||
);
|
||||
|
||||
return verified
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { AdvisoryApiHttpClient, ADVISORY_API_BASE_URL } from './advisories.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -20,14 +21,16 @@ describe('AdvisoryApiHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AdvisoryApiHttpClient,
|
||||
{ provide: ADVISORY_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(AdvisoryApiHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -58,17 +61,17 @@ describe('AdvisoryApiHttpClient', () => {
|
||||
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
|
||||
});
|
||||
|
||||
it('rejects advisory fetch when scope authorization fails', (done) => {
|
||||
it('rejects advisory fetch when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.getAdvisory('CVE-2024-12345', { traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/advisories/CVE-2024-12345');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
|
||||
describe('AdvisoryAiApiHttpClient', () => {
|
||||
let service: AdvisoryAiApiHttpClient;
|
||||
let httpClientSpy: jasmine.SpyObj<HttpClient>;
|
||||
let authSessionSpy: jasmine.SpyObj<AuthSessionStore>;
|
||||
let httpClientSpy: any;
|
||||
let authSessionSpy: any;
|
||||
|
||||
const mockConsentStatus: AiConsentStatus = {
|
||||
consented: true,
|
||||
@@ -132,23 +132,23 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.get.and.returnValue(throwError(() => new Error('Unauthorized')));
|
||||
|
||||
service.getConsentStatus().subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should use custom traceId when provided', () => {
|
||||
httpClientSpy.get.and.returnValue(of(mockConsentStatus));
|
||||
|
||||
service.getConsentStatus({ traceId: 'custom-trace-123' }).subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('X-Stella-Trace-Id')).toBe('custom-trace-123');
|
||||
});
|
||||
@@ -175,16 +175,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.post.and.returnValue(throwError(() => new Error('Invalid request')));
|
||||
|
||||
service.grantConsent(consentRequest).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('revokeConsent', () => {
|
||||
@@ -199,16 +199,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.delete.and.returnValue(throwError(() => new Error('Not found')));
|
||||
|
||||
service.revokeConsent().subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('explain', () => {
|
||||
@@ -231,16 +231,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.post.and.returnValue(throwError(() => new Error('Rate limited')));
|
||||
|
||||
service.explain(explainRequest).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('remediate', () => {
|
||||
@@ -265,16 +265,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.post.and.returnValue(throwError(() => new Error('Service unavailable')));
|
||||
|
||||
service.remediate(remediateRequest).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('justify', () => {
|
||||
@@ -300,16 +300,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.post.and.returnValue(throwError(() => new Error('Processing error')));
|
||||
|
||||
service.justify(justifyRequest).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getRateLimits', () => {
|
||||
@@ -326,16 +326,16 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error response', (done) => {
|
||||
it('should handle error response', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.get.and.returnValue(throwError(() => new Error('Unauthorized')));
|
||||
|
||||
service.getRateLimits().subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Advisory AI error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Headers', () => {
|
||||
@@ -345,7 +345,7 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
service.getConsentStatus().subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('X-StellaOps-Tenant')).toBe('tenant-xyz');
|
||||
});
|
||||
@@ -355,7 +355,7 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
service.getConsentStatus().subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('X-Stella-Trace-Id')).toBeTruthy();
|
||||
});
|
||||
@@ -365,7 +365,7 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
service.getConsentStatus().subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('X-Stella-Request-Id')).toBeTruthy();
|
||||
});
|
||||
@@ -375,7 +375,7 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
service.getConsentStatus().subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('Accept')).toBe('application/json');
|
||||
});
|
||||
@@ -386,34 +386,34 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
service.getConsentStatus().subscribe();
|
||||
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent().args;
|
||||
const callArgs = httpClientSpy.get.calls.mostRecent()!.args;
|
||||
const headers = callArgs[1]!.headers as HttpHeaders;
|
||||
expect(headers.get('X-StellaOps-Tenant')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error mapping', () => {
|
||||
it('should include traceId in error message', (done) => {
|
||||
it('should include traceId in error message', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.get.and.returnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
service.getConsentStatus({ traceId: 'trace-xyz' }).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('[trace-xyz]');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should handle non-Error objects', (done) => {
|
||||
it('should handle non-Error objects', () => new Promise<void>((resolve, reject) => {
|
||||
httpClientSpy.get.and.returnValue(throwError(() => 'String error'));
|
||||
|
||||
service.getConsentStatus({ traceId: 'trace-abc' }).subscribe({
|
||||
error: (err) => {
|
||||
expect(err.message).toContain('Unknown error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -429,16 +429,16 @@ describe('MockAdvisoryAiClient', () => {
|
||||
});
|
||||
|
||||
describe('getConsentStatus', () => {
|
||||
it('should return initial consent status as not consented', (done) => {
|
||||
it('should return initial consent status as not consented', () => new Promise<void>((resolve, reject) => {
|
||||
mockClient.getConsentStatus().subscribe((result) => {
|
||||
expect(result.consented).toBeFalse();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('grantConsent', () => {
|
||||
it('should update consent status', (done) => {
|
||||
it('should update consent status', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiConsentRequest = {
|
||||
scope: 'explain',
|
||||
sessionLevel: true,
|
||||
@@ -449,12 +449,12 @@ describe('MockAdvisoryAiClient', () => {
|
||||
mockClient.getConsentStatus().subscribe((status) => {
|
||||
expect(status.consented).toBeTrue();
|
||||
expect(status.scope).toBe('explain');
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should return consent response', (done) => {
|
||||
it('should return consent response', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiConsentRequest = {
|
||||
scope: 'all',
|
||||
sessionLevel: false,
|
||||
@@ -464,13 +464,13 @@ describe('MockAdvisoryAiClient', () => {
|
||||
mockClient.grantConsent(request).subscribe((result) => {
|
||||
expect(result.consented).toBeTrue();
|
||||
expect(result.consentedAt).toBeTruthy();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('revokeConsent', () => {
|
||||
it('should reset consent status', (done) => {
|
||||
it('should reset consent status', () => new Promise<void>((resolve, reject) => {
|
||||
// First grant consent
|
||||
mockClient.grantConsent({
|
||||
scope: 'all',
|
||||
@@ -481,15 +481,15 @@ describe('MockAdvisoryAiClient', () => {
|
||||
mockClient.revokeConsent().subscribe(() => {
|
||||
mockClient.getConsentStatus().subscribe((status) => {
|
||||
expect(status.consented).toBeFalse();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('explain', () => {
|
||||
it('should return explanation response', (done) => {
|
||||
it('should return explanation response', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiExplainRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
};
|
||||
@@ -500,11 +500,11 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(result.summary).toContain('CVE-2024-12345');
|
||||
expect(result.impactAssessment).toBeTruthy();
|
||||
expect(result.modelVersion).toBeTruthy();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should include impact assessment', (done) => {
|
||||
it('should include impact assessment', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiExplainRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
};
|
||||
@@ -513,11 +513,11 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(result.impactAssessment.severity).toBeTruthy();
|
||||
expect(result.impactAssessment.cvssScore).toBeGreaterThan(0);
|
||||
expect(result.impactAssessment.attackVector).toBeTruthy();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should include affected versions', (done) => {
|
||||
it('should include affected versions', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiExplainRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
};
|
||||
@@ -526,13 +526,13 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(result.affectedVersions).toBeTruthy();
|
||||
expect(result.affectedVersions.vulnerableRange).toBeTruthy();
|
||||
expect(result.affectedVersions.fixedVersion).toBeTruthy();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('remediate', () => {
|
||||
it('should return remediation response', (done) => {
|
||||
it('should return remediation response', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiRemediateRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'lodash',
|
||||
@@ -544,11 +544,11 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(result.cveId).toBe('CVE-2024-12345');
|
||||
expect(result.remediationId).toBeTruthy();
|
||||
expect(result.recommendations.length).toBeGreaterThan(0);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should include recommendations with commands', (done) => {
|
||||
it('should include recommendations with commands', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiRemediateRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'lodash',
|
||||
@@ -560,13 +560,13 @@ describe('MockAdvisoryAiClient', () => {
|
||||
const upgradeRec = result.recommendations.find((r) => r.action === 'upgrade');
|
||||
expect(upgradeRec).toBeTruthy();
|
||||
expect(upgradeRec!.command).toContain('lodash');
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('justify', () => {
|
||||
it('should return justification response', (done) => {
|
||||
it('should return justification response', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiJustifyRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
@@ -579,11 +579,11 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(result.draftJustification).toBeTruthy();
|
||||
expect(result.suggestedJustificationType).toBeTruthy();
|
||||
expect(result.confidenceScore).toBeGreaterThan(0);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should include evidence suggestions', (done) => {
|
||||
it('should include evidence suggestions', () => new Promise<void>((resolve, reject) => {
|
||||
const request: AiJustifyRequest = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
@@ -594,13 +594,13 @@ describe('MockAdvisoryAiClient', () => {
|
||||
mockClient.justify(request).subscribe((result) => {
|
||||
expect(result.evidenceSuggestions).toBeTruthy();
|
||||
expect(result.evidenceSuggestions.length).toBeGreaterThan(0);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('getRateLimits', () => {
|
||||
it('should return rate limit information', (done) => {
|
||||
it('should return rate limit information', () => new Promise<void>((resolve, reject) => {
|
||||
mockClient.getRateLimits().subscribe((result) => {
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -608,18 +608,18 @@ describe('MockAdvisoryAiClient', () => {
|
||||
expect(explainLimit).toBeTruthy();
|
||||
expect(explainLimit!.limit).toBeGreaterThan(0);
|
||||
expect(explainLimit!.remaining).toBeLessThanOrEqual(explainLimit!.limit);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should include all features', (done) => {
|
||||
it('should include all features', () => new Promise<void>((resolve, reject) => {
|
||||
mockClient.getRateLimits().subscribe((result) => {
|
||||
const features = result.map((r) => r.feature);
|
||||
expect(features).toContain('explain');
|
||||
expect(features).toContain('remediate');
|
||||
expect(features).toContain('justify');
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { AdvisoryAiApiHttpClient, ADVISORY_AI_API_BASE_URL } from './advisory-ai.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -16,13 +17,15 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AdvisoryAiApiHttpClient,
|
||||
{ provide: ADVISORY_AI_API_BASE_URL, useValue: '/api/v1/advisory-ai' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(AdvisoryAiApiHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { AnalyticsHttpClient } from './analytics.client';
|
||||
import { PlatformListResponse } from './analytics.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -16,12 +17,14 @@ describe('AnalyticsHttpClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AnalyticsHttpClient,
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(AnalyticsHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -55,17 +58,17 @@ describe('AnalyticsHttpClient', () => {
|
||||
req.flush(response);
|
||||
});
|
||||
|
||||
it('maps error responses with trace context', (done) => {
|
||||
it('maps error responses with trace context', () => new Promise<void>((resolve, reject) => {
|
||||
client.getLicenses(null, { traceId: 'trace-error' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('trace-error');
|
||||
expect(String(err)).toContain('Analytics error');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/analytics/licenses');
|
||||
req.flush({ detail: 'not ready' }, { status: 503, statusText: 'Unavailable' });
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EVENT_SOURCE_FACTORY,
|
||||
} from './console-status.client';
|
||||
import { ConsoleExportRequest } from './console-export.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('ConsoleExportClient', () => {
|
||||
let client: ConsoleExportClient;
|
||||
@@ -26,19 +27,21 @@ describe('ConsoleExportClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
ConsoleExportClient,
|
||||
{ provide: CONSOLE_API_BASE_URL, useValue: baseUrl },
|
||||
{ provide: EVENT_SOURCE_FACTORY, useValue: DEFAULT_EVENT_SOURCE_FACTORY },
|
||||
{
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
getActiveTenantId: () => 'tenant-default',
|
||||
} satisfies Partial<AuthSessionStore>,
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
getActiveTenantId: () => 'tenant-default',
|
||||
} satisfies Partial<AuthSessionStore>,
|
||||
},
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(ConsoleExportClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleStatusClient, CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY } from './console-status.client';
|
||||
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -44,14 +45,16 @@ describe('ConsoleStatusClient', () => {
|
||||
eventSourceFactory = jasmine.createSpy('eventSourceFactory').and.callFake((url: string) => new FakeEventSource(url) as unknown as EventSource);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
ConsoleStatusClient,
|
||||
{ provide: CONSOLE_API_BASE_URL, useValue: '/console' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
client = TestBed.inject(ConsoleStatusClient);
|
||||
@@ -91,12 +94,12 @@ describe('ConsoleStatusClient', () => {
|
||||
const subscription = client.streamRun('run-123').subscribe((evt) => events.push(evt));
|
||||
|
||||
expect(eventSourceFactory).toHaveBeenCalled();
|
||||
const url = eventSourceFactory.calls.mostRecent().args[0];
|
||||
const url = eventSourceFactory.calls.mostRecent()!.args[0];
|
||||
expect(url).toContain('/console/runs/run-123/stream?tenant=tenant-dev');
|
||||
expect(url).toContain('traceId=');
|
||||
|
||||
// Simulate incoming message
|
||||
const fakeSource = eventSourceFactory.calls.mostRecent().returnValue as unknown as FakeEventSource;
|
||||
const fakeSource = eventSourceFactory.calls.mostRecent()!.returnValue as unknown as FakeEventSource;
|
||||
const message = { data: JSON.stringify({ runId: 'run-123', kind: 'progress', progressPercent: 50, updatedAt: '2025-12-01T00:00:00Z' }) } as MessageEvent;
|
||||
fakeSource.onmessage?.call(fakeSource as unknown as EventSource, message);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { CvssClient, CVSS_API_BASE_URL } from './cvss.client';
|
||||
import { CvssReceipt, CvssReceiptDto } from './cvss.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -17,13 +18,15 @@ describe('CvssClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
CvssClient,
|
||||
{ provide: CVSS_API_BASE_URL, useValue: '/api/cvss' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
client = TestBed.inject(CvssClient);
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('ExceptionEventsHttpClient', () => {
|
||||
client = TestBed.inject(ExceptionEventsHttpClient);
|
||||
});
|
||||
|
||||
it('creates an EventSource for the tenant and parses JSON events', (done) => {
|
||||
it('creates an EventSource for the tenant and parses JSON events', () => new Promise<void>((resolve, reject) => {
|
||||
const fakeSource: Partial<EventSource> = {
|
||||
close: jasmine.createSpy('close'),
|
||||
};
|
||||
@@ -44,9 +44,9 @@ describe('ExceptionEventsHttpClient', () => {
|
||||
next: (event) => {
|
||||
expect(event.type).toBe('exception.created');
|
||||
expect(event.tenantId).toBe('tenant-x');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
error: (err) => done.fail(err),
|
||||
error: (err) => reject(new Error(err)),
|
||||
});
|
||||
|
||||
expect(eventSourceFactory).toHaveBeenCalledWith('/api/exceptions/events?tenant=tenant-x&traceId=trace-1');
|
||||
@@ -59,18 +59,18 @@ describe('ExceptionEventsHttpClient', () => {
|
||||
timestamp: '2025-12-10T00:00:00Z',
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
}));
|
||||
|
||||
it('rejects stream when scope authorization fails', (done) => {
|
||||
it('rejects stream when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.streamEvents({ traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
expect(eventSourceFactory).not.toHaveBeenCalled();
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { ExceptionApiHttpClient, EXCEPTION_API_BASE_URL } from './exception.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -20,14 +21,16 @@ describe('ExceptionApiHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
ExceptionApiHttpClient,
|
||||
{ provide: EXCEPTION_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(ExceptionApiHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -49,17 +52,17 @@ describe('ExceptionApiHttpClient', () => {
|
||||
req.flush({ items: [], count: 0, continuationToken: null });
|
||||
});
|
||||
|
||||
it('rejects stats request when scope authorization fails', (done) => {
|
||||
it('rejects stats request when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.getStats({ traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/exceptions/stats');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import type { FirstSignalApi } from './first-signal.client';
|
||||
import { FIRST_SIGNAL_API } from './first-signal.client';
|
||||
@@ -7,10 +8,13 @@ import { FirstSignalStore } from './first-signal.store';
|
||||
|
||||
describe('FirstSignalStore', () => {
|
||||
let store: FirstSignalStore;
|
||||
let api: jasmine.SpyObj<FirstSignalApi>;
|
||||
let api: any;
|
||||
|
||||
beforeEach(() => {
|
||||
api = jasmine.createSpyObj<FirstSignalApi>('FirstSignalApi', ['getFirstSignal', 'streamFirstSignal']);
|
||||
api = {
|
||||
getFirstSignal: vi.fn(),
|
||||
streamFirstSignal: vi.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FirstSignalStore, { provide: FIRST_SIGNAL_API, useValue: api }],
|
||||
@@ -21,10 +25,11 @@ describe('FirstSignalStore', () => {
|
||||
|
||||
afterEach(() => {
|
||||
store.disconnect();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stores response when loaded', () => {
|
||||
api.getFirstSignal.and.returnValue(
|
||||
api.getFirstSignal.mockReturnValue(
|
||||
of({
|
||||
response: {
|
||||
runId: 'run-1',
|
||||
@@ -43,14 +48,16 @@ describe('FirstSignalStore', () => {
|
||||
store.load('run-1');
|
||||
|
||||
expect(store.state()).toBe('loaded');
|
||||
expect(store.hasSignal()).toBeTrue();
|
||||
expect(store.hasSignal()).toBe(true);
|
||||
expect(store.firstSignal()?.message).toBe('hello');
|
||||
expect(store.etag()).toBe('"etag-1"');
|
||||
});
|
||||
|
||||
it('falls back to polling when SSE errors', fakeAsync(() => {
|
||||
api.streamFirstSignal.and.returnValue(throwError(() => new Error('boom')));
|
||||
api.getFirstSignal.and.returnValue(
|
||||
it('falls back to polling when SSE errors', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
api.streamFirstSignal.mockReturnValue(throwError(() => new Error('boom')));
|
||||
api.getFirstSignal.mockReturnValue(
|
||||
of({
|
||||
response: {
|
||||
runId: 'run-2',
|
||||
@@ -66,12 +73,12 @@ describe('FirstSignalStore', () => {
|
||||
|
||||
expect(store.realtimeMode()).toBe('polling');
|
||||
|
||||
tick(999);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(api.getFirstSignal).not.toHaveBeenCalled();
|
||||
|
||||
tick(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(api.getFirstSignal).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.disconnect();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
|
||||
import { MockOrchestratorControlClient, OrchestratorControlHttpClient } from './orchestrator-control.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -21,14 +22,16 @@ describe('OrchestratorControlHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
OrchestratorControlHttpClient,
|
||||
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(OrchestratorControlHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -107,33 +110,33 @@ describe('OrchestratorControlHttpClient', () => {
|
||||
req.flush({ success: true, newJobId: 'job-1', errorMessage: null, updatedEntry: null, traceId: 'trace-3' });
|
||||
});
|
||||
|
||||
it('rejects quota listing when scope authorization fails', (done) => {
|
||||
it('rejects quota listing when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.listQuotas({ traceId: 'trace-4' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/orchestrator/quotas');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('rejects quotas listing when limit exceeds max page size', (done) => {
|
||||
it('rejects quotas listing when limit exceeds max page size', () => new Promise<void>((resolve, reject) => {
|
||||
client.listQuotas({ traceId: 'trace-5', limit: 500 }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Invalid limit');
|
||||
httpMock.expectNone('/api/orchestrator/quotas');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('MockOrchestratorControlClient', () => {
|
||||
it('pauses quotas deterministically and persists the update', (done) => {
|
||||
it('pauses quotas deterministically and persists the update', () => new Promise<void>((resolve, reject) => {
|
||||
const mock = new MockOrchestratorControlClient();
|
||||
|
||||
mock
|
||||
@@ -149,13 +152,13 @@ describe('MockOrchestratorControlClient', () => {
|
||||
expect(stored.paused).toBe(true);
|
||||
expect(stored.pauseReason).toBe('Pause requested');
|
||||
expect(stored.quotaTicket).toBe('OPS-9');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
error: (err: unknown) => done.fail(String(err)),
|
||||
error: (err: unknown) => reject(new Error(String(err))),
|
||||
});
|
||||
},
|
||||
error: (err: unknown) => done.fail(String(err)),
|
||||
error: (err: unknown) => reject(new Error(String(err))),
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { OrchestratorHttpClient, ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -20,14 +21,16 @@ describe('OrchestratorHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
OrchestratorHttpClient,
|
||||
{ provide: ORCHESTRATOR_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(OrchestratorHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -59,17 +62,17 @@ describe('OrchestratorHttpClient', () => {
|
||||
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
|
||||
});
|
||||
|
||||
it('rejects orchestrator source fetch when scope authorization fails', (done) => {
|
||||
it('rejects orchestrator source fetch when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.getSource('11111111-1111-1111-1111-111111111111', { traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/orchestrator/sources/11111111-1111-1111-1111-111111111111');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import { VEX_EVIDENCE_API, type VexEvidenceApi } from './vex-evidence.client';
|
||||
|
||||
describe('PolicyEvidenceCompositeClient', () => {
|
||||
let client: PolicyEvidenceCompositeClient;
|
||||
let policyApi: jasmine.SpyObj<PolicyExceptionsApi>;
|
||||
let advisoryApi: jasmine.SpyObj<AdvisoryApi>;
|
||||
let vexApi: jasmine.SpyObj<VexEvidenceApi>;
|
||||
let policyApi: any;
|
||||
let advisoryApi: any;
|
||||
let vexApi: any;
|
||||
|
||||
beforeEach(() => {
|
||||
policyApi = jasmine.createSpyObj<PolicyExceptionsApi>('PolicyExceptionsApi', ['getEffective', 'simulate']);
|
||||
advisoryApi = jasmine.createSpyObj<AdvisoryApi>('AdvisoryApi', ['listAdvisories', 'getAdvisory']);
|
||||
vexApi = jasmine.createSpyObj<VexEvidenceApi>('VexEvidenceApi', ['listStatements', 'getStatement', 'getEvidence', 'exportStatement']);
|
||||
policyApi = jasmine.createSpyObj('PolicyExceptionsApi', ['getEffective', 'simulate']);
|
||||
advisoryApi = jasmine.createSpyObj('AdvisoryApi', ['listAdvisories', 'getAdvisory']);
|
||||
vexApi = jasmine.createSpyObj('VexEvidenceApi', ['listStatements', 'getStatement', 'getEvidence', 'exportStatement']);
|
||||
|
||||
policyApi.getEffective.and.returnValue(
|
||||
of({
|
||||
@@ -93,7 +93,7 @@ describe('PolicyEvidenceCompositeClient', () => {
|
||||
client = TestBed.inject(PolicyEvidenceCompositeClient);
|
||||
});
|
||||
|
||||
it('builds deterministic linksets and forwards trace ids', (done) => {
|
||||
it('builds deterministic linksets and forwards trace ids', () => new Promise<void>((resolve, reject) => {
|
||||
client
|
||||
.getComponentEvidence(
|
||||
{
|
||||
@@ -132,20 +132,20 @@ describe('PolicyEvidenceCompositeClient', () => {
|
||||
traceId: 'trace-1',
|
||||
})
|
||||
);
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
error: (err: unknown) => done.fail(String(err)),
|
||||
error: (err: unknown) => reject(new Error(String(err))),
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('rejects empty requests', (done) => {
|
||||
it('rejects empty requests', () => new Promise<void>((resolve, reject) => {
|
||||
client.getComponentEvidence({ findings: [] }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('at least one finding');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { PolicyExceptionsHttpClient, POLICY_EXCEPTIONS_API_BASE_URL } from './policy-exceptions.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -20,14 +21,16 @@ describe('PolicyExceptionsHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
PolicyExceptionsHttpClient,
|
||||
{ provide: POLICY_EXCEPTIONS_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(PolicyExceptionsHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -52,17 +55,17 @@ describe('PolicyExceptionsHttpClient', () => {
|
||||
req.flush({ policyVersion: 'sha256:test', items: [], continuationToken: null, traceId: 'trace-1' });
|
||||
});
|
||||
|
||||
it('rejects simulate request when scope authorization fails', (done) => {
|
||||
it('rejects simulate request when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.returnValue(false);
|
||||
|
||||
client.simulate({ findings: [{ findingId: 'finding-1' }] }, { traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/policy/simulate');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
ConflictDetectionQueryOptions,
|
||||
BatchEvaluationInput,
|
||||
} from './policy-simulation.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('PolicySimulationHttpClient', () => {
|
||||
let httpClient: PolicySimulationHttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let authSessionStoreMock: jasmine.SpyObj<AuthSessionStore>;
|
||||
let tenantServiceMock: jasmine.SpyObj<TenantActivationService>;
|
||||
let authSessionStoreMock: any;
|
||||
let tenantServiceMock: any;
|
||||
const baseUrl = 'https://api.stellaops.io/v1';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -33,14 +34,16 @@ describe('PolicySimulationHttpClient', () => {
|
||||
authSessionStoreMock.getActiveTenantId.and.returnValue('tenant-001');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
PolicySimulationHttpClient,
|
||||
{ provide: AuthSessionStore, useValue: authSessionStoreMock },
|
||||
{ provide: TenantActivationService, useValue: tenantServiceMock },
|
||||
{ provide: POLICY_SIMULATION_API_BASE_URL, useValue: baseUrl },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
httpClient = TestBed.inject(PolicySimulationHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -834,7 +837,7 @@ describe('MockPolicySimulationService', () => {
|
||||
it('should throw error for non-existent exception', async () => {
|
||||
try {
|
||||
await firstValueFrom(service.getException('non-existent'));
|
||||
fail('Expected error to be thrown');
|
||||
throw new Error('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
|
||||
@@ -11,30 +11,30 @@ describe('MockRiskApi', () => {
|
||||
expect(() => api.list({ tenantId: '' })).toThrowError(/tenantId is required/);
|
||||
});
|
||||
|
||||
it('returns deterministic ordering by score then id', (done) => {
|
||||
it('returns deterministic ordering by score then id', () => new Promise<void>((resolve) => {
|
||||
api.list({ tenantId: 'acme-tenant', pageSize: 10 }).subscribe((page) => {
|
||||
const scores = page.items.map((r) => r.score);
|
||||
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('filters by project and severity', (done) => {
|
||||
it('filters by project and severity', () => new Promise<void>((resolve) => {
|
||||
api
|
||||
.list({ tenantId: 'acme-tenant', projectId: 'proj-ops', severity: 'high' })
|
||||
.subscribe((page) => {
|
||||
expect(page.items.every((r) => r.projectId === 'proj-ops')).toBeTrue();
|
||||
expect(page.items.every((r) => r.severity === 'high')).toBeTrue();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('computes stats with zeroed severities present', (done) => {
|
||||
it('computes stats with zeroed severities present', () => new Promise<void>((resolve) => {
|
||||
api.stats({ tenantId: 'acme-tenant' }).subscribe((stats) => {
|
||||
expect(stats.countsBySeverity.none).toBe(0);
|
||||
expect(stats.countsBySeverity.critical).toBeGreaterThan(0);
|
||||
expect(stats.lastComputation).toMatch(/T/);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import {
|
||||
TriageEvidenceHttpClient,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
isVexNotAffected,
|
||||
isVexValid,
|
||||
} from './triage-evidence.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('TriageEvidenceHttpClient', () => {
|
||||
let client: TriageEvidenceHttpClient;
|
||||
@@ -26,9 +27,9 @@ describe('TriageEvidenceHttpClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [TriageEvidenceHttpClient],
|
||||
});
|
||||
imports: [],
|
||||
providers: [TriageEvidenceHttpClient, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]
|
||||
});
|
||||
|
||||
client = TestBed.inject(TriageEvidenceHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -171,24 +172,24 @@ describe('TriageEvidenceMockClient', () => {
|
||||
client = new TriageEvidenceMockClient();
|
||||
});
|
||||
|
||||
it('should return mock evidence', (done) => {
|
||||
it('should return mock evidence', () => new Promise<void>((resolve) => {
|
||||
client.getFindingEvidence('test-finding').subscribe((result) => {
|
||||
expect(result.finding_id).toBe('test-finding');
|
||||
expect(result.cve).toBe('CVE-2021-44228');
|
||||
expect(result.component).toBeDefined();
|
||||
expect(result.score_explain).toBeDefined();
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('should return mock list response', (done) => {
|
||||
it('should return mock list response', () => new Promise<void>((resolve) => {
|
||||
client.list({ page: 1, page_size: 10 }).subscribe((result) => {
|
||||
expect(result.items.length).toBeGreaterThan(0);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.page_size).toBe(10);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Triage Evidence Model Helpers', () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { VexEvidenceHttpClient, VEX_EVIDENCE_API_BASE_URL } from './vex-evidence.client';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -20,14 +21,16 @@ describe('VexEvidenceHttpClient', () => {
|
||||
tenantService = { authorize: jasmine.createSpy('authorize').and.returnValue(true) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
VexEvidenceHttpClient,
|
||||
{ provide: VEX_EVIDENCE_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantService as unknown as TenantActivationService },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(VexEvidenceHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
@@ -58,30 +61,30 @@ describe('VexEvidenceHttpClient', () => {
|
||||
req.flush({ items: [], count: 0, continuationToken: null, etag: '"etag-2"', traceId: 'trace-1' });
|
||||
});
|
||||
|
||||
it('maps 404 responses to ERR_AGG_NOT_FOUND', (done) => {
|
||||
it('maps 404 responses to ERR_AGG_NOT_FOUND', () => new Promise<void>((resolve, reject) => {
|
||||
client.getStatement('missing', { traceId: 'trace-2' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('ERR_AGG_NOT_FOUND');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/vex/statements/missing');
|
||||
req.flush({ message: 'not found' }, { status: 404, statusText: 'Not Found' });
|
||||
});
|
||||
}));
|
||||
|
||||
it('rejects export when scope authorization fails', (done) => {
|
||||
it('rejects export when scope authorization fails', () => new Promise<void>((resolve, reject) => {
|
||||
tenantService.authorize.and.callFake((_resource: string, action: string) => action !== 'export');
|
||||
|
||||
client.exportStatement('stmt-1', 'json', { traceId: 'trace-3' }).subscribe({
|
||||
next: () => done.fail('expected error'),
|
||||
next: () => reject(new Error('expected error')),
|
||||
error: (err: unknown) => {
|
||||
expect(String(err)).toContain('Unauthorized');
|
||||
httpMock.expectNone('/api/vex/statements/stmt-1/export');
|
||||
done();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { VulnerabilityHttpClient, VULNERABILITY_API_BASE_URL } from './vulnerability-http.client';
|
||||
import { VulnerabilitiesResponse } from './vulnerability.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class MockAuthSessionStore {
|
||||
session(): any {
|
||||
@@ -26,14 +27,16 @@ describe('VulnerabilityHttpClient', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
VulnerabilityHttpClient,
|
||||
{ provide: VULNERABILITY_API_BASE_URL, useValue: 'https://api.example.local' },
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
{ provide: TenantActivationService, useValue: tenantServiceStub },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
client = TestBed.inject(VulnerabilityHttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, firstValueFrom, from, throwError } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.model';
|
||||
import { DPoPAlgorithm } from '../../config/app-config.model';
|
||||
import { AppConfigService } from '../../config/app-config.service';
|
||||
import { base64UrlDecode } from './jose-utilities';
|
||||
import { DpopKeyStore } from './dpop-key-store';
|
||||
import { base64UrlDecode, computeJwkThumbprint } from './jose-utilities';
|
||||
import { DpopKeyStore, LoadedDpopKeyPair } from './dpop-key-store';
|
||||
import { DpopService } from './dpop.service';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
/**
|
||||
* In-memory DpopKeyStore replacement that avoids IndexedDB (unavailable in jsdom/happy-dom).
|
||||
* Uses real Web Crypto operations so JWT structure tests remain valid.
|
||||
*/
|
||||
class InMemoryDpopKeyStore {
|
||||
private stored: LoadedDpopKeyPair | null = null;
|
||||
|
||||
async load(): Promise<LoadedDpopKeyPair | null> {
|
||||
return this.stored;
|
||||
}
|
||||
|
||||
async save(keyPair: CryptoKeyPair, algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const thumbprint = await computeJwkThumbprint(publicJwk);
|
||||
const result: LoadedDpopKeyPair = {
|
||||
algorithm,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
publicJwk,
|
||||
thumbprint,
|
||||
};
|
||||
this.stored = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async generate(algorithm: DPoPAlgorithm): Promise<LoadedDpopKeyPair> {
|
||||
const algo: EcKeyImportParams = algorithm === 'ES384'
|
||||
? { name: 'ECDSA', namedCurve: 'P-384' }
|
||||
: { name: 'ECDSA', namedCurve: 'P-256' };
|
||||
const keyPair = await crypto.subtle.generateKey(algo, true, ['sign', 'verify']);
|
||||
return this.save(keyPair, algorithm);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.stored = null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('DpopService', () => {
|
||||
const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
|
||||
const config: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.stellaops.test/',
|
||||
@@ -29,29 +68,20 @@ describe('DpopService', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
DpopKeyStore,
|
||||
{ provide: DpopKeyStore, useClass: InMemoryDpopKeyStore },
|
||||
DpopService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
provide: APP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
|
||||
const store = TestBed.inject(DpopKeyStore);
|
||||
try {
|
||||
await store.clear();
|
||||
} catch {
|
||||
// ignore cleanup issues in test environment
|
||||
}
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a DPoP proof with expected header values', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
export async function sha256(data: Uint8Array<ArrayBufferLike>): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', data as Uint8Array<ArrayBuffer>);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
@@ -86,14 +86,14 @@ export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for r.');
|
||||
}
|
||||
const rLength = bytes[offset + 1];
|
||||
let r = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
let r: Uint8Array<ArrayBufferLike> = bytes.slice(offset + 2, offset + 2 + rLength);
|
||||
offset = offset + 2 + rLength;
|
||||
|
||||
if (bytes[offset] !== 0x02) {
|
||||
throw new Error('Invalid DER signature: expected INTEGER for s.');
|
||||
}
|
||||
const sLength = bytes[offset + 1];
|
||||
let s = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
let s: Uint8Array<ArrayBufferLike> = bytes.slice(offset + 2, offset + 2 + sLength);
|
||||
|
||||
r = trimLeadingZeros(r);
|
||||
s = trimLeadingZeros(s);
|
||||
@@ -105,7 +105,7 @@ export function derToJoseSignature(der: ArrayBuffer): Uint8Array {
|
||||
return signature;
|
||||
}
|
||||
|
||||
function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
function trimLeadingZeros(bytes: Uint8Array<ArrayBufferLike>): Uint8Array<ArrayBufferLike> {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0x00) {
|
||||
start += 1;
|
||||
@@ -113,7 +113,7 @@ function trimLeadingZeros(bytes: Uint8Array): Uint8Array {
|
||||
return bytes.subarray(start);
|
||||
}
|
||||
|
||||
function padStart(bytes: Uint8Array, length: number): Uint8Array {
|
||||
function padStart(bytes: Uint8Array<ArrayBufferLike>, length: number): Uint8Array {
|
||||
if (bytes.length >= length) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpErrorResponse,
|
||||
} from '@angular/common/http';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
@@ -29,7 +29,7 @@ export class BrandingService {
|
||||
// Default branding configuration
|
||||
private readonly defaultBranding: BrandingConfiguration = {
|
||||
tenantId: 'default',
|
||||
title: 'StellaOps Dashboard',
|
||||
title: 'Stella Ops Dashboard',
|
||||
themeTokens: {}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Lifecycle status of configuration resolution.
|
||||
* - `pending` – load() has not completed yet
|
||||
* - `loaded` – a valid AppConfig was fetched and applied
|
||||
* - `missing` – all resolution attempts failed (no config found)
|
||||
* - `error` – a config URL was reachable but returned invalid data
|
||||
*/
|
||||
export type ConfigStatus = 'pending' | 'loaded' | 'missing' | 'error';
|
||||
|
||||
export type DPoPAlgorithm = 'ES256' | 'ES384' | 'EdDSA';
|
||||
|
||||
export interface AuthorityConfig {
|
||||
@@ -63,7 +72,9 @@ export interface AppConfig {
|
||||
readonly apiBaseUrls: ApiBaseUrlConfig;
|
||||
readonly telemetry?: TelemetryConfig;
|
||||
/**
|
||||
* Enables quickstart banner and relaxed UX defaults for demos.
|
||||
* @deprecated Quickstart mode is removed. This field is kept for one release
|
||||
* cycle to avoid breaking deserialization of existing config files.
|
||||
* It is ignored at runtime.
|
||||
*/
|
||||
readonly quickstartMode?: boolean;
|
||||
/**
|
||||
@@ -71,6 +82,13 @@ export interface AppConfig {
|
||||
*/
|
||||
readonly welcome?: WelcomeConfig;
|
||||
readonly doctor?: DoctorConfig;
|
||||
/**
|
||||
* Setup state from Platform.
|
||||
* - `undefined`/absent: setup required (fresh install)
|
||||
* - `"complete"`: setup done, proceed normally
|
||||
* - `"<stepId>"`: setup in progress, resume at this step
|
||||
*/
|
||||
readonly setup?: string;
|
||||
}
|
||||
|
||||
export const APP_CONFIG = new InjectionToken<AppConfig>('STELLAOPS_APP_CONFIG');
|
||||
|
||||
@@ -12,13 +12,18 @@ import {
|
||||
APP_CONFIG,
|
||||
AppConfig,
|
||||
AuthorityConfig,
|
||||
ConfigStatus,
|
||||
DPoPAlgorithm,
|
||||
} from './app-config.model';
|
||||
|
||||
const DEFAULT_CONFIG_URL = '/config.json';
|
||||
const PLATFORM_ENV_SETTINGS_PATH = '/platform/envsettings.json';
|
||||
const LEGACY_CONFIG_PATH = '/config.json';
|
||||
const ENV_SETTINGS_COOKIE = 'stellaops_env_settings_url';
|
||||
const ENV_SETTINGS_QUERY_PARAM = 'envSettings';
|
||||
const COOKIE_MAX_AGE_DAYS = 30;
|
||||
|
||||
const DEFAULT_DPOP_ALG: DPoPAlgorithm = 'ES256';
|
||||
const DEFAULT_REFRESH_LEEWAY_SECONDS = 60;
|
||||
const DEFAULT_QUICKSTART = false;
|
||||
const DEFAULT_TELEMETRY_SAMPLE_RATE = 0;
|
||||
const DEFAULT_DOCTOR_FIX_ENABLED = false;
|
||||
|
||||
@@ -33,6 +38,15 @@ export class AppConfigService {
|
||||
return config?.authority ?? null;
|
||||
});
|
||||
|
||||
/** Current status of configuration resolution. */
|
||||
readonly configStatus = signal<ConfigStatus>('pending');
|
||||
|
||||
/** Whether a valid configuration has been loaded. */
|
||||
readonly isConfigured = computed(() => this.configStatus() === 'loaded');
|
||||
|
||||
/** Human-readable error detail when configStatus is 'error'. */
|
||||
readonly configError = signal<string | null>(null);
|
||||
|
||||
constructor(
|
||||
httpBackend: HttpBackend,
|
||||
@Optional() @Inject(APP_CONFIG) private readonly staticConfig: AppConfig | null
|
||||
@@ -43,16 +57,74 @@ export class AppConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads application configuration either from the injected static value or via HTTP fetch.
|
||||
* Must be called during application bootstrap (see APP_INITIALIZER wiring).
|
||||
* Loads application configuration using the following resolution chain:
|
||||
* 1. ?envSettings=<url> query param → store in cookie, fetch from URL
|
||||
* 2. Cookie stellaops_env_settings_url → fetch from cookie URL
|
||||
* 3. <origin>/platform/envsettings.json
|
||||
* 4. /config.json (legacy fallback with deprecation warning)
|
||||
* 5. All fail → configStatus = 'missing'
|
||||
*
|
||||
* Never throws — sets configStatus signal instead.
|
||||
*/
|
||||
async load(configUrl: string = DEFAULT_CONFIG_URL): Promise<void> {
|
||||
async load(): Promise<void> {
|
||||
if (this.configSignal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.staticConfig ?? (await this.fetchConfig(configUrl));
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
// Static config injection (tests, SSR)
|
||||
if (this.staticConfig) {
|
||||
this.configSignal.set(this.normalizeConfig(this.staticConfig));
|
||||
this.configStatus.set('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Query param override
|
||||
const queryParamUrl = this.getEnvSettingsUrlFromQueryParam();
|
||||
if (queryParamUrl) {
|
||||
this.setEnvSettingsCookie(queryParamUrl);
|
||||
const config = await this.tryFetchConfig(queryParamUrl);
|
||||
if (config) {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
this.configStatus.set('loaded');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Cookie
|
||||
const cookieUrl = this.getEnvSettingsUrlFromCookie();
|
||||
if (cookieUrl && cookieUrl !== queryParamUrl) {
|
||||
const config = await this.tryFetchConfig(cookieUrl);
|
||||
if (config) {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
this.configStatus.set('loaded');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Platform canonical path
|
||||
const platformUrl = `${window.location.origin}${PLATFORM_ENV_SETTINGS_PATH}`;
|
||||
const platformConfig = await this.tryFetchConfig(platformUrl);
|
||||
if (platformConfig) {
|
||||
this.configSignal.set(this.normalizeConfig(platformConfig));
|
||||
this.configStatus.set('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Legacy /config.json fallback (one release cycle)
|
||||
const legacyConfig = await this.tryFetchConfig(LEGACY_CONFIG_PATH);
|
||||
if (legacyConfig) {
|
||||
console.warn(
|
||||
'[StellaOps] Loading configuration from /config.json is deprecated. ' +
|
||||
'Serve your configuration at /platform/envsettings.json instead. ' +
|
||||
'This fallback will be removed in a future release.'
|
||||
);
|
||||
this.configSignal.set(this.normalizeConfig(legacyConfig));
|
||||
this.configStatus.set('loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: All sources failed
|
||||
this.configStatus.set('missing');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +132,43 @@ export class AppConfigService {
|
||||
*/
|
||||
setConfigForTesting(config: AppConfig): void {
|
||||
this.configSignal.set(this.normalizeConfig(config));
|
||||
this.configStatus.set('loaded');
|
||||
this.configError.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a raw config object at runtime (used by setup screen).
|
||||
* Validates, normalizes, and sets the config signal.
|
||||
* Throws if config is missing required fields.
|
||||
*/
|
||||
applyConfig(raw: AppConfig): void {
|
||||
if (!raw?.authority || !raw?.apiBaseUrls) {
|
||||
throw new Error('Invalid config: missing required fields (authority, apiBaseUrls).');
|
||||
}
|
||||
this.configSignal.set(this.normalizeConfig(raw));
|
||||
this.configStatus.set('loaded');
|
||||
this.configError.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches config JSON from an arbitrary URL. Used by the setup screen
|
||||
* to let the user point at their environment-settings endpoint.
|
||||
* Throws on network error or if the response is missing required fields.
|
||||
*/
|
||||
async fetchConfigFromUrl(url: string): Promise<AppConfig> {
|
||||
if (!this.validateConfigUrl(url)) {
|
||||
throw new Error('Invalid URL: must use http: or https: scheme.');
|
||||
}
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(url, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
if (!response?.authority || !response?.apiBaseUrls) {
|
||||
throw new Error('Config is missing required fields (authority, apiBaseUrls).');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
get config(): AppConfig {
|
||||
@@ -78,14 +187,81 @@ export class AppConfigService {
|
||||
return authority;
|
||||
}
|
||||
|
||||
private async fetchConfig(configUrl: string): Promise<AppConfig> {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(configUrl, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
return response;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getEnvSettingsUrlFromQueryParam(): string | null {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const url = params.get(ENV_SETTINGS_QUERY_PARAM);
|
||||
if (!url) return null;
|
||||
return this.validateConfigUrl(url) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getEnvSettingsUrlFromCookie(): string | null {
|
||||
try {
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith(`${ENV_SETTINGS_COOKIE}=`));
|
||||
if (!match) return null;
|
||||
const url = decodeURIComponent(match.split('=').slice(1).join('='));
|
||||
return this.validateConfigUrl(url) ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setEnvSettingsCookie(url: string): void {
|
||||
try {
|
||||
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie =
|
||||
`${ENV_SETTINGS_COOKIE}=${encodeURIComponent(url)}` +
|
||||
`; Path=/; SameSite=Strict; Max-Age=${maxAge}${secure}`;
|
||||
} catch {
|
||||
// Cookie write failures are non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL string uses only http: or https: scheme.
|
||||
* Prevents javascript:, data:, or other URI injection.
|
||||
*/
|
||||
private validateConfigUrl(url: string): boolean {
|
||||
try {
|
||||
// For relative URLs, they're safe by definition (same-origin)
|
||||
if (url.startsWith('/')) return true;
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryFetchConfig(url: string): Promise<AppConfig | null> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<AppConfig>(url, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
withCredentials: false,
|
||||
})
|
||||
);
|
||||
// Basic validation: must have authority and apiBaseUrls
|
||||
if (response && response.authority && response.apiBaseUrls) {
|
||||
return response;
|
||||
}
|
||||
this.configError.set(`Config at ${url} is missing required fields (authority, apiBaseUrls).`);
|
||||
this.configStatus.set('error');
|
||||
return null;
|
||||
} catch {
|
||||
// Fetch failure is not an error state — just means this source is unavailable.
|
||||
// The caller will try the next source.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeConfig(config: AppConfig): AppConfig {
|
||||
@@ -117,7 +293,6 @@ export class AppConfigService {
|
||||
...config,
|
||||
authority,
|
||||
telemetry,
|
||||
quickstartMode: config.quickstartMode ?? DEFAULT_QUICKSTART,
|
||||
doctor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { HttpBackend, HttpClient } from '@angular/common/http';
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { firstValueFrom, timeout } from 'rxjs';
|
||||
|
||||
import { AppConfigService } from './app-config.service';
|
||||
|
||||
export type ProbeStatus = 'pending' | 'reachable' | 'unreachable';
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Lightweight service that probes the authority OIDC well-known endpoint
|
||||
* after config loads. Uses HttpBackend directly to avoid interceptor cycles.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BackendProbeService {
|
||||
private readonly http: HttpClient;
|
||||
private readonly configService: AppConfigService;
|
||||
|
||||
readonly probeStatus = signal<ProbeStatus>('pending');
|
||||
readonly probeError = signal<string | null>(null);
|
||||
|
||||
private probePromise: Promise<void> | null = null;
|
||||
|
||||
constructor(httpBackend: HttpBackend, configService: AppConfigService) {
|
||||
this.http = new HttpClient(httpBackend);
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the authority OIDC discovery endpoint.
|
||||
* Sets probeStatus to 'reachable' on success, 'unreachable' on failure.
|
||||
* Never throws — fails gracefully.
|
||||
*/
|
||||
probe(): Promise<void> {
|
||||
this.probeStatus.set('pending');
|
||||
this.probeError.set(null);
|
||||
|
||||
this.probePromise = this.executeProbe();
|
||||
return this.probePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves once the probe status leaves 'pending'.
|
||||
* If no probe is in flight, resolves immediately.
|
||||
*/
|
||||
waitForResult(): Promise<void> {
|
||||
if (this.probeStatus() !== 'pending' || !this.probePromise) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.probePromise;
|
||||
}
|
||||
|
||||
private async executeProbe(): Promise<void> {
|
||||
try {
|
||||
const authorityBase = this.configService.config.authority.issuer;
|
||||
|
||||
// Relative issuer (e.g. "/authority") means config came from the static
|
||||
// fallback — there is no real backend to probe yet.
|
||||
if (authorityBase.startsWith('/')) {
|
||||
this.probeStatus.set('unreachable');
|
||||
this.probeError.set('Authority issuer is a relative path; no backend configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = authorityBase.endsWith('/')
|
||||
? authorityBase.slice(0, -1)
|
||||
: authorityBase;
|
||||
const wellKnownUrl = `${normalized}/.well-known/openid-configuration`;
|
||||
|
||||
const body = await firstValueFrom(
|
||||
this.http
|
||||
.get(wellKnownUrl, { responseType: 'text', withCredentials: false })
|
||||
.pipe(timeout(PROBE_TIMEOUT_MS))
|
||||
);
|
||||
|
||||
// Validate the response is actual OIDC discovery JSON, not an SPA
|
||||
// fallback page (which returns 200 with HTML for any unknown route).
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(body as string);
|
||||
} catch {
|
||||
this.probeStatus.set('unreachable');
|
||||
this.probeError.set('Authority returned non-JSON response (likely SPA fallback).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed['issuer'] || !parsed['authorization_endpoint']) {
|
||||
this.probeStatus.set('unreachable');
|
||||
this.probeError.set('Authority response is not a valid OIDC discovery document.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.probeStatus.set('reachable');
|
||||
} catch (err: unknown) {
|
||||
this.probeStatus.set('unreachable');
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Backend probe failed';
|
||||
this.probeError.set(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanMatchFn, Router } from '@angular/router';
|
||||
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { BackendProbeService } from './backend-probe.service';
|
||||
|
||||
/**
|
||||
* Route guard that redirects to /setup?reason=unreachable when backend
|
||||
* services are not reachable.
|
||||
*
|
||||
* Place this guard AFTER requireConfigGuard and BEFORE requireAuthGuard.
|
||||
* The probe runs fire-and-forget from APP_INITIALIZER, so the guard
|
||||
* awaits the result if it is still pending.
|
||||
*
|
||||
* When setup is not complete (authority may not exist yet), the probe
|
||||
* is skipped entirely — the config guard already handles the redirect.
|
||||
*/
|
||||
export const requireBackendsReachableGuard: CanMatchFn = async () => {
|
||||
const config = inject(AppConfigService);
|
||||
const probe = inject(BackendProbeService);
|
||||
const router = inject(Router);
|
||||
|
||||
// Skip probe if setup is not complete (authority may not exist yet)
|
||||
if (config.isConfigured() && config.config.setup !== 'complete') {
|
||||
return true; // config guard already handles redirect
|
||||
}
|
||||
|
||||
if (probe.probeStatus() === 'pending') {
|
||||
await probe.waitForResult();
|
||||
}
|
||||
|
||||
return probe.probeStatus() === 'reachable'
|
||||
? true
|
||||
: router.createUrlTree(['/setup'], {
|
||||
queryParams: { reason: 'unreachable' },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, UrlTree } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
import { AppConfigService } from './app-config.service';
|
||||
import { requireConfigGuard } from './config.guard';
|
||||
import { AppConfig } from './app-config.model';
|
||||
|
||||
describe('requireConfigGuard', () => {
|
||||
let configService: jasmine.SpyObj<AppConfigService>;
|
||||
let router: Router;
|
||||
|
||||
const minimalConfig: AppConfig = {
|
||||
authority: {
|
||||
issuer: 'https://auth.test',
|
||||
clientId: 'test',
|
||||
authorizeEndpoint: 'https://auth.test/authorize',
|
||||
tokenEndpoint: 'https://auth.test/token',
|
||||
redirectUri: 'https://app.test/callback',
|
||||
scope: 'openid',
|
||||
audience: 'api',
|
||||
},
|
||||
apiBaseUrls: {
|
||||
scanner: 'https://scanner.test',
|
||||
policy: 'https://policy.test',
|
||||
concelier: 'https://concelier.test',
|
||||
attestor: 'https://attestor.test',
|
||||
authority: 'https://auth.test',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
configService = jasmine.createSpyObj('AppConfigService', [], {
|
||||
isConfigured: jasmine.createSpy('isConfigured'),
|
||||
config: minimalConfig,
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: AppConfigService, useValue: configService },
|
||||
],
|
||||
});
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
function runGuard(): boolean | UrlTree {
|
||||
return TestBed.runInInjectionContext(() => requireConfigGuard(
|
||||
{} as any, // route
|
||||
{} as any, // segments
|
||||
)) as boolean | UrlTree;
|
||||
}
|
||||
|
||||
it('should redirect to /setup when config is not loaded', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(false);
|
||||
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toBe('/setup');
|
||||
});
|
||||
|
||||
it('should redirect to /setup/wizard when config loaded but setup is absent', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
|
||||
Object.defineProperty(configService, 'config', {
|
||||
get: () => ({ ...minimalConfig, setup: undefined }),
|
||||
});
|
||||
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toBe('/setup/wizard');
|
||||
});
|
||||
|
||||
it('should redirect to /setup/wizard?resume=migrations when setup is a step ID', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
|
||||
Object.defineProperty(configService, 'config', {
|
||||
get: () => ({ ...minimalConfig, setup: 'migrations' }),
|
||||
});
|
||||
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toContain('/setup/wizard');
|
||||
expect((result as UrlTree).queryParams['resume']).toBe('migrations');
|
||||
});
|
||||
|
||||
it('should return true when setup is complete', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
|
||||
Object.defineProperty(configService, 'config', {
|
||||
get: () => ({ ...minimalConfig, setup: 'complete' }),
|
||||
});
|
||||
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
40
src/Web/StellaOps.Web/src/app/core/config/config.guard.ts
Normal file
40
src/Web/StellaOps.Web/src/app/core/config/config.guard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanMatchFn, Router } from '@angular/router';
|
||||
|
||||
import { AppConfigService } from './app-config.service';
|
||||
|
||||
/**
|
||||
* Route guard that checks both configuration loading and setup state.
|
||||
*
|
||||
* - If config is not loaded → redirect to /setup
|
||||
* - If config is loaded but `setup` is absent/undefined → redirect to /setup/wizard (fresh install)
|
||||
* - If config is loaded and `setup` is a step ID → redirect to /setup/wizard?resume=<stepId>
|
||||
* - If config is loaded and `setup === "complete"` → allow navigation
|
||||
*
|
||||
* Place this guard **before** auth guards so unconfigured deployments
|
||||
* surface the setup screen instead of an auth redirect loop.
|
||||
*/
|
||||
export const requireConfigGuard: CanMatchFn = () => {
|
||||
const config = inject(AppConfigService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!config.isConfigured()) {
|
||||
return router.createUrlTree(['/setup']);
|
||||
}
|
||||
|
||||
const setup = config.config.setup;
|
||||
|
||||
if (!setup) {
|
||||
// setup absent → fresh install, go to wizard
|
||||
return router.createUrlTree(['/setup/wizard']);
|
||||
}
|
||||
|
||||
if (setup !== 'complete') {
|
||||
// setup = stepId → resume wizard at that step
|
||||
return router.createUrlTree(['/setup/wizard'], {
|
||||
queryParams: { resume: setup },
|
||||
});
|
||||
}
|
||||
|
||||
return true; // setup === 'complete'
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConsoleExportClient } from '../api/console-export.client';
|
||||
import { ConsoleExportRequest } from '../api/console-export.models';
|
||||
import { ConsoleExportService } from './console-export.service';
|
||||
import { ConsoleExportStore } from './console-export.store';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
class MockExportClient {
|
||||
createExport() {
|
||||
@@ -26,20 +27,22 @@ describe('ConsoleExportService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
ConsoleExportStore,
|
||||
ConsoleExportService,
|
||||
{ provide: ConsoleExportClient, useClass: MockExportClient },
|
||||
{ provide: AuthSessionStore, useValue: { getActiveTenantId: () => 'tenant-default' } },
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleExportService);
|
||||
store = TestBed.inject(ConsoleExportStore);
|
||||
});
|
||||
|
||||
it('startExport stores status and clears loading', (done) => {
|
||||
it('startExport stores status and clears loading', () => new Promise<void>((resolve) => {
|
||||
const req: ConsoleExportRequest = {
|
||||
scope: { tenantId: 't1' },
|
||||
sources: [{ type: 'advisory', ids: ['a'] }],
|
||||
@@ -49,22 +52,22 @@ describe('ConsoleExportService', () => {
|
||||
service.startExport(req).subscribe(() => {
|
||||
expect(store.status()?.status).toBe('queued');
|
||||
expect(store.loading()).toBe(false);
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('refreshStatus updates status', (done) => {
|
||||
it('refreshStatus updates status', () => new Promise<void>((resolve) => {
|
||||
service.refreshStatus('exp-1').subscribe(() => {
|
||||
expect(store.status()?.status).toBe('running');
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
|
||||
it('streamExport appends events', (done) => {
|
||||
it('streamExport appends events', () => new Promise<void>((resolve) => {
|
||||
service.streamExport('exp-1').subscribe(() => {
|
||||
expect(store.events().length).toBe(1);
|
||||
expect(store.events()[0].event).toBe('completed');
|
||||
done();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Navigation system types for StellaOps Dashboard.
|
||||
* Navigation system types for Stella Ops Dashboard.
|
||||
* Supports hierarchical menus with scope-based access control.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { DeterminizationService } from './determinization.service';
|
||||
import { ObservationState, CveObservation } from '../../models/determinization.models';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('DeterminizationService', () => {
|
||||
let service: DeterminizationService;
|
||||
@@ -16,9 +17,9 @@ describe('DeterminizationService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [DeterminizationService]
|
||||
});
|
||||
imports: [],
|
||||
providers: [DeterminizationService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]
|
||||
});
|
||||
|
||||
service = TestBed.inject(DeterminizationService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { TelemetrySamplerService } from './telemetry-sampler.service';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('TelemetrySamplerService', () => {
|
||||
const baseConfig: AppConfig = {
|
||||
@@ -32,16 +33,18 @@ describe('TelemetrySamplerService', () => {
|
||||
sessionStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
TelemetrySamplerService,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: baseConfig,
|
||||
provide: APP_CONFIG,
|
||||
useValue: baseConfig,
|
||||
},
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
appConfig = TestBed.inject(AppConfigService);
|
||||
sampler = TestBed.inject(TelemetrySamplerService);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { APP_CONFIG, AppConfig } from '../config/app-config.model';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import { TelemetryClient } from './telemetry.client';
|
||||
import { TelemetrySamplerService } from './telemetry-sampler.service';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
describe('TelemetryClient', () => {
|
||||
const baseConfig: AppConfig = {
|
||||
@@ -38,17 +39,19 @@ describe('TelemetryClient', () => {
|
||||
sessionStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
imports: [],
|
||||
providers: [
|
||||
AppConfigService,
|
||||
TelemetrySamplerService,
|
||||
TelemetryClient,
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useValue: baseConfig,
|
||||
provide: APP_CONFIG,
|
||||
useValue: baseConfig,
|
||||
},
|
||||
],
|
||||
});
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
appConfig = TestBed.inject(AppConfigService);
|
||||
appConfig.setConfigForTesting(baseConfig);
|
||||
|
||||
@@ -31,10 +31,9 @@ interface ChannelTypeOption {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-management',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-channel-management',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="channel-management">
|
||||
<!-- Channel List View -->
|
||||
@if (!editMode()) {
|
||||
@@ -355,7 +354,7 @@ interface ChannelTypeOption {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.channel-management {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -648,7 +647,7 @@ interface ChannelTypeOption {
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: #1C1200;
|
||||
color: var(--color-text-heading);
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -716,7 +715,7 @@ interface ChannelTypeOption {
|
||||
.type-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChannelManagementComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -19,10 +19,9 @@ import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-analytics',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
selector: 'app-delivery-analytics',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="delivery-analytics">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
@@ -238,7 +237,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.delivery-analytics { width: 100%; }
|
||||
|
||||
.section-header {
|
||||
@@ -551,7 +550,7 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
|
||||
.metric-card.success-rate { grid-column: span 1; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryAnalyticsComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -23,10 +23,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-history',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
selector: 'app-delivery-history',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="delivery-history">
|
||||
<!-- Statistics Summary -->
|
||||
<section class="stats-summary">
|
||||
@@ -319,7 +318,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.delivery-history {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -329,7 +328,7 @@ import {
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #FFFCF5;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -350,7 +349,7 @@ import {
|
||||
.stat-value.failed { color: #dc2626; }
|
||||
.stat-value.pending { color: #d97706; }
|
||||
.stat-value.throttled { color: #2563eb; }
|
||||
.stat-value.rate { color: #D4920A; }
|
||||
.stat-value.rate { color: var(--color-brand-secondary); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
@@ -392,7 +391,7 @@ import {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
|
||||
.table-container {
|
||||
@@ -711,7 +710,7 @@ import {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryHistoryComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -23,10 +23,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-escalation-config',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-escalation-config',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="escalation-config">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
@@ -237,7 +236,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.escalation-config { width: 100%; }
|
||||
|
||||
.section-header {
|
||||
@@ -251,7 +250,7 @@ import {
|
||||
.section-header p { margin: 0; color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
||||
@@ -484,7 +483,7 @@ import {
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EscalationConfigComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -40,10 +40,9 @@ interface ConfigSubTab {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
template: `
|
||||
selector: 'app-notification-dashboard',
|
||||
imports: [RouterModule],
|
||||
template: `
|
||||
<div class="notification-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
@@ -159,7 +158,7 @@ interface ConfigSubTab {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.notification-dashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
@@ -239,7 +238,7 @@ interface ConfigSubTab {
|
||||
.sent-icon { background: #10b981; }
|
||||
.failed-icon { background: #ef4444; }
|
||||
.pending-icon { background: #f59e0b; }
|
||||
.rate-icon { background: #D4920A; }
|
||||
.rate-icon { background: var(--color-brand-secondary); }
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
@@ -288,7 +287,7 @@ interface ConfigSubTab {
|
||||
|
||||
.tab-button:hover {
|
||||
color: #1976d2;
|
||||
background: #FFFCF5;
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
@@ -390,7 +389,7 @@ interface ConfigSubTab {
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: #1C1200;
|
||||
color: var(--color-text-heading);
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
@@ -484,7 +483,7 @@ interface ConfigSubTab {
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotificationDashboardComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -16,112 +16,107 @@ import {
|
||||
import { NotifierPreviewResponse, NotifierChannelType } from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-preview',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="notification-preview" *ngIf="preview">
|
||||
<header class="preview-header">
|
||||
<div class="channel-info">
|
||||
<span class="channel-icon" [class]="'type-' + preview.channelType.toLowerCase()">
|
||||
{{ getChannelIcon(preview.channelType) }}
|
||||
</span>
|
||||
<span class="channel-type">{{ preview.channelType }}</span>
|
||||
<span class="format-badge">{{ preview.format }}</span>
|
||||
</div>
|
||||
<button class="close-btn" (click)="close.emit()" title="Close preview">X</button>
|
||||
</header>
|
||||
|
||||
<div class="preview-content">
|
||||
<!-- Email-style preview -->
|
||||
@if (preview.channelType === 'Email') {
|
||||
<div class="email-preview">
|
||||
<div class="email-header">
|
||||
<div class="email-field">
|
||||
<label>Subject:</label>
|
||||
<span>{{ preview.subject || '(No subject)' }}</span>
|
||||
selector: 'app-notification-preview',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (preview) {
|
||||
<div class="notification-preview">
|
||||
<header class="preview-header">
|
||||
<div class="channel-info">
|
||||
<span class="channel-icon" [class]="'type-' + preview.channelType.toLowerCase()">
|
||||
{{ getChannelIcon(preview.channelType) }}
|
||||
</span>
|
||||
<span class="channel-type">{{ preview.channelType }}</span>
|
||||
<span class="format-badge">{{ preview.format }}</span>
|
||||
</div>
|
||||
<button class="close-btn" (click)="close.emit()" title="Close preview">X</button>
|
||||
</header>
|
||||
<div class="preview-content">
|
||||
<!-- Email-style preview -->
|
||||
@if (preview.channelType === 'Email') {
|
||||
<div class="email-preview">
|
||||
<div class="email-header">
|
||||
<div class="email-field">
|
||||
<label>Subject:</label>
|
||||
<span>{{ preview.subject || '(No subject)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
@if (preview.format === 'html' && preview.htmlBody) {
|
||||
<div class="html-content" [innerHTML]="preview.htmlBody"></div>
|
||||
} @else {
|
||||
<pre class="text-content">{{ preview.body }}</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
@if (preview.format === 'html' && preview.htmlBody) {
|
||||
<div class="html-content" [innerHTML]="preview.htmlBody"></div>
|
||||
} @else {
|
||||
<pre class="text-content">{{ preview.body }}</pre>
|
||||
}
|
||||
<!-- Slack-style preview -->
|
||||
@if (preview.channelType === 'Slack') {
|
||||
<div class="slack-preview">
|
||||
<div class="slack-message">
|
||||
<div class="slack-avatar">SO</div>
|
||||
<div class="slack-content">
|
||||
<div class="slack-header">
|
||||
<span class="slack-username">StellaOps</span>
|
||||
<span class="slack-time">Now</span>
|
||||
</div>
|
||||
<div class="slack-body markdown-content">
|
||||
<pre>{{ preview.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Teams-style preview -->
|
||||
@if (preview.channelType === 'Teams') {
|
||||
<div class="teams-preview">
|
||||
<div class="teams-card">
|
||||
<div class="teams-accent"></div>
|
||||
<div class="teams-content">
|
||||
@if (preview.subject) {
|
||||
<h3 class="teams-title">{{ preview.subject }}</h3>
|
||||
}
|
||||
<div class="teams-body">
|
||||
<pre>{{ preview.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Webhook/PagerDuty JSON preview -->
|
||||
@if (preview.channelType === 'Webhook' || preview.channelType === 'PagerDuty') {
|
||||
<div class="json-preview">
|
||||
<div class="json-header">
|
||||
<span>JSON Payload</span>
|
||||
</div>
|
||||
<pre class="json-body">{{ formatAsJson() }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Variables Section -->
|
||||
@if (preview.variables && hasVariables()) {
|
||||
<div class="variables-section">
|
||||
<h4>Template Variables</h4>
|
||||
<div class="variables-grid">
|
||||
@for (key of getVariableKeys(); track key) {
|
||||
<div class="variable-row">
|
||||
<code class="var-key">{{ '{{' + key + '}}' }}</code>
|
||||
<span class="var-value">{{ formatValue(preview.variables[key]) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Slack-style preview -->
|
||||
@if (preview.channelType === 'Slack') {
|
||||
<div class="slack-preview">
|
||||
<div class="slack-message">
|
||||
<div class="slack-avatar">SO</div>
|
||||
<div class="slack-content">
|
||||
<div class="slack-header">
|
||||
<span class="slack-username">StellaOps</span>
|
||||
<span class="slack-time">Now</span>
|
||||
</div>
|
||||
<div class="slack-body markdown-content">
|
||||
<pre>{{ preview.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Teams-style preview -->
|
||||
@if (preview.channelType === 'Teams') {
|
||||
<div class="teams-preview">
|
||||
<div class="teams-card">
|
||||
<div class="teams-accent"></div>
|
||||
<div class="teams-content">
|
||||
@if (preview.subject) {
|
||||
<h3 class="teams-title">{{ preview.subject }}</h3>
|
||||
}
|
||||
<div class="teams-body">
|
||||
<pre>{{ preview.body }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Webhook/PagerDuty JSON preview -->
|
||||
@if (preview.channelType === 'Webhook' || preview.channelType === 'PagerDuty') {
|
||||
<div class="json-preview">
|
||||
<div class="json-header">
|
||||
<span>JSON Payload</span>
|
||||
</div>
|
||||
<pre class="json-body">{{ formatAsJson() }}</pre>
|
||||
</div>
|
||||
}
|
||||
<footer class="preview-footer">
|
||||
<span class="preview-id">Preview ID: {{ preview.previewId }}</span>
|
||||
@if (preview.traceId) {
|
||||
<span class="trace-id">Trace: {{ preview.traceId }}</span>
|
||||
}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Variables Section -->
|
||||
@if (preview.variables && hasVariables()) {
|
||||
<div class="variables-section">
|
||||
<h4>Template Variables</h4>
|
||||
<div class="variables-grid">
|
||||
@for (key of getVariableKeys(); track key) {
|
||||
<div class="variable-row">
|
||||
<code class="var-key">{{ '{{' + key + '}}' }}</code>
|
||||
<span class="var-value">{{ formatValue(preview.variables[key]) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<footer class="preview-footer">
|
||||
<span class="preview-id">Preview ID: {{ preview.previewId }}</span>
|
||||
@if (preview.traceId) {
|
||||
<span class="trace-id">Trace: {{ preview.traceId }}</span>
|
||||
}
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.notification-preview {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -404,7 +399,7 @@ import { NotifierPreviewResponse, NotifierChannelType } from '../../../core/api/
|
||||
font-family: monospace;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotificationPreviewComponent {
|
||||
@Input() preview: NotifierPreviewResponse | null = null;
|
||||
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-rule-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-notification-rule-editor',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="rule-editor">
|
||||
<header class="editor-header">
|
||||
<h2>{{ isEditMode() ? 'Edit Rule' : 'Create Rule' }}</h2>
|
||||
@@ -259,7 +258,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.rule-editor {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
@@ -407,7 +406,7 @@ import {
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: #1C1200;
|
||||
color: var(--color-text-heading);
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -469,7 +468,7 @@ import {
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotificationRuleEditorComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -20,10 +20,9 @@ import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-rule-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
selector: 'app-notification-rule-list',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="rule-list-container">
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar">
|
||||
@@ -183,7 +182,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.rule-list-container {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -234,7 +233,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
||||
|
||||
.btn-primary {
|
||||
background: #1976d2;
|
||||
color: #1C1200;
|
||||
color: var(--color-text-heading);
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -474,7 +473,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotificationRuleListComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -24,10 +24,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-operator-override-management',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-operator-override-management',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="override-management">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
@@ -242,7 +241,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.override-management { width: 100%; }
|
||||
|
||||
.section-header {
|
||||
@@ -280,7 +279,7 @@ import {
|
||||
}
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: #1976d2; font-size: 0.75rem; cursor: pointer; }
|
||||
@@ -413,7 +412,7 @@ import {
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class OperatorOverrideManagementComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -327,7 +327,7 @@ import {
|
||||
.section-header p { margin: 0; color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
||||
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: #1976d2; font-size: 0.75rem; cursor: pointer; }
|
||||
|
||||
@@ -18,10 +18,9 @@ import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-quiet-hours-config',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-quiet-hours-config',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="quiet-hours-config">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
@@ -218,7 +217,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.quiet-hours-config { width: 100%; }
|
||||
|
||||
.section-header {
|
||||
@@ -232,7 +231,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
|
||||
.section-header p { margin: 0; color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
||||
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: #1976d2; font-size: 0.75rem; cursor: pointer; }
|
||||
@@ -322,7 +321,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
|
||||
|
||||
.error-banner { margin-top: 1rem; padding: 0.75rem 1rem; background: #fef2f2; color: #991b1b; border-radius: 6px; }
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class QuietHoursConfigComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -24,10 +24,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rule-simulator',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-rule-simulator',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="rule-simulator">
|
||||
<div class="simulator-layout">
|
||||
<!-- Left: Configuration -->
|
||||
@@ -250,7 +249,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.rule-simulator {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -341,7 +340,7 @@ import {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
@@ -601,7 +600,7 @@ import {
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RuleSimulatorComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -24,10 +24,9 @@ import {
|
||||
} from '../../../core/api/notifier.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-template-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-template-editor',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="template-editor">
|
||||
<header class="editor-header">
|
||||
<button class="btn-back" (click)="onCancel()">Back</button>
|
||||
@@ -212,7 +211,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.template-editor {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -346,7 +345,7 @@ import {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
||||
@@ -490,7 +489,7 @@ import {
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TemplateEditorComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -25,10 +25,9 @@ import {
|
||||
type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
|
||||
|
||||
@Component({
|
||||
selector: 'app-throttle-config',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
selector: 'app-throttle-config',
|
||||
imports: [FormsModule, ReactiveFormsModule],
|
||||
template: `
|
||||
<div class="throttle-config">
|
||||
<header class="section-header">
|
||||
<div>
|
||||
@@ -281,7 +280,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.throttle-config { width: 100%; }
|
||||
|
||||
.section-header {
|
||||
@@ -295,7 +294,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
|
||||
.section-header p { margin: 0; color: #6b7280; font-size: 0.875rem; }
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
||||
.btn-primary { background: #1976d2; color: #1C1200; border: none; }
|
||||
.btn-primary { background: #1976d2; color: var(--color-text-heading); border: none; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: #1976d2; font-size: 0.75rem; cursor: pointer; }
|
||||
@@ -504,7 +503,7 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
|
||||
.form-row.three-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ThrottleConfigComponent implements OnInit {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
/**
|
||||
* Auto-fix button component for triggering AI-assisted remediation planning.
|
||||
@@ -20,7 +20,7 @@ import { CommonModule } from '@angular/common';
|
||||
@Component({
|
||||
selector: 'stellaops-autofix-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="autofix-container">
|
||||
<button
|
||||
@@ -112,21 +112,21 @@ import { CommonModule } from '@angular/common';
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
border: 1px solid var(--color-success-border, #6ee7b7);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
border: 1px solid var(--color-success-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.autofix-button:hover:not(:disabled) {
|
||||
background: var(--color-success-hover, #a7f3d0);
|
||||
border-color: var(--color-success-border-hover, #34d399);
|
||||
background: var(--color-success-hover);
|
||||
border-color: var(--color-success-border-hover);
|
||||
}
|
||||
|
||||
.autofix-button:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring, #10b981);
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -136,17 +136,17 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.autofix-button.loading {
|
||||
background: var(--color-success-loading, #bbf7d0);
|
||||
background: var(--color-success-loading);
|
||||
}
|
||||
|
||||
.autofix-button.has-plan {
|
||||
color: var(--color-primary-text, #1e40af);
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
border-color: var(--color-primary-border, #bfdbfe);
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary-bg);
|
||||
border-color: var(--color-primary-border);
|
||||
}
|
||||
|
||||
.autofix-button.has-plan:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #dbeafe);
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -184,9 +184,9 @@ import { CommonModule } from '@angular/common';
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin-left: -1px;
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
border: 1px solid var(--color-success-border, #6ee7b7);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
border: 1px solid var(--color-success-border);
|
||||
border-left: none;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
cursor: pointer;
|
||||
@@ -194,7 +194,7 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover {
|
||||
background: var(--color-success-hover, #a7f3d0);
|
||||
background: var(--color-success-hover);
|
||||
}
|
||||
|
||||
.dropdown-trigger svg {
|
||||
@@ -211,8 +211,8 @@ import { CommonModule } from '@angular/common';
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
@@ -225,7 +225,7 @@ import { CommonModule } from '@angular/common';
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -233,13 +233,13 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.dropdown-menu li button:hover {
|
||||
background: var(--color-hover, #f3f4f6);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.dropdown-menu li button svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models';
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
@Component({
|
||||
selector: 'stellaops-action-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="action-button-wrapper" [class.disabled]="!action.enabled">
|
||||
<button
|
||||
@@ -146,35 +146,35 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
|
||||
/* Variants */
|
||||
.action-button.variant--primary {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #1C1200;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
.action-button.variant--primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #2563eb);
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.action-button.variant--danger {
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #1C1200;
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
.action-button.variant--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-hover, #dc2626);
|
||||
background: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
.action-button.variant--warning {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
.action-button.variant--warning:hover:not(:disabled) {
|
||||
background: var(--color-warning-hover, #d97706);
|
||||
background: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
.action-button.variant--secondary {
|
||||
background: var(--bg-secondary, #313244);
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.action-button.variant--secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary-hover, #45475a);
|
||||
background: var(--bg-secondary-hover);
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
@@ -194,7 +194,7 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
|
||||
.disabled-reason {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Confirmation dialog */
|
||||
@@ -215,8 +215,8 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
|
||||
.confirmation-content {
|
||||
position: relative;
|
||||
background: var(--bg-elevated, #1e1e2e);
|
||||
border: 1px solid var(--border-subtle, #313244);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-width: 320px;
|
||||
@@ -228,19 +228,19 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirmation-description {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.confirmation-actions {
|
||||
@@ -261,31 +261,31 @@ import { ProposedAction, ActionType, ACTION_TYPE_METADATA } from './chat.models'
|
||||
}
|
||||
|
||||
.confirm-btn.cancel {
|
||||
background: var(--bg-secondary, #313244);
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.confirm-btn.cancel:hover {
|
||||
background: var(--bg-secondary-hover, #45475a);
|
||||
background: var(--bg-secondary-hover);
|
||||
}
|
||||
|
||||
.confirm-btn.confirm {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
.confirm-btn.confirm:hover {
|
||||
background: var(--color-primary-hover, #2563eb);
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
.confirm-btn.confirm.variant--danger {
|
||||
background: var(--color-danger, #ef4444);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
.confirm-btn.confirm.variant--danger:hover {
|
||||
background: var(--color-danger-hover, #dc2626);
|
||||
background: var(--color-danger-hover);
|
||||
}
|
||||
.confirm-btn.confirm.variant--warning {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
background: var(--color-warning);
|
||||
}
|
||||
.confirm-btn.confirm.variant--warning:hover {
|
||||
background: var(--color-warning-hover, #d97706);
|
||||
background: var(--color-warning-hover);
|
||||
}
|
||||
`],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ChatMessageComponent } from './chat-message.component';
|
||||
import { ConversationTurn, ParsedObjectLink } from './chat.models';
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('ChatMessageComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChatMessageComponent, RouterTestingModule],
|
||||
imports: [ChatMessageComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ChatMessageComponent);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import {
|
||||
ConversationTurn,
|
||||
ParsedObjectLink,
|
||||
@@ -28,7 +28,7 @@ interface MessageSegment {
|
||||
@Component({
|
||||
selector: 'stellaops-chat-message',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ObjectLinkChipComponent, ActionButtonComponent],
|
||||
imports: [ObjectLinkChipComponent, ActionButtonComponent],
|
||||
template: `
|
||||
<article
|
||||
class="chat-message"
|
||||
@@ -161,11 +161,11 @@ interface MessageSegment {
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background: var(--bg-user-message, rgba(59, 130, 246, 0.1));
|
||||
background: var(--bg-user-message);
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background: var(--bg-assistant-message, rgba(139, 92, 246, 0.1));
|
||||
background: var(--bg-assistant-message);
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
@@ -179,11 +179,11 @@ interface MessageSegment {
|
||||
}
|
||||
|
||||
.user .message-avatar {
|
||||
background: var(--color-user, #3b82f6);
|
||||
background: var(--color-user);
|
||||
}
|
||||
|
||||
.assistant .message-avatar {
|
||||
background: var(--color-assistant, #8b5cf6);
|
||||
background: var(--color-assistant);
|
||||
}
|
||||
|
||||
.message-avatar svg {
|
||||
@@ -207,12 +207,12 @@ interface MessageSegment {
|
||||
.message-role {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grounding-score {
|
||||
@@ -248,25 +248,25 @@ interface MessageSegment {
|
||||
.message-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
color: var(--text-secondary);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-body :global(strong) {
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-body :global(code) {
|
||||
font-family: var(--font-mono, monospace);
|
||||
background: var(--bg-code, rgba(0, 0, 0, 0.2));
|
||||
background: var(--bg-code);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-body :global(pre) {
|
||||
background: var(--bg-code-block, #11111b);
|
||||
background: var(--bg-code-block);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
@@ -285,7 +285,7 @@ interface MessageSegment {
|
||||
.message-citations {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-citations, rgba(0, 0, 0, 0.1));
|
||||
background: var(--bg-citations);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ interface MessageSegment {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -327,7 +327,7 @@ interface MessageSegment {
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle, #313244);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
@@ -338,7 +338,7 @@ interface MessageSegment {
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
@@ -350,8 +350,8 @@ interface MessageSegment {
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.copy-btn svg {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { ChatService } from './chat.service';
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
@Component({
|
||||
selector: 'stellaops-chat',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ChatMessageComponent],
|
||||
imports: [FormsModule, ChatMessageComponent],
|
||||
template: `
|
||||
<div class="chat-container" [class.loading]="isLoading()">
|
||||
<!-- Header -->
|
||||
@@ -205,7 +205,7 @@ import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-surface, #181825);
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -216,8 +216,8 @@ import {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-elevated, #1e1e2e);
|
||||
border-bottom: 1px solid var(--border-subtle, #313244);
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -229,22 +229,22 @@ import {
|
||||
.header-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-assistant, #8b5cf6);
|
||||
color: var(--color-assistant);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conversation-id {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-code, rgba(0, 0, 0, 0.2));
|
||||
background: var(--bg-code);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ import {
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
@@ -269,8 +269,8 @@ import {
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-btn svg {
|
||||
@@ -295,21 +295,21 @@ import {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-state svg, .error-state svg, .empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-subtle, #313244);
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border: 3px solid var(--border-subtle);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
@@ -320,13 +320,13 @@ import {
|
||||
}
|
||||
|
||||
.error-state svg {
|
||||
color: var(--color-danger, #ef4444);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -336,7 +336,7 @@ import {
|
||||
.empty-state h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -353,9 +353,9 @@ import {
|
||||
|
||||
.suggestion-btn {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary, #313244);
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
border: 1px solid var(--border-subtle, #45475a);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -363,15 +363,15 @@ import {
|
||||
}
|
||||
|
||||
.suggestion-btn:hover {
|
||||
background: var(--bg-secondary-hover, #45475a);
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
background: var(--bg-secondary-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Streaming message */
|
||||
.chat-message.streaming {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-assistant-message, rgba(139, 92, 246, 0.1));
|
||||
background: var(--bg-assistant-message);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -380,7 +380,7 @@ import {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-assistant, #8b5cf6);
|
||||
background: var(--color-assistant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -408,12 +408,12 @@ import {
|
||||
.streaming .message-role {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dots span {
|
||||
@@ -430,14 +430,14 @@ import {
|
||||
.streaming .message-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: var(--text-primary, #cdd6f4);
|
||||
background: var(--text-primary);
|
||||
animation: blink-cursor 1s infinite;
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 2px;
|
||||
@@ -451,8 +451,8 @@ import {
|
||||
/* Input area */
|
||||
.chat-input-area {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-elevated, #1e1e2e);
|
||||
border-top: 1px solid var(--border-subtle, #313244);
|
||||
background: var(--bg-elevated);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
@@ -464,10 +464,10 @@ import {
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: var(--bg-input, #11111b);
|
||||
border: 1px solid var(--border-subtle, #313244);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
@@ -477,7 +477,7 @@ import {
|
||||
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chat-input:disabled {
|
||||
@@ -486,14 +486,14 @@ import {
|
||||
}
|
||||
|
||||
.chat-input::placeholder {
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -505,7 +505,7 @@ import {
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #2563eb);
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
@@ -521,7 +521,7 @@ import {
|
||||
.input-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
`],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ObjectLinkChipComponent } from './object-link-chip.component';
|
||||
import { ParsedObjectLink, OBJECT_LINK_METADATA } from './chat.models';
|
||||
|
||||
@@ -23,7 +23,8 @@ describe('ObjectLinkChipComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ObjectLinkChipComponent, RouterTestingModule],
|
||||
imports: [ObjectLinkChipComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ObjectLinkChipComponent);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
ParsedObjectLink,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
@Component({
|
||||
selector: 'stellaops-object-link-chip',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<a
|
||||
class="object-link-chip"
|
||||
@@ -125,16 +125,16 @@ import {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-decoration: none;
|
||||
background: var(--chip-bg, rgba(59, 130, 246, 0.1));
|
||||
color: var(--chip-color, #3b82f6);
|
||||
border: 1px solid var(--chip-border, rgba(59, 130, 246, 0.2));
|
||||
background: var(--chip-bg);
|
||||
color: var(--chip-color);
|
||||
border: 1px solid var(--chip-border);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.object-link-chip:hover {
|
||||
background: var(--chip-bg-hover, rgba(59, 130, 246, 0.2));
|
||||
border-color: var(--chip-border-hover, rgba(59, 130, 246, 0.4));
|
||||
background: var(--chip-bg-hover);
|
||||
border-color: var(--chip-border-hover);
|
||||
}
|
||||
|
||||
.object-link-chip:focus-visible {
|
||||
@@ -147,9 +147,9 @@ import {
|
||||
.chip--reach { --chip-color: #8b5cf6; --chip-bg: rgba(139, 92, 246, 0.1); --chip-border: rgba(139, 92, 246, 0.2); }
|
||||
.chip--runtime { --chip-color: #f59e0b; --chip-bg: rgba(245, 158, 11, 0.1); --chip-border: rgba(245, 158, 11, 0.2); }
|
||||
.chip--vex { --chip-color: #10b981; --chip-bg: rgba(16, 185, 129, 0.1); --chip-border: rgba(16, 185, 129, 0.2); }
|
||||
.chip--attest { --chip-color: #D4920A; --chip-bg: rgba(245, 166, 35, 0.1); --chip-border: rgba(245, 166, 35, 0.2); }
|
||||
.chip--attest { --chip-color: var(--color-brand-secondary); --chip-bg: var(--color-brand-primary-10); --chip-border: var(--color-brand-primary-20); }
|
||||
.chip--auth { --chip-color: #ef4444; --chip-bg: rgba(239, 68, 68, 0.1); --chip-border: rgba(239, 68, 68, 0.2); }
|
||||
.chip--docs { --chip-color: #6B5A2E; --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
||||
.chip--docs { --chip-color: var(--color-text-secondary); --chip-bg: rgba(100, 116, 139, 0.1); --chip-border: rgba(100, 116, 139, 0.2); }
|
||||
.chip--finding { --chip-color: #f97316; --chip-bg: rgba(249, 115, 22, 0.1); --chip-border: rgba(249, 115, 22, 0.2); }
|
||||
.chip--scan { --chip-color: #0ea5e9; --chip-bg: rgba(14, 165, 233, 0.1); --chip-border: rgba(14, 165, 233, 0.2); }
|
||||
.chip--policy { --chip-color: #22c55e; --chip-bg: rgba(34, 197, 94, 0.1); --chip-border: rgba(34, 197, 94, 0.2); }
|
||||
@@ -187,8 +187,8 @@ import {
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated, #1e1e2e);
|
||||
border: 1px solid var(--border-subtle, #313244);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
min-width: 200px;
|
||||
@@ -202,7 +202,7 @@ import {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border-subtle, #313244);
|
||||
border-top-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
@@ -214,7 +214,7 @@ import {
|
||||
|
||||
.preview-type {
|
||||
font-weight: 600;
|
||||
color: var(--preview-color, #3b82f6);
|
||||
color: var(--preview-color);
|
||||
}
|
||||
|
||||
.preview-verified {
|
||||
@@ -233,14 +233,14 @@ import {
|
||||
.preview-path {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #a6adc8);
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #6c7086);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
`],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-ai.models';
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
@Component({
|
||||
selector: 'stellaops-evidence-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
@if (citation) {
|
||||
<aside
|
||||
@@ -135,8 +135,8 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
styles: [`
|
||||
.evidence-drilldown {
|
||||
position: relative;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
overflow: hidden;
|
||||
@@ -160,8 +160,8 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-alt);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
}
|
||||
|
||||
.evidence-type-badge.type-patch {
|
||||
color: #F5A623;
|
||||
color: var(--color-brand-primary);
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -231,12 +231,12 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--color-hover, #e5e7eb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.close-btn svg {
|
||||
@@ -257,13 +257,13 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--color-warning-text, #92400e);
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-text);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.verification-status.verified {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
@@ -278,7 +278,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.evidence-excerpt {
|
||||
@@ -287,7 +287,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
|
||||
.excerpt-content {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-code-bg, #f3f4f6);
|
||||
background: var(--color-code-bg);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -298,7 +298,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.evidence-reference {
|
||||
@@ -310,7 +310,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-code-bg, #f3f4f6);
|
||||
background: var(--color-code-bg);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -336,11 +336,11 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--color-hover, #e5e7eb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.copy-btn svg {
|
||||
@@ -355,7 +355,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
.type-description {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -384,24 +384,24 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
color: var(--color-text-primary, #374151);
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--color-hover, #f9fafb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: var(--color-primary-contrast, #ffffff);
|
||||
background: var(--color-primary, #3b82f6);
|
||||
border: 1px solid var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary-contrast);
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--color-primary-hover, #2563eb);
|
||||
border-color: var(--color-primary-hover, #2563eb);
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
/**
|
||||
* Explain button component for triggering AI explanation generation.
|
||||
@@ -20,7 +20,7 @@ import { CommonModule } from '@angular/common';
|
||||
@Component({
|
||||
selector: 'stellaops-explain-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
@@ -52,21 +52,21 @@ import { CommonModule } from '@angular/common';
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary-text, #1e40af);
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
border: 1px solid var(--color-primary-border, #bfdbfe);
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary-bg);
|
||||
border: 1px solid var(--color-primary-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.explain-button:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover, #dbeafe);
|
||||
border-color: var(--color-primary-border-hover, #93c5fd);
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-border-hover);
|
||||
}
|
||||
|
||||
.explain-button:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring, #3b82f6);
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.explain-button.loading {
|
||||
background: var(--color-primary-loading, #e0e7ff);
|
||||
background: var(--color-primary-loading);
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import type {
|
||||
ExplanationResult,
|
||||
ExplanationCitation,
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
@Component({
|
||||
selector: 'stellaops-explanation-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<article class="explanation-panel" [class.loading]="loading" [class.collapsed]="collapsed()">
|
||||
<header class="panel-header">
|
||||
@@ -169,8 +169,8 @@ import type {
|
||||
`,
|
||||
styles: [`
|
||||
.explanation-panel {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -180,8 +180,8 @@ import type {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-alt);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.explanation-panel.collapsed .panel-header {
|
||||
@@ -207,13 +207,13 @@ import type {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.authority-badge {
|
||||
@@ -224,13 +224,13 @@ import type {
|
||||
}
|
||||
|
||||
.authority-badge.evidence-backed {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.authority-badge.suggestion {
|
||||
color: var(--color-warning-text, #92400e);
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-text);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.confidence {
|
||||
@@ -238,13 +238,13 @@ import type {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.confidence-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
@@ -258,11 +258,11 @@ import type {
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: var(--color-hover, #f3f4f6);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.collapse-btn svg {
|
||||
@@ -283,14 +283,14 @@ import type {
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e5e7eb);
|
||||
border-top-color: var(--color-primary, #3b82f6);
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -301,23 +301,23 @@ import type {
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-text, #1e40af);
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
border: 1px solid var(--color-primary-border, #bfdbfe);
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary-bg);
|
||||
border: 1px solid var(--color-primary-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -325,7 +325,7 @@ import type {
|
||||
.summary-section {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -339,13 +339,13 @@ import type {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plain-language-toggle {
|
||||
@@ -358,7 +358,7 @@ import type {
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.explanation-content {
|
||||
@@ -368,7 +368,7 @@ import type {
|
||||
.content-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.content-text :deep(h2) {
|
||||
@@ -384,12 +384,12 @@ import type {
|
||||
.content-text :deep(code) {
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--color-code-bg, #f3f4f6);
|
||||
background: var(--color-code-bg);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.citations-section {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -400,12 +400,12 @@ import type {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.citation-rate {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.citations-list {
|
||||
@@ -426,14 +426,14 @@ import type {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.citation-btn:hover {
|
||||
background: var(--color-hover, #f9fafb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.citation-type {
|
||||
@@ -471,14 +471,14 @@ import type {
|
||||
}
|
||||
|
||||
.citation-type.type-patch {
|
||||
color: #F5A623;
|
||||
color: var(--color-brand-primary);
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.citation-claim {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -488,16 +488,16 @@ import type {
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary, #9ca3af);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
/**
|
||||
* Plain language toggle component for switching between technical and beginner-friendly explanations.
|
||||
@@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common';
|
||||
@Component({
|
||||
selector: 'stellaops-plain-language-toggle',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="plain-language-toggle" [class.compact]="compact">
|
||||
<label class="toggle-container">
|
||||
@@ -93,19 +93,19 @@ import { CommonModule } from '@angular/common';
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
padding: 0.125rem;
|
||||
background: var(--color-toggle-off, #d1d5db);
|
||||
background: var(--color-toggle-off);
|
||||
border-radius: 9999px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-track {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
background: var(--color-surface, #ffffff);
|
||||
background: var(--color-surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
@@ -116,7 +116,7 @@ import { CommonModule } from '@angular/common';
|
||||
}
|
||||
|
||||
.toggle-input:focus-visible + .toggle-track {
|
||||
outline: 2px solid var(--color-focus-ring, #3b82f6);
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ import { CommonModule } from '@angular/common';
|
||||
.label-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plain-language-toggle.compact .label-text {
|
||||
@@ -148,7 +148,7 @@ import { CommonModule } from '@angular/common';
|
||||
|
||||
.label-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.active-badge {
|
||||
@@ -158,8 +158,8 @@ import { CommonModule } from '@angular/common';
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-info-text, #1e40af);
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-text);
|
||||
background: var(--color-info-bg);
|
||||
border-radius: 9999px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import type {
|
||||
PullRequestInfo,
|
||||
PullRequestStatus,
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
@Component({
|
||||
selector: 'stellaops-pr-tracker',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
@if (pullRequest) {
|
||||
<article class="pr-tracker" [class]="'status-' + pullRequest.status">
|
||||
@@ -210,24 +210,24 @@ import type {
|
||||
`,
|
||||
styles: [`
|
||||
.pr-tracker {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pr-tracker.status-merged {
|
||||
border-color: var(--color-merged-border, #a78bfa);
|
||||
border-color: var(--color-merged-border);
|
||||
}
|
||||
|
||||
.pr-tracker.status-closed {
|
||||
border-color: var(--color-error-border, #fca5a5);
|
||||
border-color: var(--color-error-border);
|
||||
}
|
||||
|
||||
.pr-header {
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-alt);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pr-title-row {
|
||||
@@ -240,7 +240,7 @@ import type {
|
||||
.pr-number {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pr-title {
|
||||
@@ -248,7 +248,7 @@ import type {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -263,23 +263,23 @@ import type {
|
||||
}
|
||||
|
||||
.pr-status-badge.draft {
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
background: var(--color-surface, #f3f4f6);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.pr-status-badge.open {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.pr-status-badge.merged {
|
||||
color: var(--color-merged-text, #5b21b6);
|
||||
background: var(--color-merged-bg, #ede9fe);
|
||||
color: var(--color-merged-text);
|
||||
background: var(--color-merged-bg);
|
||||
}
|
||||
|
||||
.pr-status-badge.closed {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.pr-meta {
|
||||
@@ -293,7 +293,7 @@ import type {
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta-item svg {
|
||||
@@ -303,7 +303,7 @@ import type {
|
||||
|
||||
.meta-item.scm-provider {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--color-surface, #f3f4f6);
|
||||
background: var(--color-surface);
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
@@ -320,7 +320,7 @@ import type {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.check-summary,
|
||||
@@ -333,19 +333,19 @@ import type {
|
||||
|
||||
.check-summary.all-passed,
|
||||
.review-summary.approved {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.check-summary.some-failed {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.check-summary.in-progress,
|
||||
.review-summary.pending {
|
||||
color: var(--color-warning-text, #92400e);
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-text);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.ci-checks-section {
|
||||
@@ -363,7 +363,7 @@ import type {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.check-item:last-child {
|
||||
@@ -384,23 +384,23 @@ import type {
|
||||
}
|
||||
|
||||
.check-status.passed {
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.check-status.failed {
|
||||
color: var(--color-error, #ef4444);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.check-status.running {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.check-status.pending {
|
||||
color: var(--color-text-secondary, #9ca3af);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.check-status.skipped {
|
||||
color: var(--color-text-tertiary, #d1d5db);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.check-status .spinner {
|
||||
@@ -419,12 +419,12 @@ import type {
|
||||
.check-name {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.check-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ import type {
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -459,8 +459,8 @@ import type {
|
||||
height: 2rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-contrast, #ffffff);
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: var(--color-primary-contrast);
|
||||
background: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ import type {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.reviewer-decision {
|
||||
@@ -476,7 +476,7 @@ import type {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.decision-icon {
|
||||
@@ -485,22 +485,22 @@ import type {
|
||||
}
|
||||
|
||||
.decision-icon.approved {
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.decision-icon.changes {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.decision-icon.pending {
|
||||
color: var(--color-text-secondary, #9ca3af);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.timeline-section {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
@@ -513,12 +513,12 @@ import type {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.timeline-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.pr-actions {
|
||||
@@ -526,8 +526,8 @@ import type {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-alt);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -549,34 +549,34 @@ import type {
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
color: var(--color-text-primary, #374151);
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--color-hover, #f9fafb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: var(--color-merged-contrast, #ffffff);
|
||||
background: var(--color-merged, #8b5cf6);
|
||||
border: 1px solid var(--color-merged, #8b5cf6);
|
||||
color: var(--color-merged-contrast);
|
||||
background: var(--color-merged);
|
||||
border: 1px solid var(--color-merged);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--color-merged-hover, #7c3aed);
|
||||
border-color: var(--color-merged-hover, #7c3aed);
|
||||
background: var(--color-merged-hover);
|
||||
border-color: var(--color-merged-hover);
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-error-border, #fca5a5);
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-error-border);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import type {
|
||||
RemediationPlan,
|
||||
RemediationStep,
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
@Component({
|
||||
selector: 'stellaops-remediation-plan-preview',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [],
|
||||
template: `
|
||||
<article class="remediation-plan" [class.loading]="loading">
|
||||
<header class="plan-header">
|
||||
@@ -225,8 +225,8 @@ import type {
|
||||
`,
|
||||
styles: [`
|
||||
.remediation-plan {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -236,8 +236,8 @@ import type {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-surface-alt);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -253,13 +253,13 @@ import type {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -270,37 +270,37 @@ import type {
|
||||
}
|
||||
|
||||
.status-badge.draft {
|
||||
color: var(--color-info-text, #1e40af);
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-text);
|
||||
background: var(--color-info-bg);
|
||||
}
|
||||
|
||||
.status-badge.validated {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.status-badge.in_progress {
|
||||
color: var(--color-warning-text, #92400e);
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-text);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
color: var(--color-success-text, #065f46);
|
||||
background: var(--color-success-bg, #d1fae5);
|
||||
color: var(--color-success-text);
|
||||
background: var(--color-success-bg);
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.strategy-badge {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.25rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -318,14 +318,14 @@ import type {
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e5e7eb);
|
||||
border-top-color: var(--color-success, #10b981);
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-success);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -336,23 +336,23 @@ import type {
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--color-error-text, #991b1b);
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-text, #1e40af);
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
border: 1px solid var(--color-primary-border, #bfdbfe);
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary-bg);
|
||||
border: 1px solid var(--color-primary-border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -360,7 +360,7 @@ import type {
|
||||
.summary-section {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -374,26 +374,26 @@ import type {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.impact-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@@ -414,20 +414,20 @@ import type {
|
||||
.impact-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.impact-item.warning .impact-value {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.impact-item.good .impact-value {
|
||||
color: var(--color-success, #10b981);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.impact-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.risk-score {
|
||||
@@ -439,13 +439,13 @@ import type {
|
||||
.risk-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.risk-bar {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -457,21 +457,21 @@ import type {
|
||||
}
|
||||
|
||||
.risk-fill.low {
|
||||
background: var(--color-success, #10b981);
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.risk-fill.medium {
|
||||
background: var(--color-warning, #f59e0b);
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.risk-fill.high {
|
||||
background: var(--color-error, #ef4444);
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.risk-value {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.steps-section {
|
||||
@@ -486,7 +486,7 @@ import type {
|
||||
|
||||
.step-item {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -504,7 +504,7 @@ import type {
|
||||
}
|
||||
|
||||
.step-header:hover {
|
||||
background: var(--color-hover, #f9fafb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@@ -515,8 +515,8 @@ import type {
|
||||
height: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-text, #1e40af);
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary-bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -549,7 +549,7 @@ import type {
|
||||
}
|
||||
|
||||
.step-type.type-vex_document {
|
||||
color: #F5A623;
|
||||
color: var(--color-brand-primary);
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
@@ -557,15 +557,15 @@ import type {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary, #111827);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.breaking-badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error-text, #991b1b);
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error-text);
|
||||
background: var(--color-error-bg);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -595,18 +595,18 @@ import type {
|
||||
.expand-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.step-description {
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ import type {
|
||||
margin-bottom: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.step-command pre,
|
||||
@@ -630,8 +630,8 @@ import type {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
background: var(--color-code-bg, #1f2937);
|
||||
color: var(--color-code-text, #e5e7eb);
|
||||
background: var(--color-code-bg);
|
||||
color: var(--color-code-text);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -646,15 +646,15 @@ import type {
|
||||
right: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-code-text, #e5e7eb);
|
||||
background: var(--color-code-btn, #374151);
|
||||
color: var(--color-code-text);
|
||||
background: var(--color-code-btn);
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--color-code-btn-hover, #4b5563);
|
||||
background: var(--color-code-btn-hover);
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
@@ -666,7 +666,7 @@ import type {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -687,24 +687,24 @@ import type {
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
color: var(--color-text-primary, #374151);
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--color-hover, #f9fafb);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
color: var(--color-primary-contrast, #ffffff);
|
||||
background: var(--color-success, #10b981);
|
||||
border: 1px solid var(--color-success, #10b981);
|
||||
color: var(--color-primary-contrast);
|
||||
background: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--color-success-hover, #059669);
|
||||
border-color: var(--color-success-hover, #059669);
|
||||
background: var(--color-success-hover);
|
||||
border-color: var(--color-success-hover);
|
||||
}
|
||||
`]
|
||||
})
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter, ActivatedRoute, Router, convertToParamMap } from '@angular/router';
|
||||
import { AgentDetailPageComponent } from './agent-detail-page.component';
|
||||
import { AgentStore } from './services/agent.store';
|
||||
import { Agent, AgentHealthResult, AgentTask } from './models/agent.models';
|
||||
@@ -85,8 +84,9 @@ describe('AgentDetailPageComponent', () => {
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AgentDetailPageComponent, RouterTestingModule],
|
||||
imports: [AgentDetailPageComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AgentStore, useValue: mockStore },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AgentStore } from './services/agent.store';
|
||||
@@ -32,10 +32,9 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent],
|
||||
template: `
|
||||
selector: 'st-agent-detail-page',
|
||||
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent],
|
||||
template: `
|
||||
<div class="agent-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
@@ -352,7 +351,7 @@ interface ActionFeedback {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.agent-detail-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
@@ -369,7 +368,7 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
.breadcrumb__link {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
@@ -378,11 +377,11 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
.breadcrumb__separator {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb__current {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
@@ -411,12 +410,12 @@ interface ActionFeedback {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-header__id {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-header__actions {
|
||||
@@ -436,24 +435,24 @@ interface ActionFeedback {
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tag--env {
|
||||
background: var(--tag-env-bg, #dbeafe);
|
||||
color: var(--tag-env-text, #1e40af);
|
||||
background: var(--tag-env-bg);
|
||||
color: var(--tag-env-text);
|
||||
}
|
||||
|
||||
.tag--version {
|
||||
background: var(--tag-version-bg, #e5e7eb);
|
||||
color: var(--tag-version-text, #374151);
|
||||
background: var(--tag-version-bg);
|
||||
color: var(--tag-version-text);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -464,17 +463,17 @@ interface ActionFeedback {
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--primary, #3b82f6);
|
||||
border-bottom-color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,8 +487,8 @@ interface ActionFeedback {
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -497,7 +496,7 @@ interface ActionFeedback {
|
||||
.stat-card__label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -505,19 +504,19 @@ interface ActionFeedback {
|
||||
.stat-card__value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Section Card */
|
||||
.section-card {
|
||||
padding: 1.25rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid var(--status-warning, #f59e0b);
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +524,7 @@ interface ActionFeedback {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Resource Meters */
|
||||
@@ -543,7 +542,7 @@ interface ActionFeedback {
|
||||
|
||||
.resource-meter__bar {
|
||||
height: 8px;
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -564,19 +563,19 @@ interface ActionFeedback {
|
||||
|
||||
.detail-list dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-list dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-list code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -585,8 +584,8 @@ interface ActionFeedback {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
color: var(--warning-text, #92400e);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -602,8 +601,8 @@ interface ActionFeedback {
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
min-width: 180px;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
@@ -620,18 +619,18 @@ interface ActionFeedback {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0.25rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,12 +648,12 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,8 +669,8 @@ interface ActionFeedback {
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-default, #e5e7eb);
|
||||
border-top-color: var(--primary, #3b82f6);
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
@@ -682,12 +681,12 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
.error-state__message {
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
@@ -702,8 +701,8 @@ interface ActionFeedback {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
@@ -722,11 +721,11 @@ interface ActionFeedback {
|
||||
}
|
||||
|
||||
.action-toast--success {
|
||||
border-left: 3px solid var(--status-success, #10b981);
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
.action-toast--error {
|
||||
border-left: 3px solid var(--status-error, #ef4444);
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
|
||||
.action-toast__icon {
|
||||
@@ -742,18 +741,18 @@ interface ActionFeedback {
|
||||
|
||||
.action-toast--success .action-toast__icon {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success, #10b981);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.action-toast--error .action-toast__icon {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.action-toast__message {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-toast__close {
|
||||
@@ -762,14 +761,14 @@ interface ActionFeedback {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
|
||||
export class AgentDetailPageComponent implements OnInit {
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AgentFleetDashboardComponent } from './agent-fleet-dashboard.component';
|
||||
import { AgentStore } from './services/agent.store';
|
||||
import { Agent, AgentStatus } from './models/agent.models';
|
||||
@@ -87,8 +86,8 @@ describe('AgentFleetDashboardComponent', () => {
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AgentFleetDashboardComponent, RouterTestingModule],
|
||||
providers: [{ provide: AgentStore, useValue: mockStore }],
|
||||
imports: [AgentFleetDashboardComponent],
|
||||
providers: [provideRouter([]), { provide: AgentStore, useValue: mockStore }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgentFleetDashboardComponent);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@@ -20,10 +20,9 @@ import { FleetComparisonComponent } from './components/fleet-comparison/fleet-co
|
||||
type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-fleet-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent],
|
||||
template: `
|
||||
selector: 'st-agent-fleet-dashboard',
|
||||
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent],
|
||||
template: `
|
||||
<div class="agent-fleet-dashboard">
|
||||
<!-- Page Header -->
|
||||
<header class="page-header">
|
||||
@@ -290,7 +289,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.agent-fleet-dashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1600px;
|
||||
@@ -309,13 +308,13 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
@@ -330,36 +329,36 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.realtime-status--connected {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success, #10b981);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.realtime-status__indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted, #9ca3af);
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.realtime-status__indicator--connected {
|
||||
background: var(--status-success, #10b981);
|
||||
background: var(--status-success);
|
||||
animation: pulse-connected 2s infinite;
|
||||
}
|
||||
|
||||
.realtime-status__indicator--connecting {
|
||||
background: var(--status-warning, #f59e0b);
|
||||
background: var(--status-warning);
|
||||
animation: pulse-connecting 1s infinite;
|
||||
}
|
||||
|
||||
.realtime-status__indicator--error {
|
||||
background: var(--status-error, #ef4444);
|
||||
background: var(--status-error);
|
||||
}
|
||||
|
||||
@keyframes pulse-connected {
|
||||
@@ -391,35 +390,35 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: #1C1200;
|
||||
background: var(--primary);
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover, #2563eb);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
padding: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -441,21 +440,21 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
|
||||
&--success {
|
||||
border-left: 3px solid var(--status-success, #10b981);
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid var(--status-warning, #f59e0b);
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
border-left: 3px solid var(--status-error, #ef4444);
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,12 +462,12 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.kpi-card__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -479,7 +478,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -492,13 +491,13 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -519,7 +518,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
.filter-group__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
@@ -532,33 +531,33 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary, #ffffff);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--chip-color, var(--primary, #3b82f6));
|
||||
border-color: var(--chip-color, var(--primary));
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--chip-color, var(--primary, #3b82f6));
|
||||
border-color: var(--chip-color, var(--primary, #3b82f6));
|
||||
background: var(--chip-color, var(--primary));
|
||||
border-color: var(--chip-color, var(--primary));
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
background: var(--surface-primary);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,14 +571,14 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
|
||||
.view-controls__count {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.view-controls__toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -589,16 +588,16 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -625,8 +624,8 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-default, #e5e7eb);
|
||||
border-top-color: var(--primary, #3b82f6);
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
@@ -637,12 +636,12 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
}
|
||||
|
||||
.error-state__message {
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state__message {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -650,13 +649,13 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
.page-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-footer__refresh {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -686,7 +685,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentFleetDashboardComponent implements OnInit, OnDestroy {
|
||||
readonly store = inject(AgentStore);
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AgentOnboardWizardComponent } from './agent-onboard-wizard.component';
|
||||
|
||||
describe('AgentOnboardWizardComponent', () => {
|
||||
@@ -16,7 +15,8 @@ describe('AgentOnboardWizardComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AgentOnboardWizardComponent, RouterTestingModule],
|
||||
imports: [AgentOnboardWizardComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgentOnboardWizardComponent);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { inject } from '@angular/core';
|
||||
@@ -15,10 +15,9 @@ import { inject } from '@angular/core';
|
||||
type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete';
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-onboard-wizard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
template: `
|
||||
selector: 'st-agent-onboard-wizard',
|
||||
imports: [FormsModule, RouterLink],
|
||||
template: `
|
||||
<div class="onboard-wizard">
|
||||
<!-- Header -->
|
||||
<header class="wizard-header">
|
||||
@@ -195,7 +194,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.onboard-wizard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
@@ -209,7 +208,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
.wizard-header__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -238,7 +237,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
height: 2px;
|
||||
background: var(--border-default, #e5e7eb);
|
||||
background: var(--border-default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,8 +253,8 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 2px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 2px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -266,29 +265,29 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
|
||||
.progress-step__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-step--active .progress-step__number {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.progress-step--active .progress-step__label {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.progress-step--completed .progress-step__number {
|
||||
background: var(--primary, #3b82f6);
|
||||
border-color: var(--primary, #3b82f6);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.wizard-content {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
min-height: 300px;
|
||||
@@ -300,7 +299,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
}
|
||||
|
||||
.step-content > p {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -319,19 +318,19 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--border-default, #e5e7eb);
|
||||
border: 2px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
background: var(--surface-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -343,7 +342,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
|
||||
.env-option__desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
@@ -360,13 +359,13 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -374,7 +373,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
/* Install Command */
|
||||
.install-command {
|
||||
position: relative;
|
||||
background: var(--surface-code, #1f2937);
|
||||
background: var(--surface-code);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
@@ -414,7 +413,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,8 +428,8 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border-default, #e5e7eb);
|
||||
border-top-color: var(--primary, #3b82f6);
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
@@ -453,13 +452,13 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
background: var(--status-success, #10b981);
|
||||
background: var(--status-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.troubleshooting {
|
||||
@@ -468,17 +467,17 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
@@ -490,7 +489,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-success, #10b981);
|
||||
background: var(--status-success);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -530,24 +529,24 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: #1C1200;
|
||||
background: var(--primary);
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover, #2563eb);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentOnboardWizardComponent {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
import { Agent, AgentAction } from '../../models/agent.models';
|
||||
|
||||
@@ -64,10 +64,9 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-action-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'st-agent-action-modal',
|
||||
imports: [],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
@@ -166,7 +165,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -186,7 +185,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
background: var(--surface-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||||
animation: slide-up 0.2s ease-out;
|
||||
@@ -208,7 +207,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.modal__title {
|
||||
@@ -225,15 +224,15 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
}
|
||||
|
||||
.modal__icon--danger {
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.modal__icon--warning {
|
||||
color: var(--status-warning, #f59e0b);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.modal__icon--info {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.modal__close {
|
||||
@@ -246,12 +245,12 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +262,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal__agent-info {
|
||||
@@ -271,18 +270,18 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
strong {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,19 +294,19 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal__input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -315,7 +314,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
.modal__input-error {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
@@ -323,8 +322,8 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
@@ -349,26 +348,26 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: #1C1200;
|
||||
background: var(--primary);
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover, #2563eb);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--warning {
|
||||
background: var(--status-warning, #f59e0b);
|
||||
background: var(--status-warning);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
@@ -377,7 +376,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--status-error, #ef4444);
|
||||
background: var(--status-error);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
@@ -397,7 +396,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentActionModalComponent {
|
||||
/** The action to perform */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
import {
|
||||
Agent,
|
||||
@@ -19,10 +19,9 @@ import {
|
||||
} from '../../models/agent.models';
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'st-agent-card',
|
||||
imports: [],
|
||||
template: `
|
||||
<article
|
||||
class="agent-card"
|
||||
[class.agent-card--online]="agent().status === 'online'"
|
||||
@@ -101,34 +100,34 @@ import {
|
||||
}
|
||||
</article>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.agent-card {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-hover, #d1d5db);
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #3b82f6);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
&--offline {
|
||||
opacity: 0.8;
|
||||
background: var(--surface-muted, #f9fafb);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ import {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -179,7 +178,7 @@ import {
|
||||
|
||||
.agent-card__id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
@@ -190,13 +189,13 @@ import {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +217,13 @@ import {
|
||||
}
|
||||
|
||||
.agent-card__tag--env {
|
||||
background: var(--tag-env-bg, #dbeafe);
|
||||
color: var(--tag-env-text, #1e40af);
|
||||
background: var(--tag-env-bg);
|
||||
color: var(--tag-env-text);
|
||||
}
|
||||
|
||||
.agent-card__tag--version {
|
||||
background: var(--tag-version-bg, #e5e7eb);
|
||||
color: var(--tag-version-text, #374151);
|
||||
background: var(--tag-version-bg);
|
||||
color: var(--tag-version-text);
|
||||
}
|
||||
|
||||
.agent-card__capacity {
|
||||
@@ -235,7 +234,7 @@ import {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -245,7 +244,7 @@ import {
|
||||
|
||||
.capacity-bar {
|
||||
height: 6px;
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -261,7 +260,7 @@ import {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@@ -273,14 +272,14 @@ import {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.metric__value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agent-card__warning {
|
||||
@@ -289,16 +288,16 @@ import {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
background: var(--warning-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--warning-text, #92400e);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--warning, #f59e0b);
|
||||
color: var(--warning);
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentCardComponent {
|
||||
/** Agent data */
|
||||
|
||||
@@ -7,15 +7,14 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
import { AgentHealthResult } from '../../models/agent.models';
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-health-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'st-agent-health-tab',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="agent-health-tab">
|
||||
<!-- Header -->
|
||||
<header class="tab-header">
|
||||
@@ -119,7 +118,7 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.agent-health-tab {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
@@ -149,19 +148,19 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
|
||||
&--pass {
|
||||
border-left: 3px solid var(--status-success, #10b981);
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left: 3px solid var(--status-warning, #f59e0b);
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-left: 3px solid var(--status-error, #ef4444);
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +172,7 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
|
||||
.summary-item__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -189,8 +188,8 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.15s;
|
||||
|
||||
@@ -199,15 +198,15 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
}
|
||||
|
||||
&--pass {
|
||||
border-left: 3px solid var(--status-success, #10b981);
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left: 3px solid var(--status-warning, #f59e0b);
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
&--fail {
|
||||
border-left: 3px solid var(--status-error, #ef4444);
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,17 +225,17 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
|
||||
&--pass {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success, #10b981);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--status-warning, #f59e0b);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
&--fail {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,14 +253,14 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
.check-item__message {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.check-item__time {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.check-item__actions {
|
||||
@@ -272,12 +271,12 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
.history-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
@@ -305,22 +304,22 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--primary, #3b82f6);
|
||||
color: #1C1200;
|
||||
background: var(--primary);
|
||||
color: var(--color-text-heading);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover, #2563eb);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,15 +340,15 @@ import { AgentHealthResult } from '../../models/agent.models';
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentHealthTabComponent {
|
||||
/** Health check results */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AgentTasksTabComponent } from './agent-tasks-tab.component';
|
||||
import { AgentTask } from '../../models/agent.models';
|
||||
|
||||
@@ -32,7 +32,8 @@ describe('AgentTasksTabComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AgentTasksTabComponent, RouterTestingModule],
|
||||
imports: [AgentTasksTabComponent],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgentTasksTabComponent);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { AgentTask } from '../../models/agent.models';
|
||||
@@ -15,10 +15,9 @@ import { AgentTask } from '../../models/agent.models';
|
||||
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
@Component({
|
||||
selector: 'st-agent-tasks-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
selector: 'st-agent-tasks-tab',
|
||||
imports: [RouterLink],
|
||||
template: `
|
||||
<div class="agent-tasks-tab">
|
||||
<!-- Header -->
|
||||
<header class="tab-header">
|
||||
@@ -188,7 +187,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.agent-tasks-tab {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
@@ -218,20 +217,20 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
background: var(--surface-primary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--primary, #3b82f6);
|
||||
border-color: var(--primary, #3b82f6);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
@@ -257,7 +256,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
.queue-viz {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -265,7 +264,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.queue-items {
|
||||
@@ -279,13 +278,13 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
min-width: 120px;
|
||||
|
||||
&--running {
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,14 +295,14 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
.queue-item__progress {
|
||||
height: 4px;
|
||||
background: var(--surface-secondary, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue-item__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #3b82f6);
|
||||
background: var(--primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
@@ -319,8 +318,8 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.15s;
|
||||
|
||||
@@ -329,15 +328,15 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
&--running {
|
||||
border-left: 3px solid var(--primary, #3b82f6);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
border-left: 3px solid var(--status-success, #10b981);
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid var(--status-error, #ef4444);
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
|
||||
&--cancelled {
|
||||
@@ -361,27 +360,27 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
&--running {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success, #10b981);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
&--failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
&--cancelled {
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +388,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid rgba(59, 130, 246, 0.3);
|
||||
border-top-color: var(--primary, #3b82f6);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -418,8 +417,8 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
.task-item__id {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -427,10 +426,10 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
.task-item__ref {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
|
||||
a {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
@@ -442,7 +441,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
.task-item__progress-bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: var(--surface-secondary, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 3px;
|
||||
margin: 0.75rem 0;
|
||||
overflow: hidden;
|
||||
@@ -450,7 +449,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
|
||||
.task-item__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #3b82f6);
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -460,7 +459,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
right: 0;
|
||||
top: -1.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-item__error {
|
||||
@@ -472,7 +471,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
@@ -484,7 +483,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-item__actions {
|
||||
@@ -508,27 +507,27 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: transparent;
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@@ -539,7 +538,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
@@ -547,7 +546,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AgentTasksTabComponent {
|
||||
/** Agent tasks */
|
||||
|
||||
@@ -7,17 +7,16 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
import { Agent, getCapacityColor } from '../../models/agent.models';
|
||||
|
||||
type GroupBy = 'none' | 'environment' | 'status';
|
||||
|
||||
@Component({
|
||||
selector: 'st-capacity-heatmap',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'st-capacity-heatmap',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="capacity-heatmap">
|
||||
<!-- Header -->
|
||||
<header class="heatmap-header">
|
||||
@@ -42,16 +41,16 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-label">Utilization:</span>
|
||||
<div class="legend-scale">
|
||||
<span class="legend-item" style="--item-color: var(--capacity-low, #10b981)">
|
||||
<span class="legend-item" style="--item-color: var(--capacity-low)">
|
||||
<50%
|
||||
</span>
|
||||
<span class="legend-item" style="--item-color: var(--capacity-medium, #f59e0b)">
|
||||
<span class="legend-item" style="--item-color: var(--capacity-medium)">
|
||||
50-80%
|
||||
</span>
|
||||
<span class="legend-item" style="--item-color: var(--capacity-high, #f97316)">
|
||||
<span class="legend-item" style="--item-color: var(--capacity-high)">
|
||||
80-95%
|
||||
</span>
|
||||
<span class="legend-item" style="--item-color: var(--capacity-critical, #ef4444)">
|
||||
<span class="legend-item" style="--item-color: var(--capacity-critical)">
|
||||
>95%
|
||||
</span>
|
||||
</div>
|
||||
@@ -144,10 +143,10 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.capacity-heatmap {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
@@ -171,19 +170,19 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.group-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
background: var(--surface-primary);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #3b82f6);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,13 +193,13 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.legend-scale {
|
||||
@@ -213,7 +212,7 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
@@ -250,13 +249,13 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, #3b82f6);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--offline {
|
||||
opacity: 0.4;
|
||||
background: var(--surface-secondary, #e5e7eb) !important;
|
||||
background: var(--surface-secondary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +267,7 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
}
|
||||
|
||||
.heatmap-cell--offline .heatmap-cell__value {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@@ -285,12 +284,12 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.heatmap-group__count {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
@@ -301,8 +300,8 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
transform: translateY(-50%);
|
||||
width: 180px;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 20;
|
||||
@@ -331,7 +330,7 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
font-size: 0.75rem;
|
||||
|
||||
dt {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
@@ -344,9 +343,9 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -357,7 +356,7 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
@@ -368,12 +367,12 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-stat__label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -397,7 +396,7 @@ type GroupBy = 'none' | 'environment' | 'status';
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class CapacityHeatmapComponent {
|
||||
/** List of agents */
|
||||
@@ -463,13 +462,13 @@ export class CapacityHeatmapComponent {
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'var(--status-success, #10b981)';
|
||||
return 'var(--status-success)';
|
||||
case 'degraded':
|
||||
return 'var(--status-warning, #f59e0b)';
|
||||
return 'var(--status-warning)';
|
||||
case 'offline':
|
||||
return 'var(--status-error, #ef4444)';
|
||||
return 'var(--status-error)';
|
||||
default:
|
||||
return 'var(--status-unknown, #9ca3af)';
|
||||
return 'var(--status-unknown)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
|
||||
import { Agent, getStatusColor, getStatusLabel, getCapacityColor } from '../../models/agent.models';
|
||||
|
||||
@@ -21,10 +21,9 @@ interface ColumnConfig {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'st-fleet-comparison',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'st-fleet-comparison',
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="fleet-comparison">
|
||||
<!-- Toolbar -->
|
||||
<header class="comparison-toolbar">
|
||||
@@ -208,10 +207,10 @@ interface ColumnConfig {
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.fleet-comparison {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -222,8 +221,8 @@ interface ColumnConfig {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
@@ -240,7 +239,7 @@ interface ColumnConfig {
|
||||
|
||||
.toolbar-count {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
@@ -259,8 +258,8 @@ interface ColumnConfig {
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
@@ -270,7 +269,7 @@ interface ColumnConfig {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
@@ -299,8 +298,8 @@ interface ColumnConfig {
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&--warning {
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
color: var(--warning-text, #92400e);
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,13 +322,13 @@ interface ColumnConfig {
|
||||
.comparison-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-default, #e5e7eb);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
background: var(--surface-secondary);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
&.sortable {
|
||||
@@ -337,12 +336,12 @@ interface ColumnConfig {
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.sorted {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +354,7 @@ interface ColumnConfig {
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
&.row--offline {
|
||||
@@ -392,8 +391,8 @@ interface ColumnConfig {
|
||||
|
||||
.agent-id {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
background: var(--surface-secondary, #f3f4f6);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -406,8 +405,8 @@ interface ColumnConfig {
|
||||
font-weight: 500;
|
||||
|
||||
&--env {
|
||||
background: var(--tag-env-bg, #dbeafe);
|
||||
color: var(--tag-env-text, #1e40af);
|
||||
background: var(--tag-env-bg);
|
||||
color: var(--tag-env-text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +426,7 @@ interface ColumnConfig {
|
||||
gap: 0.25rem;
|
||||
|
||||
&--mismatch {
|
||||
color: var(--status-warning, #f59e0b);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +443,7 @@ interface ColumnConfig {
|
||||
.capacity-bar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--surface-secondary, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -464,22 +463,22 @@ interface ColumnConfig {
|
||||
}
|
||||
|
||||
.heartbeat {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cert-expiry {
|
||||
&--warning {
|
||||
color: var(--status-warning, #f59e0b);
|
||||
color: var(--status-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--critical {
|
||||
color: var(--status-error, #ef4444);
|
||||
color: var(--status-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--na {
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,12 +496,12 @@ interface ColumnConfig {
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--surface-primary, #ffffff);
|
||||
border-color: var(--border-default, #e5e7eb);
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-primary);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,24 +509,24 @@ interface ColumnConfig {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
background: var(--surface-hover, #f3f4f6);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.comparison-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-secondary, #f9fafb);
|
||||
border-top: 1px solid var(--border-default, #e5e7eb);
|
||||
background: var(--surface-secondary);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -543,7 +542,7 @@ interface ColumnConfig {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class FleetComparisonComponent {
|
||||
/** List of agents */
|
||||
|
||||
@@ -152,14 +152,14 @@ export interface AgentActionResult {
|
||||
export function getStatusColor(status: AgentStatus): string {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'var(--status-success, #10b981)';
|
||||
return 'var(--status-success)';
|
||||
case 'degraded':
|
||||
return 'var(--status-warning, #f59e0b)';
|
||||
return 'var(--status-warning)';
|
||||
case 'offline':
|
||||
return 'var(--status-error, #ef4444)';
|
||||
return 'var(--status-error)';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'var(--status-unknown, #9ca3af)';
|
||||
return 'var(--status-unknown)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +184,10 @@ export function getStatusLabel(status: AgentStatus): string {
|
||||
* Get capacity color based on utilization percentage.
|
||||
*/
|
||||
export function getCapacityColor(percent: number): string {
|
||||
if (percent < 50) return 'var(--capacity-low, #10b981)';
|
||||
if (percent < 80) return 'var(--capacity-medium, #f59e0b)';
|
||||
if (percent < 95) return 'var(--capacity-high, #f97316)';
|
||||
return 'var(--capacity-critical, #ef4444)';
|
||||
if (percent < 50) return 'var(--capacity-low)';
|
||||
if (percent < 80) return 'var(--capacity-medium)';
|
||||
if (percent < 95) return 'var(--capacity-high)';
|
||||
return 'var(--capacity-critical)';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,10 +37,9 @@ import {
|
||||
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-ai-run-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
selector: 'stellaops-ai-run-viewer',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="ai-run-viewer" [class.loading]="loading()">
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
@@ -300,12 +299,12 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.ai-run-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -316,14 +315,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -335,15 +334,15 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--error-color, #ef4444);
|
||||
color: var(--error-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -353,7 +352,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -370,8 +369,8 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.run-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
@@ -418,7 +417,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.run-section {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@@ -427,7 +426,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -437,8 +436,8 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -451,7 +450,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.info-item dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -496,15 +495,15 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
border: 2px solid var(--bg-primary, #fff);
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #3b82f6);
|
||||
background: var(--primary-color);
|
||||
border: 2px solid var(--bg-primary);
|
||||
box-shadow: 0 0 0 2px var(--primary-color);
|
||||
}
|
||||
|
||||
.marker-line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
background: var(--border-color, #e0e0e0);
|
||||
background: var(--border-color);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -524,19 +523,19 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.event-body {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.turn-content {
|
||||
@@ -549,18 +548,18 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
}
|
||||
|
||||
.user-turn {
|
||||
border-left: 3px solid var(--info-color, #0ea5e9);
|
||||
border-left: 3px solid var(--info-color);
|
||||
}
|
||||
|
||||
.assistant-turn {
|
||||
border-left: 3px solid var(--primary-color, #3b82f6);
|
||||
border-left: 3px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.grounding-score {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -579,7 +578,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
@@ -592,7 +591,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.pack-stats {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -601,7 +600,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -613,7 +612,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.action-target {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -654,7 +653,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.approval-by {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.approval-reason {
|
||||
@@ -673,7 +672,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.attestation-type, .attestation-id {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -698,9 +697,9 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.artifact-card {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.artifact-header {
|
||||
@@ -714,12 +713,12 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.artifact-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.artifact-name {
|
||||
@@ -731,19 +730,19 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.artifact-uri {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.artifact-footer {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.artifact-digest {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--text-tertiary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -789,12 +788,12 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AiRunViewerComponent implements OnInit, OnChanges {
|
||||
private readonly api = inject(AI_RUNS_API);
|
||||
|
||||
@@ -25,10 +25,9 @@ import {
|
||||
import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
@Component({
|
||||
selector: 'stellaops-ai-runs-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
selector: 'stellaops-ai-runs-list',
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="ai-runs-list">
|
||||
<!-- Header with filters -->
|
||||
<header class="list-header">
|
||||
@@ -150,12 +149,12 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.ai-runs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #fff);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
@@ -163,7 +162,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.list-title {
|
||||
@@ -180,14 +179,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.filter-select, .filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-select:focus, .filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.list-content {
|
||||
@@ -201,14 +200,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-color, #e0e0e0);
|
||||
border-top-color: var(--primary-color, #3b82f6);
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -220,15 +219,15 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -247,14 +246,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, #666);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.runs-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -264,7 +263,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
}
|
||||
|
||||
.run-row:hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.run-row.selected {
|
||||
@@ -273,7 +272,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.run-id-cell code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -302,7 +301,7 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
|
||||
.count-cell {
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attested-cell {
|
||||
@@ -316,11 +315,11 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
}
|
||||
|
||||
.no-attestation {
|
||||
color: var(--text-tertiary, #999);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -330,14 +329,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -348,14 +347,14 @@ import { AI_RUNS_API } from '../../core/api/ai-runs.client';
|
||||
}
|
||||
|
||||
.page-btn:not(:disabled):hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class AiRunsListComponent implements OnInit {
|
||||
private readonly api = inject(AI_RUNS_API);
|
||||
|
||||
@@ -59,11 +59,10 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-sbom-lake-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SkeletonComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
selector: 'app-sbom-lake-page',
|
||||
imports: [CommonModule, SkeletonComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="sbom-lake">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
@@ -510,7 +509,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.sbom-lake {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
@@ -532,12 +531,12 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
.page-subtitle {
|
||||
margin: 0;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.page-meta {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-muted, #D4C9A8);
|
||||
color: var(--text-color-muted);
|
||||
}
|
||||
.page-actions {
|
||||
display: flex;
|
||||
@@ -551,8 +550,8 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
background: var(--surface-card, #ffffff);
|
||||
border: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.filter-group {
|
||||
@@ -565,28 +564,28 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.filter-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
background: var(--surface-ground, #FFFCF5);
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
background: var(--surface-ground, #FFFCF5);
|
||||
color: var(--text-color, #3D2E0A);
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn--secondary {
|
||||
background: var(--surface-ground, #FFFCF5);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
@@ -603,9 +602,9 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--red-50, #fef2f2);
|
||||
border: 1px solid var(--red-200, #fecaca);
|
||||
color: var(--red-700, #b91c1c);
|
||||
background: var(--red-50);
|
||||
border: 1px solid var(--red-200);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
@@ -620,8 +619,8 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
gap: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: var(--surface-card, #ffffff);
|
||||
border: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
@@ -642,11 +641,11 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
.panel-subtitle {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.panel-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.panel-body {
|
||||
@@ -686,25 +685,25 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
.metric-row__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.metric-row__bar {
|
||||
height: 6px;
|
||||
background: var(--surface-ground, #FFF9ED);
|
||||
background: var(--surface-ground);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric-row__fill {
|
||||
height: 100%;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
.metric-row__fill--accent {
|
||||
background: var(--emerald-500, #10b981);
|
||||
background: var(--emerald-500);
|
||||
}
|
||||
.metric-row__value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color, #3D2E0A);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.metric-row__chips {
|
||||
display: flex;
|
||||
@@ -721,25 +720,25 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.severity-badge--critical { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
|
||||
.severity-badge--high { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
|
||||
.severity-badge--medium { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
|
||||
.severity-badge--low { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
|
||||
.severity-badge--unknown { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
|
||||
.severity-badge--critical { background: var(--red-100); color: var(--red-700); }
|
||||
.severity-badge--high { background: var(--orange-100); color: var(--orange-700); }
|
||||
.severity-badge--medium { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.severity-badge--low { background: var(--blue-100); color: var(--blue-700); }
|
||||
.severity-badge--unknown { background: var(--gray-100); color: var(--gray-600); }
|
||||
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-ground, #FFF9ED);
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flag--warning { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
|
||||
.flag--success { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
|
||||
.flag--warning { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.flag--success { background: var(--green-100); color: var(--green-700); }
|
||||
|
||||
.coverage-summary {
|
||||
display: grid;
|
||||
@@ -747,7 +746,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.coverage-stat {
|
||||
background: var(--surface-ground, #FFFCF5);
|
||||
background: var(--surface-ground);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
@@ -760,7 +759,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
.coverage-stat__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.coverage-list {
|
||||
display: flex;
|
||||
@@ -779,17 +778,17 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
.coverage-row__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.coverage-row__bar {
|
||||
height: 6px;
|
||||
background: var(--surface-ground, #FFF9ED);
|
||||
background: var(--surface-ground);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.coverage-row__fill {
|
||||
height: 100%;
|
||||
background: var(--emerald-500, #10b981);
|
||||
background: var(--emerald-500);
|
||||
}
|
||||
.coverage-row__value {
|
||||
font-size: 0.8rem;
|
||||
@@ -807,19 +806,19 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
}
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
background: var(--primary-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 6px;
|
||||
}
|
||||
.trend-bar--accent {
|
||||
background: var(--emerald-500, #10b981);
|
||||
background: var(--emerald-500);
|
||||
}
|
||||
.trend-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.trend-row {
|
||||
display: flex;
|
||||
@@ -830,7 +829,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
@@ -841,39 +840,39 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface-ground, #FFFCF5);
|
||||
background: var(--surface-ground);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.table-primary {
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.empty-callout {
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--surface-border, rgba(212, 201, 168, 0.3));
|
||||
border: 1px dashed var(--surface-border);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-color-secondary, #6B5A2E);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.empty-callout h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text-color, #3D2E0A);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -882,7 +881,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
`],
|
||||
`]
|
||||
})
|
||||
export class SbomLakePageComponent {
|
||||
private readonly analytics = inject(AnalyticsHttpClient);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user