stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

@@ -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',

View File

@@ -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"
]
}

View File

@@ -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.

View File

@@ -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": "."
}
}
}

View File

@@ -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
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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
}
}

View 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();

View File

@@ -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>

View File

@@ -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;

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -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,
},
],
};

View File

@@ -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

View File

@@ -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);

View File

@@ -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('-', '');

View File

@@ -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({

View File

@@ -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

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
});
});
}));
});
});

View File

@@ -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);

View File

@@ -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' });
});
}));
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
}));
});
});

View File

@@ -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))),
});
});
}));
});

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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();
}

View File

@@ -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();
});
});
}));
});

View File

@@ -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', () => {

View File

@@ -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();
},
});
});
}));
});

View File

@@ -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);

View File

@@ -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';

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -29,7 +29,7 @@ export class BrandingService {
// Default branding configuration
private readonly defaultBranding: BrandingConfiguration = {
tenantId: 'default',
title: 'StellaOps Dashboard',
title: 'Stella Ops Dashboard',
themeTokens: {}
};

View File

@@ -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');

View File

@@ -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,
};
}

View File

@@ -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);
}
}
}

View File

@@ -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' },
});
};

View File

@@ -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);
});
});

View 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'
};

View File

@@ -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();
});
});
}));
});

View File

@@ -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.
*/

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
`]
})

View File

@@ -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);
}
`],
})

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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;
}
`],

View File

@@ -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);

View File

@@ -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);
}
`],
})

View File

@@ -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);
}
`]
})

View File

@@ -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 {

View File

@@ -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);
}
`]
})

View File

@@ -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;
}

View File

@@ -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);
}
`]
})

View File

@@ -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);
}
`]
})

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 */

View File

@@ -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 */

View File

@@ -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 */

View File

@@ -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);

View File

@@ -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 */

View File

@@ -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)">
&lt;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)">
&gt;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)';
}
}

View File

@@ -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 */

View File

@@ -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)';
}
/**

View File

@@ -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);

View File

@@ -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);

View File

@@ -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