search and ai stabilization work, localization stablized.
This commit is contained in:
41
src/Web/StellaOps.Web/cdp-check.cjs
Normal file
41
src/Web/StellaOps.Web/cdp-check.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
// NO event handlers - they hang because of Zone.js
|
||||
|
||||
const response = await page.goto('https://stella-ops.local/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log('DOM loaded, status=' + response.status());
|
||||
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
// Wait for Angular
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
|
||||
// CDP evaluate - bypasses Zone.js
|
||||
const r1 = await client.send('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({title: document.title, bodyLen: document.body.innerHTML.length, text: document.body.innerText.substring(0,500), hasRouter: !!document.querySelector("router-outlet"), hasSplash: !!document.getElementById("stella-splash")})',
|
||||
timeout: 3000,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log('\nPage state:', r1.result.value);
|
||||
|
||||
// Screenshot via CDP
|
||||
const ss = await client.send('Page.captureScreenshot', { format: 'png' });
|
||||
const outPath = 'C:/dev/New folder/git.stella-ops.org/page-check.png';
|
||||
fs.writeFileSync(outPath, Buffer.from(ss.data, 'base64'));
|
||||
console.log('Screenshot saved to', outPath);
|
||||
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error('Fatal:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
297
src/Web/StellaOps.Web/e2e/click-navigation.e2e.spec.ts
Normal file
297
src/Web/StellaOps.Web/e2e/click-navigation.e2e.spec.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Click Navigation Tests
|
||||
* Verifies actual sidebar click navigation between sections.
|
||||
* Tests that clicking menu items transitions pages correctly.
|
||||
*/
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots';
|
||||
|
||||
async function snap(page: import('@playwright/test').Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function go(page: import('@playwright/test').Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
test.describe('Sidebar Click Navigation', () => {
|
||||
test('click through main sidebar sections from dashboard', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
// Click Releases in sidebar
|
||||
const releasesLink = page.locator('text=Releases').first();
|
||||
if (await releasesLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await releasesLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-01-releases');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
}
|
||||
|
||||
// Click Release Versions
|
||||
const versionsLink = page.locator('text=Release Versions').first();
|
||||
if (await versionsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await versionsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-02-release-versions');
|
||||
}
|
||||
|
||||
// Click Approvals Queue
|
||||
const approvalsLink = page.locator('text=Approvals Queue').first();
|
||||
if (await approvalsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await approvalsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-03-approvals-queue');
|
||||
}
|
||||
|
||||
const criticalErrors = errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
expect(criticalErrors, 'Critical errors during release nav: ' + criticalErrors.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('click through security section', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
// Click Posture in sidebar
|
||||
const postureLink = page.locator('text=Posture').first();
|
||||
if (await postureLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await postureLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-04-posture');
|
||||
}
|
||||
|
||||
// Click Triage
|
||||
const triageLink = page.locator('text=Triage').first();
|
||||
if (await triageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await triageLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-05-triage');
|
||||
}
|
||||
|
||||
// Click Supply-Chain Data
|
||||
const supplyLink = page.locator('text=Supply-Chain Data').first();
|
||||
if (await supplyLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await supplyLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-06-supply-chain');
|
||||
}
|
||||
|
||||
// Click Reachability
|
||||
const reachLink = page.locator('text=Reachability').first();
|
||||
if (await reachLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reachLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-07-reachability');
|
||||
}
|
||||
|
||||
// Click Reports
|
||||
const reportsLink = page.locator('text=Reports').first();
|
||||
if (await reportsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await reportsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-08-reports');
|
||||
}
|
||||
|
||||
const criticalErrors = errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
expect(criticalErrors, 'Critical errors during security nav: ' + criticalErrors.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('click through evidence section', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
// Click Evidence > Overview
|
||||
const overviewLink = page.locator('text=Overview').first();
|
||||
if (await overviewLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await overviewLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-09-evidence-overview');
|
||||
}
|
||||
|
||||
// Click Decision Capsules
|
||||
const capsulesLink = page.locator('text=Decision Capsules').first();
|
||||
if (await capsulesLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await capsulesLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-10-decision-capsules');
|
||||
}
|
||||
|
||||
// Click Replay & Verify
|
||||
const replayLink = page.locator('text=Replay').first();
|
||||
if (await replayLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await replayLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-11-replay');
|
||||
}
|
||||
|
||||
const criticalErrors = errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
expect(criticalErrors, 'Critical errors during evidence nav: ' + criticalErrors.join('\n')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Header Toolbar Interactions', () => {
|
||||
test('region dropdown opens', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// Try to click the Region dropdown
|
||||
const regionBtn = page.locator('text=All regions').first();
|
||||
if (await regionBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await regionBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap(page, 'click-12-region-dropdown');
|
||||
// Close by clicking elsewhere
|
||||
await page.locator('body').click({ position: { x: 600, y: 400 } });
|
||||
}
|
||||
});
|
||||
|
||||
test('environment dropdown opens', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const envBtn = page.locator('text=All environments').first();
|
||||
if (await envBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await envBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap(page, 'click-13-env-dropdown');
|
||||
await page.locator('body').click({ position: { x: 600, y: 400 } });
|
||||
}
|
||||
});
|
||||
|
||||
test('window dropdown opens', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const windowBtn = page.locator('text=24h').first();
|
||||
if (await windowBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await windowBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await snap(page, 'click-14-window-dropdown');
|
||||
await page.locator('body').click({ position: { x: 600, y: 400 } });
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard button returns home', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/posture');
|
||||
const dashBtn = page.locator('text=Dashboard').first();
|
||||
if (await dashBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await dashBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
// Should be back at dashboard
|
||||
const url = page.url();
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(20);
|
||||
await snap(page, 'click-15-back-to-dashboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tab Navigation Within Pages', () => {
|
||||
test('topology page tab switching', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/overview');
|
||||
|
||||
// Click Map tab
|
||||
const mapTab = page.locator('text=Map').first();
|
||||
if (await mapTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await mapTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-16-topology-map-tab');
|
||||
}
|
||||
|
||||
// Click Targets tab
|
||||
const targetsTab = page.locator('text=Targets').first();
|
||||
if (await targetsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await targetsTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-17-topology-targets-tab');
|
||||
}
|
||||
|
||||
// Click Hosts tab
|
||||
const hostsTab = page.locator('text=Hosts').first();
|
||||
if (await hostsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await hostsTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-18-topology-hosts-tab');
|
||||
}
|
||||
|
||||
// Click Agents tab
|
||||
const agentsTab = page.locator('text=Agents').first();
|
||||
if (await agentsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await agentsTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-19-topology-agents-tab');
|
||||
}
|
||||
});
|
||||
|
||||
test('policy governance tab switching', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/overview');
|
||||
|
||||
// Click Risk Budget tab
|
||||
const riskTab = page.locator('text=Risk Budget').first();
|
||||
if (await riskTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await riskTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-20-policy-risk-budget-tab');
|
||||
}
|
||||
|
||||
// Click Sealed Mode tab
|
||||
const sealedTab = page.locator('text=Sealed Mode').first();
|
||||
if (await sealedTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await sealedTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-21-policy-sealed-mode-tab');
|
||||
}
|
||||
|
||||
// Click Profiles tab
|
||||
const profilesTab = page.locator('text=Profiles').first();
|
||||
if (await profilesTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await profilesTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-22-policy-profiles-tab');
|
||||
}
|
||||
});
|
||||
|
||||
test('notifications page tab switching', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/notifications/rules');
|
||||
|
||||
// Click Channels tab
|
||||
const channelsTab = page.locator('text=Channels').first();
|
||||
if (await channelsTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await channelsTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-23-notifications-channels-tab');
|
||||
}
|
||||
|
||||
// Click Templates tab
|
||||
const templatesTab = page.locator('text=Templates').first();
|
||||
if (await templatesTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await templatesTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-24-notifications-templates-tab');
|
||||
}
|
||||
|
||||
// Click Delivery tab
|
||||
const deliveryTab = page.locator('text=Delivery').first();
|
||||
if (await deliveryTab.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await deliveryTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await snap(page, 'click-25-notifications-delivery-tab');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,8 +55,75 @@ const adminTestSession: StubAuthSession = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Minimal runtime config for deterministic SPA bootstrap in E2E. */
|
||||
const e2eRuntimeConfig = {
|
||||
setup: 'complete',
|
||||
authority: {
|
||||
issuer: 'https://127.0.0.1',
|
||||
clientId: 'stellaops-web-e2e',
|
||||
authorizeEndpoint: 'https://127.0.0.1/connect/authorize',
|
||||
tokenEndpoint: 'https://127.0.0.1/connect/token',
|
||||
logoutEndpoint: 'https://127.0.0.1/connect/logout',
|
||||
redirectUri: 'https://127.0.0.1/auth/callback',
|
||||
postLogoutRedirectUri: 'https://127.0.0.1/',
|
||||
scope: 'openid profile ui.read',
|
||||
audience: 'stellaops',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: '',
|
||||
gateway: '',
|
||||
policy: '',
|
||||
scanner: '',
|
||||
concelier: '',
|
||||
attestor: '',
|
||||
},
|
||||
telemetry: {
|
||||
sampleRate: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const test = base.extend<{ authenticatedPage: Page }>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
// Ensure APP_INITIALIZER config resolution does not hang on missing backend proxy targets.
|
||||
await page.route('**/platform/envsettings.json', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(e2eRuntimeConfig),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/config.json', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(e2eRuntimeConfig),
|
||||
});
|
||||
});
|
||||
|
||||
// Keep backend probe guard reachable in isolated E2E runs.
|
||||
await page.route('https://127.0.0.1/.well-known/openid-configuration', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
issuer: 'https://127.0.0.1',
|
||||
authorization_endpoint: 'https://127.0.0.1/connect/authorize',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent background health polling from failing the shell bootstrap path.
|
||||
await page.route('**/health', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok' }),
|
||||
});
|
||||
});
|
||||
|
||||
// Intercept branding endpoint that can return 500 in dev/Docker
|
||||
await page.route('**/console/branding**', (route) => {
|
||||
route.fulfill({
|
||||
|
||||
504
src/Web/StellaOps.Web/e2e/i18n-translations.e2e.spec.ts
Normal file
504
src/Web/StellaOps.Web/e2e/i18n-translations.e2e.spec.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* i18n Translation System — E2E Tests
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. Platform translation API (/platform/i18n/{locale}.json) is requested on load
|
||||
* 2. Translated strings render correctly in the UI (no raw keys visible)
|
||||
* 3. Offline fallback works when Platform API is unavailable
|
||||
* 4. Locale switching re-fetches and updates displayed text
|
||||
* 5. Multiple routes render translated content without raw keys
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
|
||||
/** Regex to match the Platform i18n API URL pattern */
|
||||
const I18N_API_PATTERN = /\/platform\/i18n\/.*\.json/;
|
||||
|
||||
/** Subset of en-US translations for verification */
|
||||
const EN_US_BUNDLE: Record<string, string> = {
|
||||
'ui.loading.skeleton': 'Loading...',
|
||||
'ui.error.generic': 'Something went wrong.',
|
||||
'ui.error.network': 'Network error. Check your connection.',
|
||||
'ui.actions.save': 'Save',
|
||||
'ui.actions.cancel': 'Cancel',
|
||||
'ui.actions.delete': 'Delete',
|
||||
'ui.actions.confirm': 'Confirm',
|
||||
'ui.actions.close': 'Close',
|
||||
'ui.actions.retry': 'Retry',
|
||||
'ui.actions.search': 'Search',
|
||||
'ui.actions.export': 'Export',
|
||||
'ui.actions.refresh': 'Refresh',
|
||||
'ui.actions.sign_in': 'Sign in',
|
||||
'ui.labels.status': 'Status',
|
||||
'ui.labels.severity': 'Severity',
|
||||
'ui.labels.details': 'Details',
|
||||
'ui.labels.filters': 'Filters',
|
||||
'ui.severity.critical': 'Critical',
|
||||
'ui.severity.high': 'High',
|
||||
'ui.severity.medium': 'Medium',
|
||||
'ui.severity.low': 'Low',
|
||||
'ui.severity.info': 'Info',
|
||||
'ui.severity.none': 'None',
|
||||
'ui.release_orchestrator.title': 'Release Orchestrator',
|
||||
'ui.release_orchestrator.subtitle': 'Pipeline overview and release management',
|
||||
'ui.release_orchestrator.pipeline_runs': 'Pipeline Runs',
|
||||
'ui.risk_dashboard.title': 'Risk Profiles',
|
||||
'ui.risk_dashboard.subtitle': 'Tenant-scoped risk posture with deterministic ordering.',
|
||||
'ui.risk_dashboard.search_placeholder': 'Title contains',
|
||||
'ui.findings.title': 'Findings',
|
||||
'ui.findings.search_placeholder': 'Search findings...',
|
||||
'ui.findings.no_findings': 'No findings to display.',
|
||||
'ui.sources_dashboard.title': 'Sources Dashboard',
|
||||
'ui.timeline.title': 'Timeline',
|
||||
'ui.timeline.empty_state': 'Enter a correlation ID to view the event timeline',
|
||||
'ui.exception_center.title': 'Exception Center',
|
||||
'ui.evidence_thread.title_default': 'Evidence Thread',
|
||||
'ui.first_signal.label': 'First signal',
|
||||
'ui.first_signal.waiting': 'Waiting for first signal\u2026',
|
||||
'ui.first_signal.kind.queued': 'Queued',
|
||||
'ui.first_signal.kind.started': 'Started',
|
||||
'ui.first_signal.kind.succeeded': 'Succeeded',
|
||||
'ui.first_signal.kind.failed': 'Failed',
|
||||
'ui.locale.en_us': 'English (US)',
|
||||
'ui.locale.de_de': 'German (DE)',
|
||||
'common.error.generic': 'Something went wrong.',
|
||||
'common.error.not_found': 'The requested resource was not found.',
|
||||
'common.actions.save': 'Save',
|
||||
'common.actions.cancel': 'Cancel',
|
||||
'common.status.healthy': 'Healthy',
|
||||
'common.status.active': 'Active',
|
||||
'common.status.pending': 'Pending',
|
||||
'common.status.failed': 'Failed',
|
||||
'common.severity.critical': 'Critical',
|
||||
'common.severity.high': 'High',
|
||||
'common.severity.medium': 'Medium',
|
||||
'common.severity.low': 'Low',
|
||||
};
|
||||
|
||||
/** de-DE translation bundle for locale switch test */
|
||||
const DE_DE_BUNDLE: Record<string, string> = {
|
||||
'ui.actions.save': 'Speichern',
|
||||
'ui.actions.cancel': 'Abbrechen',
|
||||
'ui.actions.delete': 'L\u00f6schen',
|
||||
'ui.actions.search': 'Suche',
|
||||
'ui.release_orchestrator.title': 'Release-Orchestrator',
|
||||
'ui.risk_dashboard.title': 'Risikoprofile',
|
||||
'ui.findings.title': 'Ergebnisse',
|
||||
'ui.timeline.title': 'Zeitleiste',
|
||||
'ui.exception_center.title': 'Ausnahmezentrum',
|
||||
'ui.locale.en_us': 'Englisch (US)',
|
||||
'ui.locale.de_de': 'Deutsch (DE)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect any console warnings about missing translation keys.
|
||||
*/
|
||||
function setupTranslationWarningCollector(page: import('@playwright/test').Page) {
|
||||
const warnings: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
if (msg.type() === 'warning' && text.includes('Translation key not found')) {
|
||||
warnings.push(text);
|
||||
}
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept translation API requests using a regex pattern for reliability.
|
||||
* Returns a tracker object with captured request data.
|
||||
*/
|
||||
async function mockTranslationApi(
|
||||
page: import('@playwright/test').Page,
|
||||
bundle: Record<string, string> = EN_US_BUNDLE,
|
||||
options?: { bundleByLocale?: Record<string, Record<string, string>> }
|
||||
) {
|
||||
const tracker = {
|
||||
requested: false,
|
||||
locales: [] as string[],
|
||||
urls: [] as string[],
|
||||
headers: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
await page.route(I18N_API_PATTERN, async (route) => {
|
||||
const url = route.request().url();
|
||||
const locale = url.match(/\/platform\/i18n\/(.+?)\.json/)?.[1] ?? '';
|
||||
|
||||
tracker.requested = true;
|
||||
tracker.locales.push(locale);
|
||||
tracker.urls.push(url);
|
||||
tracker.headers = route.request().headers();
|
||||
|
||||
const responseBundle = options?.bundleByLocale?.[locale] ?? bundle;
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responseBundle),
|
||||
});
|
||||
});
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
test.describe('i18n Translation Loading', () => {
|
||||
test('translations are loaded and page renders without raw keys', async ({ authenticatedPage: page }) => {
|
||||
const tracker = await mockTranslationApi(page);
|
||||
|
||||
// Passive listener to capture ALL request URLs
|
||||
const allRequestUrls: string[] = [];
|
||||
page.on('request', (req) => allRequestUrls.push(req.url()));
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify translations are active — page renders meaningful content, not raw keys
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(bodyText.trim().length, 'Page should render content').toBeGreaterThan(50);
|
||||
|
||||
// No raw translation keys should be visible in the page
|
||||
const rawKeyLines = bodyText.split('\n').filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
|
||||
});
|
||||
expect(rawKeyLines, `Raw keys found: ${rawKeyLines.join(', ')}`).toHaveLength(0);
|
||||
|
||||
// If the new Platform i18n API is active, verify it was called correctly
|
||||
const i18nRequests = allRequestUrls.filter((url) => url.includes('/platform/i18n/'));
|
||||
if (tracker.requested) {
|
||||
expect(tracker.locales[0], 'Default locale should be en-US').toBe('en-US');
|
||||
} else if (i18nRequests.length > 0) {
|
||||
expect(i18nRequests[0]).toContain('en-US');
|
||||
}
|
||||
// If neither detected, translations are loaded via embedded/inline bundle (pre-build)
|
||||
});
|
||||
|
||||
test('loads translations and renders them (no raw keys visible)', async ({ authenticatedPage: page }) => {
|
||||
const translationWarnings = setupTranslationWarningCollector(page);
|
||||
await mockTranslationApi(page);
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Page should render without excessive missing-key warnings
|
||||
expect(
|
||||
translationWarnings.length,
|
||||
`Unexpected missing translations: ${translationWarnings.join(', ')}`
|
||||
).toBeLessThan(5);
|
||||
});
|
||||
|
||||
test('falls back to embedded offline bundle when Platform API fails', async ({ authenticatedPage: page }) => {
|
||||
// Make the Platform API return 500
|
||||
await page.route(I18N_API_PATTERN, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'Internal Server Error',
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Page should still render (fallback bundle loaded)
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(
|
||||
bodyText.trim().length,
|
||||
'Page should render content even when API fails'
|
||||
).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('falls back to embedded offline bundle when Platform API times out', async ({ authenticatedPage: page }) => {
|
||||
// Simulate network timeout by aborting
|
||||
await page.route(I18N_API_PATTERN, async (route) => {
|
||||
await route.abort('timedout');
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(
|
||||
bodyText.trim().length,
|
||||
'Page should render content even with network timeout'
|
||||
).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('i18n Translated Content on Routes', () => {
|
||||
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||
await mockTranslationApi(page);
|
||||
});
|
||||
|
||||
/**
|
||||
* Routes to verify. Each route should render some translated content.
|
||||
* Some routes may only show minimal content (e.g., "Skip to main content")
|
||||
* if their data APIs are not mocked, so we verify no raw keys are shown.
|
||||
*/
|
||||
const ROUTES_WITH_TITLES: {
|
||||
path: string;
|
||||
name: string;
|
||||
expectedText?: string;
|
||||
}[] = [
|
||||
{ path: '/findings', name: 'Findings', expectedText: 'Findings' },
|
||||
{ path: '/', name: 'Control Plane' },
|
||||
{ path: '/operations/orchestrator', name: 'Release Orchestrator' },
|
||||
{ path: '/security', name: 'Risk Dashboard' },
|
||||
{ path: '/timeline', name: 'Timeline' },
|
||||
{ path: '/policy/exceptions', name: 'Exception Center' },
|
||||
];
|
||||
|
||||
for (const route of ROUTES_WITH_TITLES) {
|
||||
test(`renders ${route.name} (${route.path}) without raw translation keys`, async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const translationWarnings = setupTranslationWarningCollector(page);
|
||||
|
||||
await navigateAndWait(page, route.path, { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
|
||||
// Page should have some content
|
||||
expect(bodyText.trim().length, `${route.name} should render content`).toBeGreaterThan(5);
|
||||
|
||||
// No raw translation keys should be visible
|
||||
const lines = bodyText.split('\n');
|
||||
const rawKeyLines = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
|
||||
});
|
||||
|
||||
expect(
|
||||
rawKeyLines,
|
||||
`Raw translation keys on ${route.path}: ${rawKeyLines.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
|
||||
// If we expect specific text AND it's available, verify
|
||||
if (route.expectedText && bodyText.length > 50) {
|
||||
expect(bodyText).toContain(route.expectedText);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('i18n No Raw Keys on Navigation', () => {
|
||||
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||
await mockTranslationApi(page);
|
||||
});
|
||||
|
||||
test('no raw i18n keys across multi-route navigation', async ({ authenticatedPage: page }) => {
|
||||
const translationWarnings = setupTranslationWarningCollector(page);
|
||||
const routesToVisit = ['/', '/security', '/findings', '/policy/exceptions'];
|
||||
|
||||
for (const route of routesToVisit) {
|
||||
await navigateAndWait(page, route, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
const lines = bodyText.split('\n');
|
||||
|
||||
const rawKeyLines = lines.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
|
||||
});
|
||||
|
||||
expect(
|
||||
rawKeyLines,
|
||||
`Raw translation keys on ${route}: ${rawKeyLines.join(', ')}`
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('i18n Locale Switching', () => {
|
||||
test('switching locale from selector fetches de-DE bundle and renders German text', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const tracker = await mockTranslationApi(page, EN_US_BUNDLE, {
|
||||
bundleByLocale: {
|
||||
'en-US': EN_US_BUNDLE,
|
||||
'de-DE': DE_DE_BUNDLE,
|
||||
},
|
||||
});
|
||||
|
||||
// This route maintains background activity; avoid networkidle waits for this case.
|
||||
await page.goto('/operations/orchestrator', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('#topbar-locale-select')).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.selectOption('#topbar-locale-select', 'de-DE');
|
||||
await page.waitForFunction(
|
||||
() => localStorage.getItem('stellaops_locale') === 'de-DE',
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(
|
||||
tracker.locales.includes('de-DE'),
|
||||
`Expected de-DE translation request, got locales: ${tracker.locales.join(', ')}`
|
||||
).toBeTruthy();
|
||||
|
||||
await expect(page.locator('#topbar-locale-select option[value="de-DE"]')).toHaveText(
|
||||
'Deutsch (DE)',
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
await expect(page.locator('body')).toContainText('Deutsch (DE)');
|
||||
});
|
||||
|
||||
test('locale preference can be saved and persists in localStorage', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
await mockTranslationApi(page, EN_US_BUNDLE, {
|
||||
bundleByLocale: {
|
||||
'en-US': EN_US_BUNDLE,
|
||||
'de-DE': DE_DE_BUNDLE,
|
||||
},
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Set locale preference to de-DE (simulates what I18nService.setLocale does)
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('stellaops_locale', 'de-DE');
|
||||
});
|
||||
|
||||
// Verify the preference was persisted
|
||||
const savedLocale = await page.evaluate(() => localStorage.getItem('stellaops_locale'));
|
||||
expect(savedLocale).toBe('de-DE');
|
||||
|
||||
// Reload the page
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify locale preference survived the reload
|
||||
const persistedLocale = await page.evaluate(() => localStorage.getItem('stellaops_locale'));
|
||||
expect(persistedLocale).toBe('de-DE');
|
||||
|
||||
// Page should still render without raw keys after locale switch
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(bodyText.trim().length, 'Page should render after locale switch').toBeGreaterThan(50);
|
||||
|
||||
const rawKeyLines = bodyText.split('\n').filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
|
||||
});
|
||||
expect(rawKeyLines, `Raw keys after locale switch: ${rawKeyLines.join(', ')}`).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('saved locale persists in localStorage', async ({ authenticatedPage: page }) => {
|
||||
await mockTranslationApi(page);
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Set locale via localStorage (as setLocale would)
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('stellaops_locale', 'fr-FR');
|
||||
});
|
||||
|
||||
// Verify the preference was persisted
|
||||
const savedLocale = await page.evaluate(() => {
|
||||
return localStorage.getItem('stellaops_locale');
|
||||
});
|
||||
|
||||
expect(savedLocale).toBe('fr-FR');
|
||||
|
||||
// After reload, the app should read from localStorage
|
||||
const allRequestUrls: string[] = [];
|
||||
page.on('request', (req) => allRequestUrls.push(req.url()));
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify either: the fr-FR locale was requested, or localStorage still has fr-FR
|
||||
const frFrRequested = allRequestUrls.some(
|
||||
(url) => url.includes('/platform/i18n/') && url.includes('fr-FR')
|
||||
);
|
||||
const stillPersisted = await page.evaluate(() => {
|
||||
return localStorage.getItem('stellaops_locale');
|
||||
});
|
||||
|
||||
// At minimum, the locale preference should persist in localStorage
|
||||
expect(stillPersisted).toBe('fr-FR');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('i18n Translation Pipe in Templates', () => {
|
||||
test.beforeEach(async ({ authenticatedPage: page }) => {
|
||||
await mockTranslationApi(page);
|
||||
});
|
||||
|
||||
test('severity labels render as translated text, not raw keys', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
await navigateAndWait(page, '/security', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
|
||||
// Should NOT show raw keys
|
||||
expect(bodyText).not.toContain('ui.severity.critical');
|
||||
expect(bodyText).not.toContain('ui.severity.high');
|
||||
expect(bodyText).not.toContain('ui.severity.medium');
|
||||
});
|
||||
|
||||
test('action buttons render translated labels, not raw keys', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
|
||||
// Should NOT show raw keys as visible text
|
||||
expect(bodyText).not.toContain('ui.actions.save');
|
||||
expect(bodyText).not.toContain('ui.actions.cancel');
|
||||
expect(bodyText).not.toContain('ui.actions.delete');
|
||||
expect(bodyText).not.toContain('ui.actions.sign_in');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('i18n API Contract', () => {
|
||||
test('translation bundle keys follow flat dot-path format', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
// Verify our expected bundle format is valid
|
||||
for (const [key, value] of Object.entries(EN_US_BUNDLE)) {
|
||||
expect(typeof key).toBe('string');
|
||||
expect(typeof value).toBe('string');
|
||||
// Keys follow the dot-path pattern: namespace.feature.field[.subfield...]
|
||||
expect(key).toMatch(/^[\w]+\.[\w]+\.[\w.]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('Platform API is requested with correct URL structure', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const tracker = await mockTranslationApi(page);
|
||||
|
||||
const requestPromise = page
|
||||
.waitForRequest((req) => I18N_API_PATTERN.test(req.url()), { timeout: 15_000 })
|
||||
.catch(() => null);
|
||||
|
||||
await navigateAndWait(page, '/', { timeout: 30_000 });
|
||||
const req = await requestPromise;
|
||||
|
||||
// Verify the request URL structure (either from mock or actual)
|
||||
if (req) {
|
||||
const url = new URL(req.url());
|
||||
expect(url.pathname).toMatch(/^\/platform\/i18n\/[\w-]+\.json$/);
|
||||
} else if (tracker.urls.length > 0) {
|
||||
expect(tracker.urls[0]).toMatch(/\/platform\/i18n\/[\w-]+\.json/);
|
||||
}
|
||||
|
||||
// At minimum, translations loaded (either from mock or real server)
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(bodyText.trim().length).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
329
src/Web/StellaOps.Web/e2e/identity-providers.e2e.spec.ts
Normal file
329
src/Web/StellaOps.Web/e2e/identity-providers.e2e.spec.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait, assertPageHasContent } from './helpers/nav.helper';
|
||||
|
||||
/**
|
||||
* E2E tests for the Identity Providers settings page.
|
||||
*
|
||||
* These tests use the auth fixture which mocks the backend API.
|
||||
* The MockIdentityProviderClient in app.config.ts serves mock data,
|
||||
* so these tests verify UI rendering and interaction without a live backend.
|
||||
*/
|
||||
|
||||
const sampleLdapProvider = {
|
||||
id: 'e2e-ldap-id',
|
||||
name: 'E2E LDAP',
|
||||
type: 'ldap',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
host: 'ldap.e2e.test',
|
||||
port: '389',
|
||||
bindDn: 'cn=admin,dc=e2e,dc=test',
|
||||
bindPassword: 'secret',
|
||||
searchBase: 'dc=e2e,dc=test',
|
||||
},
|
||||
description: 'E2E LDAP test provider',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-02-24T00:00:00Z',
|
||||
updatedAt: '2026-02-24T00:00:00Z',
|
||||
createdBy: 'e2e-admin',
|
||||
updatedBy: 'e2e-admin',
|
||||
};
|
||||
|
||||
const sampleSamlProvider = {
|
||||
id: 'e2e-saml-id',
|
||||
name: 'E2E SAML',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
spEntityId: 'stellaops-e2e-sp',
|
||||
idpEntityId: 'https://idp.e2e.test',
|
||||
idpSsoUrl: 'https://idp.e2e.test/sso',
|
||||
},
|
||||
description: 'E2E SAML test provider',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-02-24T00:00:00Z',
|
||||
updatedAt: '2026-02-24T00:00:00Z',
|
||||
createdBy: 'e2e-admin',
|
||||
updatedBy: 'e2e-admin',
|
||||
};
|
||||
|
||||
const sampleOidcProvider = {
|
||||
id: 'e2e-oidc-id',
|
||||
name: 'E2E OIDC',
|
||||
type: 'oidc',
|
||||
enabled: false,
|
||||
configuration: {
|
||||
authority: 'https://oidc.e2e.test',
|
||||
clientId: 'stellaops-e2e',
|
||||
clientSecret: 'e2e-secret',
|
||||
},
|
||||
description: 'E2E OIDC test provider',
|
||||
healthStatus: 'disabled',
|
||||
createdAt: '2026-02-24T00:00:00Z',
|
||||
updatedAt: '2026-02-24T00:00:00Z',
|
||||
createdBy: 'e2e-admin',
|
||||
updatedBy: 'e2e-admin',
|
||||
};
|
||||
|
||||
test.describe('Identity Providers Settings Page', () => {
|
||||
test('should load page and display content', async ({ authenticatedPage: page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && /NG0\d{3,4}/.test(msg.text())) {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Mock the identity providers API
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([sampleLdapProvider, sampleSamlProvider, sampleOidcProvider]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ type: 'standard', displayName: 'Standard', requiredFields: [], optionalFields: [] },
|
||||
{
|
||||
type: 'ldap',
|
||||
displayName: 'LDAP / Active Directory',
|
||||
requiredFields: [
|
||||
{ name: 'host', displayName: 'Host', fieldType: 'text', defaultValue: null, description: null },
|
||||
{ name: 'port', displayName: 'Port', fieldType: 'number', defaultValue: '389', description: null },
|
||||
{ name: 'bindDn', displayName: 'Bind DN', fieldType: 'text', defaultValue: null, description: null },
|
||||
{ name: 'bindPassword', displayName: 'Bind Password', fieldType: 'secret', defaultValue: null, description: null },
|
||||
{ name: 'searchBase', displayName: 'Search Base', fieldType: 'text', defaultValue: null, description: null },
|
||||
],
|
||||
optionalFields: [],
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
displayName: 'SAML 2.0',
|
||||
requiredFields: [
|
||||
{ name: 'spEntityId', displayName: 'SP Entity ID', fieldType: 'text', defaultValue: null, description: null },
|
||||
{ name: 'idpEntityId', displayName: 'IdP Entity ID', fieldType: 'text', defaultValue: null, description: null },
|
||||
],
|
||||
optionalFields: [],
|
||||
},
|
||||
{
|
||||
type: 'oidc',
|
||||
displayName: 'OpenID Connect',
|
||||
requiredFields: [
|
||||
{ name: 'authority', displayName: 'Authority', fieldType: 'url', defaultValue: null, description: null },
|
||||
{ name: 'clientId', displayName: 'Client ID', fieldType: 'text', defaultValue: null, description: null },
|
||||
],
|
||||
optionalFields: [],
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await assertPageHasContent(page);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should show empty state with no providers', async ({ authenticatedPage: page }) => {
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const emptyState = page.locator('.idp-empty-state');
|
||||
if (await emptyState.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(emptyState).toContainText('No identity providers');
|
||||
}
|
||||
});
|
||||
|
||||
test('should display provider cards with correct type badges', async ({ authenticatedPage: page }) => {
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([sampleLdapProvider, sampleSamlProvider, sampleOidcProvider]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify provider names are visible
|
||||
const ldapName = page.locator('text=E2E LDAP').first();
|
||||
const samlName = page.locator('text=E2E SAML').first();
|
||||
const oidcName = page.locator('text=E2E OIDC').first();
|
||||
|
||||
if (await ldapName.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(ldapName).toBeVisible();
|
||||
}
|
||||
if (await samlName.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(samlName).toBeVisible();
|
||||
}
|
||||
if (await oidcName.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(oidcName).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should open add provider wizard on button click', async ({ authenticatedPage: page }) => {
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ type: 'standard', displayName: 'Standard', requiredFields: [], optionalFields: [] },
|
||||
{ type: 'ldap', displayName: 'LDAP', requiredFields: [], optionalFields: [] },
|
||||
{ type: 'saml', displayName: 'SAML', requiredFields: [], optionalFields: [] },
|
||||
{ type: 'oidc', displayName: 'OIDC', requiredFields: [], optionalFields: [] },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const addButton = page.locator('button:has-text("Add Provider")').first();
|
||||
if (await addButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await addButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Wizard should be visible
|
||||
const wizard = page.locator('app-add-provider-wizard, .wizard-overlay').first();
|
||||
if (await wizard.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(wizard).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle enable/disable toggle', async ({ authenticatedPage: page }) => {
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([sampleLdapProvider]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock disable endpoint
|
||||
await page.route('**/api/v1/platform/identity-providers/*/disable', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...sampleLdapProvider, enabled: false, healthStatus: 'disabled' }),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find disable/enable toggle button
|
||||
const toggleBtn = page.locator('button:has-text("Disable")').first();
|
||||
if (await toggleBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await toggleBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle delete provider', async ({ authenticatedPage: page }) => {
|
||||
await page.route('**/api/v1/platform/identity-providers', (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([sampleLdapProvider]),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/platform/identity-providers/types', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock delete endpoint
|
||||
await page.route('**/api/v1/platform/identity-providers/*', (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
route.fulfill({ status: 204 });
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/settings/identity-providers', { timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const deleteBtn = page.locator('button:has-text("Delete")').first();
|
||||
if (await deleteBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await deleteBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
568
src/Web/StellaOps.Web/e2e/interactive-smoke.e2e.spec.ts
Normal file
568
src/Web/StellaOps.Web/e2e/interactive-smoke.e2e.spec.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Interactive Smoke Tests - Section by Section
|
||||
* Tests actual UI interactions, clicks, navigation elements on every screen.
|
||||
* Takes screenshots for visual verification.
|
||||
*/
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots';
|
||||
|
||||
async function snap(page: import('@playwright/test').Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: import('@playwright/test').Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function go(page: import('@playwright/test').Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// SECTION 1: Mission Control
|
||||
test.describe('Section 1: Mission Control', () => {
|
||||
test('dashboard loads with widgets', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
await snap(page, '01-mission-control-dashboard');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(20);
|
||||
const nav = page.locator('nav, [role="navigation"], .sidebar, .sidenav, mat-sidenav');
|
||||
const navCount = await nav.count();
|
||||
expect(navCount, 'Should have navigation element').toBeGreaterThanOrEqual(1);
|
||||
const criticalErrors = errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
expect(criticalErrors, 'Critical errors: ' + criticalErrors.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('sidebar navigation has main sections', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const links = page.locator('a[href], [routerlink], mat-list-item, .nav-item, .menu-item');
|
||||
const count = await links.count();
|
||||
expect(count, 'Should have navigation links').toBeGreaterThan(3);
|
||||
await snap(page, '01-mission-control-nav');
|
||||
});
|
||||
|
||||
test('mission control alerts page', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/mission-control/alerts');
|
||||
await snap(page, '01-mission-control-alerts');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
const criticalErrors = errors.filter(e => e.includes('NG0'));
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('mission control activity page', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/mission-control/activity');
|
||||
await snap(page, '01-mission-control-activity');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
const criticalErrors = errors.filter(e => e.includes('NG0'));
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 2: Releases
|
||||
test.describe('Section 2: Releases', () => {
|
||||
test('releases overview loads', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/releases/overview');
|
||||
await snap(page, '02-releases-overview');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
const criticalErrors = errors.filter(e => e.includes('NG0'));
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('release versions page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/versions');
|
||||
await snap(page, '02-releases-versions');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('releases runs page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/runs');
|
||||
await snap(page, '02-releases-runs');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('approvals queue page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/approvals');
|
||||
await snap(page, '02-releases-approvals');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('promotion queue page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/promotion-queue');
|
||||
await snap(page, '02-releases-promotion-queue');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('environments page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/environments');
|
||||
await snap(page, '02-releases-environments');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('deployments page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/releases/deployments');
|
||||
await snap(page, '02-releases-deployments');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 3: Security
|
||||
test.describe('Section 3: Security', () => {
|
||||
test('security posture page', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/security/posture');
|
||||
await snap(page, '03-security-posture');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
const criticalErrors = errors.filter(e => e.includes('NG0'));
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('security triage page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/triage');
|
||||
await snap(page, '03-security-triage');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('advisories and VEX page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/advisories-vex');
|
||||
await snap(page, '03-security-advisories-vex');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('disposition center page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/disposition');
|
||||
await snap(page, '03-security-disposition');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('supply chain data page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/supply-chain-data');
|
||||
await snap(page, '03-security-supply-chain');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('reachability center page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/reachability');
|
||||
await snap(page, '03-security-reachability');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('security reports page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security/reports');
|
||||
await snap(page, '03-security-reports');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 4: Evidence
|
||||
test.describe('Section 4: Evidence', () => {
|
||||
test('evidence overview', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/overview');
|
||||
await snap(page, '04-evidence-overview');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('decision capsules page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/capsules');
|
||||
await snap(page, '04-evidence-capsules');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('verify and replay page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/verify-replay');
|
||||
await snap(page, '04-evidence-verify-replay');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('evidence exports page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/exports');
|
||||
await snap(page, '04-evidence-exports');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('audit log dashboard', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/audit-log');
|
||||
await snap(page, '04-evidence-audit-log');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('audit log events', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/audit-log/events');
|
||||
await snap(page, '04-evidence-audit-events');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('audit timeline search', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/audit-log/timeline');
|
||||
await snap(page, '04-evidence-audit-timeline');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 5: Ops - Operations
|
||||
test.describe('Section 5: Ops - Operations', () => {
|
||||
test('ops overview', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations');
|
||||
await snap(page, '05-ops-overview');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('jobs and queues', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/jobs-queues');
|
||||
await snap(page, '05-ops-jobs-queues');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('system health', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/system-health');
|
||||
await snap(page, '05-ops-system-health');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('orchestrator dashboard', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/orchestrator');
|
||||
await snap(page, '05-ops-orchestrator');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('doctor diagnostics', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/doctor');
|
||||
await snap(page, '05-ops-doctor');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('notifications', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/notifications');
|
||||
await snap(page, '05-ops-notifications');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('AI runs list', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations/ai-runs');
|
||||
await snap(page, '05-ops-ai-runs');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 6: Ops - Integrations
|
||||
test.describe('Section 6: Ops - Integrations', () => {
|
||||
test('integration hub', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/integrations');
|
||||
await snap(page, '06-integrations-hub');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('registries page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/integrations/registries');
|
||||
await snap(page, '06-integrations-registries');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('source control page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/integrations/scm');
|
||||
await snap(page, '06-integrations-scm');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('CI/CD page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/integrations/ci');
|
||||
await snap(page, '06-integrations-ci');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('runtime hosts page', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/integrations/runtime-hosts');
|
||||
await snap(page, '06-integrations-runtime');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 7: Ops - Policy
|
||||
test.describe('Section 7: Ops - Policy', () => {
|
||||
test('policy overview', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/overview');
|
||||
await snap(page, '07-policy-overview');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('policy baselines', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/baselines');
|
||||
await snap(page, '07-policy-baselines');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('gate catalog', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/gates');
|
||||
await snap(page, '07-policy-gates');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('shadow mode / simulation', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/simulation');
|
||||
await snap(page, '07-policy-simulation');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('policy lint', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/simulation/lint');
|
||||
await snap(page, '07-policy-lint');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('risk budget', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/risk-budget');
|
||||
await snap(page, '07-policy-risk-budget');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('sealed mode', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/sealed-mode');
|
||||
await snap(page, '07-policy-sealed-mode');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('policy profiles', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/profiles');
|
||||
await snap(page, '07-policy-profiles');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 8: Setup
|
||||
test.describe('Section 8: Setup and Configuration', () => {
|
||||
test('identity and access', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/identity-access');
|
||||
await snap(page, '08-setup-identity');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('tenant and branding', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/tenant-branding');
|
||||
await snap(page, '08-setup-tenant');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('notifications rules', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/notifications/rules');
|
||||
await snap(page, '08-setup-notifications-rules');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('notifications channels', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/notifications/channels');
|
||||
await snap(page, '08-setup-notifications-channels');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('notifications templates', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/notifications/templates');
|
||||
await snap(page, '08-setup-notifications-templates');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('usage and limits', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/usage');
|
||||
await snap(page, '08-setup-usage');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('system settings', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/system');
|
||||
await snap(page, '08-setup-system');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 9: Topology
|
||||
test.describe('Section 9: Topology', () => {
|
||||
test('topology overview', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/overview');
|
||||
await snap(page, '09-topology-overview');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('topology map', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/map');
|
||||
await snap(page, '09-topology-map');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('regions and environments', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/regions');
|
||||
await snap(page, '09-topology-regions');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('targets', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/targets');
|
||||
await snap(page, '09-topology-targets');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('hosts', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/hosts');
|
||||
await snap(page, '09-topology-hosts');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('agent fleet', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/agents');
|
||||
await snap(page, '09-topology-agents');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('promotion graph', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/topology/promotion-graph');
|
||||
await snap(page, '09-topology-promotion-graph');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 10: Platform Setup
|
||||
test.describe('Section 10: Platform Setup', () => {
|
||||
test('platform setup home', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/platform-setup');
|
||||
await snap(page, '10-platform-setup-home');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('promotion paths', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/platform-setup/promotion-paths');
|
||||
await snap(page, '10-platform-promotion-paths');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('workflows and gates', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/platform-setup/workflows-gates');
|
||||
await snap(page, '10-platform-workflows-gates');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('trust and signing', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/platform-setup/trust-signing');
|
||||
await snap(page, '10-platform-trust-signing');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 11: AI and Analysis
|
||||
test.describe('Section 11: AI and Analysis', () => {
|
||||
test('AI chat', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ai/chat');
|
||||
await snap(page, '11-ai-chat');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('AI autofix', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ai/autofix');
|
||||
await snap(page, '11-ai-autofix');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('graph explorer', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/graph');
|
||||
await snap(page, '11-graph-explorer');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('timeline', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/timeline');
|
||||
await snap(page, '11-timeline');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('change trace', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/change-trace');
|
||||
await snap(page, '11-change-trace');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// SECTION 12: Welcome
|
||||
test.describe('Section 12: Welcome and Setup Wizard', () => {
|
||||
test('welcome page (no auth)', async ({ page }) => {
|
||||
await page.goto('/welcome', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(1500);
|
||||
await snap(page, '12-welcome');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
@@ -82,4 +82,4 @@ Error generating stack: `+n.message+`
|
||||
<div id='root'></div>
|
||||
</body>
|
||||
</html>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIALJ4Vlw+cnzMRwIAAIgHAAAZAAAAZTRlYjRkMTI3MmQ5NTZmYjEyMGYuanNvbr2VbU/bMBDHv8rp3rSVTMkzjRGTxsQeJLRXfbWVSW5yoVlDnNmXDVTy3aeEsNIORJDY8sqO7365+/9tZ4NZXtCnFCVSQMsgdb0jL43DKFu6npOh6NY/qytCiZeFXqpiaonrasoWBTJZtii/brrRk5iD5dKbuf4siWM/duNZlKW+36bnXLTgn2Ty7AYsq2QNuQVDKlmpZUEosDL6OyXcl9B9GwUWOlGc6xLlpqvw0eqKvCSUvsBEF/VViTJqBKa16TPDWKAqS83dvG3jQiCry36ka05091G6rihhSttqFK/ulg3Zuuib32VaVobneZfqOV504HgHnjd3femE0nenUTz7gi2AzQ1Kp02gqlexF+SUMm0IPmq9bht5jnjkuC1xW4YbPkZ9n19zbQgWaOhHTZYXOAge78KPHmO/M6SYoAdDokumax6Cd/dq97b4C4GKWSWrKyq5f5HoumSUrkC7zquKUpSZKiw1/y5YbNv8cDaHBR4OE86d7XYWPNPaCzZ1sN3UftA8XbtAW7ZzRokAPtxChxqPnjxyIwHK3pQJjDd/3GwmcPIGNosSIIBbgNZey2DIVrq0BCegfqmc7+Onl8Tj0eFoctxmQJfxzPOtjQy7yLvTNr6HTy0rru14MpmyPqVzsna+UuU4dJzJMT705qxLhAU+jBvmVLS3B51XMyrcGuXFg416ucov126IN50tEdxCs6f124zJDL+fIm9X39krXk/77OA/3R9kjDZ93J3OKLFS1nY/ir9+LHvslqDXKNnU1Fw0vwFQSwMEFAAACAgAsnhWXHU8uJFYAwAAAQ0AABkAAAAwNjZkOWEzNWQzMzA0YzI5ZTU3Mi5qc29uzVbtjuM0FH0V6/5pK2UyzmfbrPjBIhaQ0CBB0UpsC/Ikt62Z1C72zbbLbCUegifkScBJRjOT6U6zsxrAvxzbOff4HPneew1LWeI3BWTA07SYiigpoojHeTjFZByCV+9fiA1CBkZXhPYc94SqwOKs+fYxRN9uMffJggeElixkb67r2QeRzyYiSaNLHk/GQRyJZRzxgrvfJZV1LBfCWGaRqi3bihWCB1ujf8WcWjr52uiNrDbgQalzQVIryK5rwn3JllIhZEE88SDXZbVRkI0PHhSVaeGi8XjqgVBKU73ibrbwgMSqnemKcl3Twf0Wc8LC8RS0huwN/FCTfy1/F6Zg3zsCbKg0ExWtmcHfKmmwGMHCA4O2KlvZusEtCUMzWccIeZie8fAsDGdBlPEki0I/jpOfwEGQeQcZdz/gtnWgFfMlLrVB9rXWV+7WpxEnDvGWSDBJj8G+knuqDLI5XBq9s2jm0Ac9Se6jT49y/lZUKl+zFrkXbof1XdILDwSRyNcbVNQu5LpSBFnggb2S2y0WkC1FafHwUYe9Y3rkWhHuqZceSZx21E6O6fGFQUHIWuReuF09/jM53PPtp0Ua3Oc8jh7Ros0Kp0HDDmjwbyjxVNkuxFu5ctcjzeZwXifAXuKlUedhBfFk+vhNPzFvJvw2bwbp4cP388Aq902QAWNBPGXvmRvn56zJkbsmRxYaLbv4bsYUYtHkyb/++JO5SsJ28p+vS2Ea3+eKsSDhLY7YCUn1ur/SpIeDRraBx66Z2/pRkSwzNlBIO22uZFHiwGMkN6gryljEf+Gcs8PohYN1o4G9M35uAgYPA7rZK21mDdgw5JyPXsBdR1+7s0ttbgKe9DL2g7DjZch5/LxeBk/z8nk86K/0KcdC9v6eGV/WZZrNgfRL/KpOJGa2FqrHE0v9II06T+x5PYlvPYk+xpOolSPXyhK71MW7Ge6JfXZXzZqbNsOB2x6MfKkUGndseGNB3KI0nc3wBsYnIzfDkV+iWtF65HeEHAaPuXJstE4l9elD5/F8viQ0PVsXZ1DcMSg90br0rE0Oupten1hFPrFjcEwedAyTo2Wy1LZ/x5D64XTcSTrj/12hXHiAxmjTnrMkqLKQwVZYW/feD3r1DrZD0FeQkanwsDj8DVBLAwQUAAAICACyeFZc5dC/5NcBAACcBAAACwAAAHJlcG9ydC5qc29uxZPNjptAEIRfBfUpkcYOMPy/QS45JJH2EPnQzDQ28cCQmSbajcW7R2B2bWu9UpJLbl38VNX0ByfoiFEjI1QnQMUjmgfrjuQ8VNEkwDM6/tp2BFWU51GepakMy6wUoEeH3NoeqjSR+bbIEwFNa8hD9e20TB81VEAJ1YmO4jzWZZo1dRSHDZyf/ISzLeyNrdFsPfE4bNmDACbPZ5t5etNmU9dxEclClaUso7LIGi3l/HrLZjb+Sa5tngLPqI5B6wNHqA5YGwIBg7PfSfFaYckGAcaq9UznE9xtZ9qeoJIClDVj10OVTTfbKAVg31te9HyMnQDG/TrZkZVdQulxIMWk5zbIh/X2ESp2Iwlw5EezrgGZUR066he9m3bTbkEzyxOwZTRQReJiOYuxv8hQQGPw+LRM/tgOw3r1OW+axBW0MMt0iTLVUoaJiktK8/gWmrMjk/9Aj0y9Jr056y3FtPUDqTc53nPeFJhmsg6TIo8SiU0iQx1ecXRzhPPBwiAYcP8aoDo427Vjd5/hH5U9Y42S4gI2vwEr8/zf0cKXpfxD+wudDj7PBYJ3vQ1w5EPg6MfYOtLv4b9+Abubrc45Lz/Gy37vpMbXqfHfpgog56x7XuGwbvY0Tb8BUEsBAj8DFAAACAgAsnhWXD5yfMxHAgAAiAcAABkAAAAAAAAAAAAAALSBAAAAAGU0ZWI0ZDEyNzJkOTU2ZmIxMjBmLmpzb25QSwECPwMUAAAICACyeFZcdTy4kVgDAAABDQAAGQAAAAAAAAAAAAAAtIF+AgAAMDY2ZDlhMzVkMzMwNGMyOWU1NzIuanNvblBLAQI/AxQAAAgIALJ4Vlzl0L/k1wEAAJwEAAALAAAAAAAAAAAAAAC0gQ0GAAByZXBvcnQuanNvblBLBQYAAAAAAwADAMcAAAANCAAAAAA=</script>
|
||||
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAONMWFyFvp9sTAIAAIwHAAAZAAAAZTRlYjRkMTI3MmQ5NTZmYjEyMGYuanNvbr2VbU/bMBDHv8rp3rSVQslDmyZGTBoTe5CmveqrrUxykwvNGuLMvmygku8+xYQVMhhBYssrx777+e7+9nmHWV7QhxQF0ozWs9TzF34az8Ns7fluho5d/yQvCAWeF2oti6khrqspG3SQybBB8WVnR49iDtZrP/KCKInjIPbiKMzSIGjdcy5a8A/SeXYFhmWyhdyAJpls5LogdLDS6hsl3IVg90YHC5VIzlWJYmcjfDC6Ii8JReBgoor6okQRNg6mte48vcB1UJalYjvR5nHmIMvzbqRqTpTdlS4rSpjSNhzJm5tlTaYuuux7UMNS8zK3vr7rhweuf+DPlu5CBLFwvenCn3/GlsD6CoV1oKqrY1eSE8qUJniv1LZN5Uli4LXEfRxB9BD1bX7JtSZYoabvNRle4RD4vAf3Fg/B32iSTNCRIVEl0yUP4kf3+fEef+agZJbJ5oJK7iYSVZeMwnPQbPOqohRFJgtDzb8zdvZpvjtdwgoPh1VuEfQyc/+e2jPO9Wx/roNZ83jsDpqy/WcUCBDANVjUePTorRs5IM1VmcB491vNZgLHr2C3KgFmcA3QymsYNJlKlYbgGORPmfOt/fSceDw6HE2OWg+wHk98X1vLubW8uW/jW/jUsOTajCeTKasT+kjGLDeyHM9dd3KEd7U5tY6wwrt2g5SKwrB3xl9MqPleKD8eLNTzq/z82g3RxsoSwjU0vVq/zpj04AYVhb077vsv16CihXsfHv6nBkJaK93Z3RQaBVbSGPtW/PG29NgtQW1RsK6pOWt+AVBLAwQUAAAICADjTFhcr0RhwHQFAADqGQAAGQAAADVmODgwZGUzNGU4OWJmYjgwNjM1Lmpzb27tWW1v2zYQ/isEN8A2ICvUq2V1KbC+rQWKbkDSDViStrR0jtXIpEee8gLH/32gpCS2YteK06xfpg+GbFEP756Hd0ee53Sc5fAupTENxlHEUvB8iIaj8ShioRdQq3z+gU+BxjRzItFHxYXOOWZSaBtcsPUMEhs1tSiCRk3jo3l5txG0HzjAwBmOnGicMO4Mg8hh5vUMczONvsgwmWTilOQy4TmQsZJToiGHBKUiY8BkApqk0H/1mowKkeZAuEiJApGC0uQ3UFMuCMIlUovOlPwKCdYuJBMlp1kxpRY14MYLGs9LJ1s4mGcCaOw5oUUTmRdTQePBwqJpoWokz3cji3IhJFYAND46sSjy0/pOFpjI0hK4nEGCkBoTOU5ofFROT95XPh/ckEBPLKpAF3lNbXMyjVzhYVZiuswN+8ztu/4hG8TeMGae7UXe39RAoLqiMTMvwKxWqSb8BYylAvJWyjPj5XbEyCAuGcKG62DfZJdYKCDHdKTkhQZ1TFug+8xdRXe8tejveSGSCamhWwFHTWD3DvjEohyRJ5MpCKx/SGQhkMaORfVZNptBSuMxzzUsHjTYWsdIIoVZn60YCXyvYXiwjpCXCjgCqZFb4YYNXOeH8THjp9COjKCp4jfZMLhtUEOnifrj1gYvcAICs4QjpH+0JSYc+KsuuN63PViT/8aVDXrPmGDX35ZTXzS4y3xuuNjsmUW1MN+RxpQQEoXk+liYmwG5JnA5kwrNStVITKkg+2TENdhwiSDSX+bkHgUxMZ9k8bw7L3EIIddk4/Wpmisqx6zB4vpKJKQ7L5cHWVik0NAj+8/JnO6s3wd+np2aJYeSHNM9OYNKCL0nlalXqDjKdilwwBqr0fe3RObutcxdUtRpr6jnhrUAe3vkcJJpomSBQKY8E8gzocmIJ2enShYiJTzB7DzDq2eEn8ssJQLwQqqzzBTuC56hJmOpCBqUxCwDo57nDuoJuBlSKmWfSpTdziZuOxbZvDg+VaDR7ROD+lFglsekk8ppmTYF5pKnkHYsuizt67JUk2OK8gX8melslLeJSd92/KARkzsEZUshPedOyGDQXkiP1ZQses9Kjjxnhfhqn9It+S+tk6rb+QnlbMRVv9qg9au9WafXs5cY6s4JZlOQBcbEY58ZYzdTbIneLVelpOfeXx7m7o1Uh9WsXYcx1nu2ouRfZmy52KoxLTT0wmGjNDDn6UR0d4vGp9esPeFbQtDzyPWKJgelIUTODC9km7lbFQtsL7oXdU+ml7+jXl5VDz3Pv89q5ervJR+beLBIpzz/dLbzHWzU7U0hknKS9TEyrh+3oNxnjSBxwyejPNiR8u/DdCs+t0kS3j7pltuOcsYDlKoscoDvEKbdjkbIcy5n+nNlUadH9vf3b8yxHpfYAtv3mtt/tu0A8AjVhjuqdlOwb9gffp8s5LNGFnpdH8frtsKSR0TBPwVotMipxLoloWMCov/xwKqGb2U7tH2/eW55Kqp9f7U70ZJp37vl6suDyfh5joonZ6Ds+if7q8xE1wRPb/GlYvwm/qqCc6gKnFx17wtV6xOs1afcg73l53DY7vAc2n7Amqf+JyN+uS00fADzQV0M/HDHKl4Xz6Nznhewf0xL2Y7pSVXdb/janJYeedWSDW6BO6+gQJ1MSPfV697mzfRLWZ4V2msZ+g9rWOyuZcDutPTZA7QcruargG2XdCTTq0qpJUK6qxzusHGuVAmqneGisRH+dYygWnb8QtsfNEpFsL7p8rgehpmnkSTZ/y2MHVoYj+mwGRGGDxLhqRqfayzxh2v71y9zqds3PkM7dBp5xAuC/6Lb99DBoJRU9TiNHAtNYzrjWpf/Gtz7l6GBbRDkGY1RFbA4WfwLUEsDBBQAAAgIAONMWFySYa+w7gEAAL0EAAALAAAAcmVwb3J0Lmpzb27FU02vmzAQ/CvWnp0I8xXDuVVVqeqllXqocjBmCTTGRvbSvqeI/15ByEuil6eqvfS2a/DMzsz6BD2SqhUpKE+gNI3KfHP+iD5AKSYOgZSnr12PUIrdThRC7iKZpSmHevSKOmehTIqoyLdFJjg0ncEA5ffTUn2soQRMsUprEe/iusjyphJx1MD5z89qxoWDcZUy24A0DlsKwIEw0Blmrt6E2VRVLEUidVEkhShk3tRJMl/vyMzAP9F3zTMLpPSRdYF5VLpVlUHgMHj3AzWtIyzcwME4vYo6K3g4neksQplw0M6MvYUyn27tEEnEQVnraDmYdew5kDqslRtJu4UVnwbUhPU8jqJ2/XyEkvyIHDyG0aw+KCKl2x7t0u+n/bRfwpnbE5AjZaAU/Ao5N6O9thGHxqjj81KFYzcM6+mFb5r4TWpZI2VUY5KiLKqmklGeZPepdULaDXllgznr3GKM2zCgfjPDR6CbTGCEoqiEbHSkRJFJEd1kGH51pNvOHticjUHWeNezgAY1Oc8aJN1iYDVu3r1n1Whrg0zZmnm0NfrAPqDvlWWET/Qqdd1613dj/zj4Pwlc10Dk10XY3S1CksbynzdhoWefzpq/XEyA/7og+zsDZ56Xh/Ni5QPW+JY1/ltWDui98xfLhtXJ0zT9BlBLAQI/AxQAAAgIAONMWFyFvp9sTAIAAIwHAAAZAAAAAAAAAAAAAAC0gQAAAABlNGViNGQxMjcyZDk1NmZiMTIwZi5qc29uUEsBAj8DFAAACAgA40xYXK9EYcB0BQAA6hkAABkAAAAAAAAAAAAAALSBgwIAADVmODgwZGUzNGU4OWJmYjgwNjM1Lmpzb25QSwECPwMUAAAICADjTFhckmGvsO4BAAC9BAAACwAAAAAAAAAAAAAAtIEuCAAAcmVwb3J0Lmpzb25QSwUGAAAAAAMAAwDHAAAARQoAAAAA</script>
|
||||
@@ -1,28 +1,52 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright config targeting the Docker compose stack.
|
||||
* Playwright config for running E2E tests against the Docker compose stack.
|
||||
* The stack must be running before executing these tests.
|
||||
*
|
||||
* Usage: npx playwright test --config playwright.e2e.config.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['json', { outputFile: 'e2e-results.json' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local',
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
|
||||
// Default targets Docker stack, but allow local source-served runs for debugging/unblock.
|
||||
// Example:
|
||||
// PLAYWRIGHT_LOCAL_SOURCE=1 PLAYWRIGHT_BASE_URL=https://127.0.0.1:4400
|
||||
// npx playwright test --config playwright.e2e.config.ts ...
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(function (): any {
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'https://127.1.0.1';
|
||||
const localSource = process.env.PLAYWRIGHT_LOCAL_SOURCE === '1';
|
||||
return {
|
||||
...(localSource
|
||||
? {
|
||||
webServer: {
|
||||
command: 'npm run serve:test',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
timeout: 120_000,
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
use: {
|
||||
baseURL,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
|
||||
76
src/Web/StellaOps.Web/signin-check.cjs
Normal file
76
src/Web/StellaOps.Web/signin-check.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
const requests = [];
|
||||
page.on('requestfinished', async (req) => {
|
||||
try {
|
||||
const r = await req.response();
|
||||
if (r) requests.push(r.status() + ' ' + req.method() + ' ' + req.url().replace('https://stella-ops.local', ''));
|
||||
} catch {}
|
||||
});
|
||||
page.on('requestfailed', (req) =>
|
||||
requests.push('FAIL ' + req.method() + ' ' + req.url().replace('https://stella-ops.local', ''))
|
||||
);
|
||||
|
||||
await page.goto('https://stella-ops.local/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
// Wait for Angular
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
|
||||
// Click Sign In button using CDP
|
||||
const clickResult = await client.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
const btn = document.querySelector('button[class*="sign-in"], a[class*="sign-in"], button:has(span), [routerlink*="auth"], [href*="auth"]');
|
||||
if (!btn) {
|
||||
// Try finding by text content
|
||||
const allBtns = [...document.querySelectorAll('button, a')];
|
||||
const signInBtn = allBtns.find(b => b.textContent.includes('Sign In'));
|
||||
if (signInBtn) { signInBtn.click(); 'clicked: ' + signInBtn.tagName + ' ' + signInBtn.textContent.trim(); }
|
||||
else 'no sign in button found. buttons: ' + allBtns.map(b => b.tagName + ':' + b.textContent.trim().substring(0,30)).join(', ');
|
||||
} else {
|
||||
btn.click();
|
||||
'clicked: ' + btn.tagName + ' ' + btn.textContent.trim();
|
||||
}
|
||||
`,
|
||||
timeout: 3000,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log('Click result:', clickResult.result.value);
|
||||
|
||||
// Wait for navigation/redirect
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
// Check current URL and page state
|
||||
const check = await client.send('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({url: window.location.href, title: document.title, bodyText: document.body.innerText.substring(0, 500)})',
|
||||
timeout: 3000,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log('\nAfter click:', check.result.value);
|
||||
|
||||
// Screenshot
|
||||
const ss = await client.send('Page.captureScreenshot', { format: 'png' });
|
||||
const outPath = 'C:/dev/New folder/git.stella-ops.org/signin-check.png';
|
||||
fs.writeFileSync(outPath, Buffer.from(ss.data, 'base64'));
|
||||
console.log('Screenshot saved to', outPath);
|
||||
|
||||
// Show recent requests
|
||||
console.log('\nRecent requests:');
|
||||
requests.slice(-15).forEach(r => console.log(' ' + r));
|
||||
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
console.error('Fatal:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
[class.app-fresh--active]="fresh.active"
|
||||
[class.app-fresh--stale]="!fresh.active"
|
||||
>
|
||||
Fresh auth: {{ fresh.active ? 'Active' : 'Stale' }}
|
||||
{{ fresh.active ? ('ui.auth.fresh_active' | translate) : ('ui.auth.fresh_stale' | translate) }}
|
||||
@if (fresh.expiresAt) {
|
||||
(expires {{ fresh.expiresAt | date: 'shortTime' }})
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
<app-user-menu></app-user-menu>
|
||||
} @else if (showSignIn()) {
|
||||
<button type="button" class="app-auth__signin" (click)="onSignIn()">Sign in</button>
|
||||
<button type="button" class="app-auth__signin" (click)="onSignIn()">{{ 'ui.actions.sign_in' | translate }}</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BrandingService } from './core/branding/branding.service';
|
||||
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
|
||||
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
|
||||
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
|
||||
import { TranslatePipe } from './core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -42,6 +43,7 @@ import { PlatformContextUrlSyncService } from './core/context/platform-context-u
|
||||
BreadcrumbComponent,
|
||||
KeyboardShortcutsComponent,
|
||||
LegacyUrlBannerComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
|
||||
@@ -34,6 +34,7 @@ import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/
|
||||
import { RISK_API, MockRiskApi } 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 { I18nService } from './core/i18n';
|
||||
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
|
||||
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
|
||||
import { BackendProbeService } from './core/config/backend-probe.service';
|
||||
@@ -254,6 +255,12 @@ import { RISK_BUDGET_API, HttpRiskBudgetApi } from './core/services/risk-budget.
|
||||
import { FIX_VERIFICATION_API, FixVerificationApiClient } from './core/services/fix-verification.service';
|
||||
import { SCORING_API, HttpScoringApi } from './core/services/scoring.service';
|
||||
import { ABAC_OVERLAY_API, AbacOverlayHttpClient } from './core/api/abac-overlay.client';
|
||||
import {
|
||||
IDENTITY_PROVIDER_API,
|
||||
IDENTITY_PROVIDER_API_BASE_URL,
|
||||
IdentityProviderApiHttpClient,
|
||||
MockIdentityProviderClient,
|
||||
} from './core/api/identity-provider.client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -262,12 +269,13 @@ export const appConfig: ApplicationConfig = {
|
||||
{ provide: TitleStrategy, useClass: PageTitleStrategy },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService) => async () => {
|
||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService) => async () => {
|
||||
await configService.load();
|
||||
await i18nService.loadTranslations();
|
||||
if (configService.isConfigured()) {
|
||||
probeService.probe();
|
||||
}
|
||||
})(inject(AppConfigService), inject(BackendProbeService));
|
||||
})(inject(AppConfigService), inject(BackendProbeService), inject(I18nService));
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
@@ -1065,6 +1073,27 @@ export const appConfig: ApplicationConfig = {
|
||||
AocHttpClient,
|
||||
{ provide: AOC_API, useExisting: AocHttpClient },
|
||||
|
||||
// Identity Provider API (Platform backend via gateway)
|
||||
{
|
||||
provide: IDENTITY_PROVIDER_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
useFactory: (config: AppConfigService) => {
|
||||
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
|
||||
try {
|
||||
return new URL('/api/v1/platform/identity-providers', gatewayBase).toString();
|
||||
} catch {
|
||||
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
|
||||
return `${normalized}/api/v1/platform/identity-providers`;
|
||||
}
|
||||
},
|
||||
},
|
||||
IdentityProviderApiHttpClient,
|
||||
MockIdentityProviderClient,
|
||||
{
|
||||
provide: IDENTITY_PROVIDER_API,
|
||||
useExisting: IdentityProviderApiHttpClient,
|
||||
},
|
||||
|
||||
// Doctor background services — started from AppComponent to avoid
|
||||
// NG0200 circular DI during APP_INITIALIZER (Router not yet ready).
|
||||
DoctorTrendService,
|
||||
|
||||
@@ -125,6 +125,13 @@ export const routes: Routes = [
|
||||
data: { breadcrumb: 'Setup' },
|
||||
loadChildren: () => import('./routes/setup.routes').then((m) => m.SETUP_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
title: 'Settings',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Settings' },
|
||||
loadChildren: () => import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
title: 'Welcome',
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Identity Provider API client.
|
||||
* Manages external identity provider configurations (LDAP, SAML, OIDC, standard).
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, of, delay, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IdentityProviderConfigDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
configuration: Record<string, string | null>;
|
||||
description: string | null;
|
||||
healthStatus: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
export interface CreateIdentityProviderRequest {
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
configuration: Record<string, string | null>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateIdentityProviderRequest {
|
||||
enabled?: boolean;
|
||||
configuration?: Record<string, string | null>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TestConnectionRequest {
|
||||
type: string;
|
||||
configuration: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
export interface IdentityProviderTypeSchema {
|
||||
type: string;
|
||||
displayName: string;
|
||||
requiredFields: IdentityProviderFieldSchema[];
|
||||
optionalFields: IdentityProviderFieldSchema[];
|
||||
}
|
||||
|
||||
export interface IdentityProviderFieldSchema {
|
||||
name: string;
|
||||
displayName: string;
|
||||
fieldType: string;
|
||||
defaultValue: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IdentityProviderApi {
|
||||
list(): Observable<IdentityProviderConfigDto[]>;
|
||||
get(id: string): Observable<IdentityProviderConfigDto>;
|
||||
create(req: CreateIdentityProviderRequest): Observable<IdentityProviderConfigDto>;
|
||||
update(id: string, req: UpdateIdentityProviderRequest): Observable<IdentityProviderConfigDto>;
|
||||
remove(id: string): Observable<void>;
|
||||
enable(id: string): Observable<IdentityProviderConfigDto>;
|
||||
disable(id: string): Observable<IdentityProviderConfigDto>;
|
||||
testConnection(req: TestConnectionRequest): Observable<TestConnectionResult>;
|
||||
getHealth(id: string): Observable<TestConnectionResult>;
|
||||
applyToAuthority(id: string): Observable<void>;
|
||||
getTypes(): Observable<IdentityProviderTypeSchema[]>;
|
||||
}
|
||||
|
||||
export const IDENTITY_PROVIDER_API = new InjectionToken<IdentityProviderApi>('IDENTITY_PROVIDER_API');
|
||||
export const IDENTITY_PROVIDER_API_BASE_URL = new InjectionToken<string>('IDENTITY_PROVIDER_API_BASE_URL');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IdentityProviderApiHttpClient implements IdentityProviderApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
private readonly baseUrl = inject(IDENTITY_PROVIDER_API_BASE_URL, { optional: true }) ?? '/api/v1/platform/identity-providers';
|
||||
|
||||
list(): Observable<IdentityProviderConfigDto[]> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.get<IdentityProviderConfigDto[]>(this.baseUrl, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
get(id: string): Observable<IdentityProviderConfigDto> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.get<IdentityProviderConfigDto>(`${this.baseUrl}/${id}`, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
create(req: CreateIdentityProviderRequest): Observable<IdentityProviderConfigDto> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.post<IdentityProviderConfigDto>(this.baseUrl, req, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
update(id: string, req: UpdateIdentityProviderRequest): Observable<IdentityProviderConfigDto> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.put<IdentityProviderConfigDto>(`${this.baseUrl}/${id}`, req, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
enable(id: string): Observable<IdentityProviderConfigDto> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.post<IdentityProviderConfigDto>(`${this.baseUrl}/${id}/enable`, null, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
disable(id: string): Observable<IdentityProviderConfigDto> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.post<IdentityProviderConfigDto>(`${this.baseUrl}/${id}/disable`, null, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
testConnection(req: TestConnectionRequest): Observable<TestConnectionResult> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.post<TestConnectionResult>(`${this.baseUrl}/test-connection`, req, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getHealth(id: string): Observable<TestConnectionResult> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.get<TestConnectionResult>(`${this.baseUrl}/${id}/health`, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
applyToAuthority(id: string): Observable<void> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.post<void>(`${this.baseUrl}/${id}/apply`, null, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
getTypes(): Observable<IdentityProviderTypeSchema[]> {
|
||||
const traceId = generateTraceId();
|
||||
return this.http.get<IdentityProviderTypeSchema[]>(`${this.baseUrl}/types`, { headers: this.buildHeaders(traceId) }).pipe(
|
||||
catchError((err) => throwError(() => this.mapError(err, traceId)))
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(traceId: string): HttpHeaders {
|
||||
const tenant = this.authSession.getActiveTenantId() || '';
|
||||
return new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': traceId,
|
||||
'X-Stella-Request-Id': traceId,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
private mapError(err: unknown, traceId: string): Error {
|
||||
return err instanceof Error
|
||||
? new Error(`[${traceId}] Identity Provider API error: ${err.message}`)
|
||||
: new Error(`[${traceId}] Identity Provider API error: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockIdentityProviderClient implements IdentityProviderApi {
|
||||
private providers: IdentityProviderConfigDto[] = [
|
||||
{
|
||||
id: 'idp-ldap-1',
|
||||
name: 'Corporate LDAP',
|
||||
type: 'ldap',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
server: 'ldaps://ldap.corp.example.com',
|
||||
port: '636',
|
||||
bindDn: 'cn=svc-stellaops,ou=services,dc=corp,dc=example,dc=com',
|
||||
searchBase: 'ou=users,dc=corp,dc=example,dc=com',
|
||||
userFilter: '(sAMAccountName={0})',
|
||||
},
|
||||
description: 'Corporate Active Directory via LDAPS',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
updatedAt: '2026-02-20T08:30:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'idp-oidc-1',
|
||||
name: 'Okta SSO',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
authority: 'https://dev-12345.okta.com',
|
||||
clientId: 'stellaops-client-id',
|
||||
audience: 'api://stellaops',
|
||||
scopes: 'openid profile email',
|
||||
},
|
||||
description: 'Okta OpenID Connect integration',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-01-20T14:00:00Z',
|
||||
updatedAt: '2026-02-18T16:00:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'idp-saml-1',
|
||||
name: 'Azure AD SAML',
|
||||
type: 'saml',
|
||||
enabled: false,
|
||||
configuration: {
|
||||
spEntityId: 'https://stellaops.example.com',
|
||||
idpEntityId: 'https://sts.windows.net/tenant-id/',
|
||||
idpSsoUrl: 'https://login.microsoftonline.com/tenant-id/saml2',
|
||||
idpMetadataUrl: 'https://login.microsoftonline.com/tenant-id/federationmetadata/2007-06/federationmetadata.xml',
|
||||
},
|
||||
description: 'Azure AD federation (SAML 2.0)',
|
||||
healthStatus: 'degraded',
|
||||
createdAt: '2026-02-01T09:00:00Z',
|
||||
updatedAt: '2026-02-22T11:00:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: null,
|
||||
},
|
||||
];
|
||||
|
||||
list(): Observable<IdentityProviderConfigDto[]> {
|
||||
return of([...this.providers]).pipe(delay(200));
|
||||
}
|
||||
|
||||
get(id: string): Observable<IdentityProviderConfigDto> {
|
||||
const provider = this.providers.find(p => p.id === id);
|
||||
if (!provider) {
|
||||
return throwError(() => new Error(`Provider ${id} not found`));
|
||||
}
|
||||
return of({ ...provider }).pipe(delay(100));
|
||||
}
|
||||
|
||||
create(req: CreateIdentityProviderRequest): Observable<IdentityProviderConfigDto> {
|
||||
const newProvider: IdentityProviderConfigDto = {
|
||||
id: `idp-${req.type}-${Date.now()}`,
|
||||
name: req.name,
|
||||
type: req.type,
|
||||
enabled: req.enabled,
|
||||
configuration: { ...req.configuration },
|
||||
description: req.description ?? null,
|
||||
healthStatus: 'unknown',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
updatedBy: null,
|
||||
};
|
||||
this.providers = [...this.providers, newProvider];
|
||||
return of({ ...newProvider }).pipe(delay(500));
|
||||
}
|
||||
|
||||
update(id: string, req: UpdateIdentityProviderRequest): Observable<IdentityProviderConfigDto> {
|
||||
const idx = this.providers.findIndex(p => p.id === id);
|
||||
if (idx === -1) {
|
||||
return throwError(() => new Error(`Provider ${id} not found`));
|
||||
}
|
||||
const updated: IdentityProviderConfigDto = {
|
||||
...this.providers[idx],
|
||||
...(req.enabled !== undefined ? { enabled: req.enabled } : {}),
|
||||
...(req.configuration ? { configuration: { ...this.providers[idx].configuration, ...req.configuration } } : {}),
|
||||
...(req.description !== undefined ? { description: req.description ?? null } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'admin',
|
||||
};
|
||||
this.providers = this.providers.map((p, i) => i === idx ? updated : p);
|
||||
return of({ ...updated }).pipe(delay(300));
|
||||
}
|
||||
|
||||
remove(id: string): Observable<void> {
|
||||
this.providers = this.providers.filter(p => p.id !== id);
|
||||
return of(undefined).pipe(delay(300));
|
||||
}
|
||||
|
||||
enable(id: string): Observable<IdentityProviderConfigDto> {
|
||||
return this.update(id, { enabled: true });
|
||||
}
|
||||
|
||||
disable(id: string): Observable<IdentityProviderConfigDto> {
|
||||
return this.update(id, { enabled: false });
|
||||
}
|
||||
|
||||
testConnection(_req: TestConnectionRequest): Observable<TestConnectionResult> {
|
||||
return of({
|
||||
success: true,
|
||||
message: 'Connection established successfully',
|
||||
latencyMs: 42,
|
||||
}).pipe(delay(1500));
|
||||
}
|
||||
|
||||
getHealth(_id: string): Observable<TestConnectionResult> {
|
||||
return of({
|
||||
success: true,
|
||||
message: 'Provider is healthy',
|
||||
latencyMs: 38,
|
||||
}).pipe(delay(500));
|
||||
}
|
||||
|
||||
applyToAuthority(_id: string): Observable<void> {
|
||||
return of(undefined).pipe(delay(800));
|
||||
}
|
||||
|
||||
getTypes(): Observable<IdentityProviderTypeSchema[]> {
|
||||
return of([
|
||||
{
|
||||
type: 'standard',
|
||||
displayName: 'Standard Authentication',
|
||||
requiredFields: [],
|
||||
optionalFields: [
|
||||
{ name: 'minLength', displayName: 'Min Password Length', fieldType: 'number', defaultValue: '12', description: 'Minimum password length' },
|
||||
{ name: 'requireUppercase', displayName: 'Require Uppercase', fieldType: 'boolean', defaultValue: 'true', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ldap',
|
||||
displayName: 'LDAP / Active Directory',
|
||||
requiredFields: [
|
||||
{ name: 'server', displayName: 'LDAP Server URL', fieldType: 'text', defaultValue: null, description: 'ldap:// or ldaps:// URL' },
|
||||
{ name: 'bindDn', displayName: 'Bind DN', fieldType: 'text', defaultValue: null, description: 'Distinguished name for LDAP bind' },
|
||||
{ name: 'bindPassword', displayName: 'Bind Password', fieldType: 'password', defaultValue: null, description: null },
|
||||
{ name: 'searchBase', displayName: 'User Search Base', fieldType: 'text', defaultValue: null, description: 'Base DN for user searches' },
|
||||
],
|
||||
optionalFields: [
|
||||
{ name: 'port', displayName: 'Port', fieldType: 'number', defaultValue: '389', description: '389 (LDAP) or 636 (LDAPS)' },
|
||||
{ name: 'useSsl', displayName: 'Use SSL/TLS', fieldType: 'boolean', defaultValue: 'false', description: null },
|
||||
{ name: 'userFilter', displayName: 'User Filter', fieldType: 'text', defaultValue: '(uid={0})', description: 'Use {0} for username' },
|
||||
{ name: 'groupSearchBase', displayName: 'Group Search Base', fieldType: 'text', defaultValue: null, description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'saml',
|
||||
displayName: 'SAML 2.0',
|
||||
requiredFields: [
|
||||
{ name: 'spEntityId', displayName: 'SP Entity ID', fieldType: 'text', defaultValue: null, description: 'Service Provider entity identifier' },
|
||||
{ name: 'idpEntityId', displayName: 'IdP Entity ID', fieldType: 'text', defaultValue: null, description: 'Identity Provider entity identifier' },
|
||||
{ name: 'idpSsoUrl', displayName: 'IdP SSO URL', fieldType: 'text', defaultValue: null, description: 'Single Sign-On service URL' },
|
||||
],
|
||||
optionalFields: [
|
||||
{ name: 'idpMetadataUrl', displayName: 'IdP Metadata URL', fieldType: 'text', defaultValue: null, description: 'Federation metadata endpoint' },
|
||||
{ name: 'signAuthnRequests', displayName: 'Sign AuthN Requests', fieldType: 'boolean', defaultValue: 'true', description: null },
|
||||
{ name: 'wantAssertionsSigned', displayName: 'Want Assertions Signed', fieldType: 'boolean', defaultValue: 'true', description: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'oidc',
|
||||
displayName: 'OpenID Connect',
|
||||
requiredFields: [
|
||||
{ name: 'authority', displayName: 'Authority URL', fieldType: 'text', defaultValue: null, description: 'OIDC issuer / authority endpoint' },
|
||||
{ name: 'clientId', displayName: 'Client ID', fieldType: 'text', defaultValue: null, description: 'OAuth 2.0 client identifier' },
|
||||
],
|
||||
optionalFields: [
|
||||
{ name: 'clientSecret', displayName: 'Client Secret', fieldType: 'password', defaultValue: null, description: 'OAuth 2.0 client secret' },
|
||||
{ name: 'audience', displayName: 'Audience', fieldType: 'text', defaultValue: null, description: 'API audience identifier' },
|
||||
{ name: 'scopes', displayName: 'Scopes', fieldType: 'text', defaultValue: 'openid profile email', description: 'Space-separated scope list' },
|
||||
],
|
||||
},
|
||||
]).pipe(delay(150));
|
||||
}
|
||||
}
|
||||
564
src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts
Normal file
564
src/Web/StellaOps.Web/src/app/core/api/unified-search.client.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
// Sprint: SPRINT_20260223_099_FE_unified_search_bar_entity_cards_synthesis_panel
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, EMPTY } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { SearchClient } from './search.client';
|
||||
import type { SearchFilter, SearchResponse, SearchResult } from './search.models';
|
||||
import { SUPPORTED_UNIFIED_DOMAINS, SUPPORTED_UNIFIED_ENTITY_TYPES } from './unified-search.models';
|
||||
import type {
|
||||
EntityCard,
|
||||
UnifiedEntityType,
|
||||
UnifiedSearchDiagnostics,
|
||||
UnifiedSearchDomain,
|
||||
UnifiedSearchFilter,
|
||||
UnifiedSearchResponse,
|
||||
SynthesisResult,
|
||||
SearchSuggestion,
|
||||
SearchRefinement,
|
||||
SearchFeedbackRequest,
|
||||
SearchQualityAlert,
|
||||
SearchQualityMetrics,
|
||||
SearchQualityAlertUpdateRequest,
|
||||
} from './unified-search.models';
|
||||
|
||||
interface UnifiedSearchRequestDto {
|
||||
q: string;
|
||||
k?: number;
|
||||
filters?: {
|
||||
domains?: string[];
|
||||
entityTypes?: string[];
|
||||
entityKey?: string;
|
||||
product?: string;
|
||||
version?: string;
|
||||
service?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
includeSynthesis?: boolean;
|
||||
includeDebug?: boolean;
|
||||
}
|
||||
|
||||
interface SearchSuggestionDto {
|
||||
text: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface SearchRefinementDto {
|
||||
text: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface UnifiedSearchResponseDto {
|
||||
query: string;
|
||||
topK: number;
|
||||
cards: EntityCardDto[];
|
||||
synthesis: SynthesisResultDto | null;
|
||||
suggestions?: SearchSuggestionDto[];
|
||||
refinements?: SearchRefinementDto[];
|
||||
diagnostics: UnifiedSearchDiagnosticsDto;
|
||||
}
|
||||
|
||||
interface EntityCardDto {
|
||||
entityKey: string;
|
||||
entityType: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
severity?: string;
|
||||
actions: EntityCardActionDto[];
|
||||
metadata?: Record<string, string>;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
interface EntityCardActionDto {
|
||||
label: string;
|
||||
actionType: string;
|
||||
route?: string;
|
||||
command?: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
interface SynthesisResultDto {
|
||||
summary: string;
|
||||
template: string;
|
||||
confidence: string;
|
||||
sourceCount: number;
|
||||
domainsCovered: string[];
|
||||
}
|
||||
|
||||
interface UnifiedSearchDiagnosticsDto {
|
||||
ftsMatches: number;
|
||||
vectorMatches: number;
|
||||
entityCardCount: number;
|
||||
durationMs: number;
|
||||
usedVector: boolean;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UnifiedSearchClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly legacySearchClient = inject(SearchClient);
|
||||
|
||||
search(
|
||||
query: string,
|
||||
filter?: UnifiedSearchFilter,
|
||||
limit = 10,
|
||||
): Observable<UnifiedSearchResponse> {
|
||||
const normalizedQuery = query.trim();
|
||||
if (normalizedQuery.length < 2) {
|
||||
return of({
|
||||
query,
|
||||
topK: limit,
|
||||
cards: [],
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: 0,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 0,
|
||||
durationMs: 0,
|
||||
usedVector: false,
|
||||
mode: 'empty',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const request: UnifiedSearchRequestDto = {
|
||||
q: normalizedQuery,
|
||||
k: Math.max(1, Math.min(100, limit)),
|
||||
filters: this.normalizeFilter(filter),
|
||||
includeSynthesis: true,
|
||||
includeDebug: false,
|
||||
};
|
||||
|
||||
return this.http
|
||||
.post<UnifiedSearchResponseDto>('/api/v1/search/query', request)
|
||||
.pipe(
|
||||
map((response) => this.mapResponse(response, normalizedQuery)),
|
||||
catchError(() => this.fallbackToLegacy(normalizedQuery, filter, limit)),
|
||||
);
|
||||
}
|
||||
|
||||
private fallbackToLegacy(
|
||||
query: string,
|
||||
filter?: UnifiedSearchFilter,
|
||||
limit = 10,
|
||||
): Observable<UnifiedSearchResponse> {
|
||||
return this.legacySearchClient.search(query, this.toLegacyFilter(filter), limit).pipe(
|
||||
map((response) => this.mapLegacyResponse(response, query, limit)),
|
||||
catchError(() =>
|
||||
of({
|
||||
query,
|
||||
topK: limit,
|
||||
cards: [],
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: 0,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 0,
|
||||
durationMs: 0,
|
||||
usedVector: false,
|
||||
mode: 'fallback-empty',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private toLegacyFilter(filter?: UnifiedSearchFilter): SearchFilter | undefined {
|
||||
if (!filter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const types = new Set<'docs' | 'api' | 'doctor'>();
|
||||
for (const domain of filter.domains ?? []) {
|
||||
if (domain === 'knowledge') {
|
||||
types.add('docs');
|
||||
types.add('api');
|
||||
types.add('doctor');
|
||||
}
|
||||
}
|
||||
|
||||
for (const entityType of filter.entityTypes ?? []) {
|
||||
if (entityType === 'docs' || entityType === 'api' || entityType === 'doctor') {
|
||||
types.add(entityType);
|
||||
}
|
||||
}
|
||||
|
||||
const tags = (filter.tags ?? [])
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.sort();
|
||||
|
||||
const normalized: SearchFilter = {
|
||||
types: types.size > 0 ? Array.from(types).sort() : undefined,
|
||||
product: filter.product?.trim() || undefined,
|
||||
version: filter.version?.trim() || undefined,
|
||||
service: filter.service?.trim() || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
};
|
||||
|
||||
if (
|
||||
!normalized.types &&
|
||||
!normalized.product &&
|
||||
!normalized.version &&
|
||||
!normalized.service &&
|
||||
!normalized.tags
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private mapLegacyResponse(
|
||||
response: SearchResponse,
|
||||
queryFallback: string,
|
||||
limit: number,
|
||||
): UnifiedSearchResponse {
|
||||
const cards = (response.groups ?? [])
|
||||
.flatMap((group) => group.results ?? [])
|
||||
.map((result, index) => this.mapLegacyCard(result, index));
|
||||
|
||||
return {
|
||||
query: response.query?.trim() || queryFallback,
|
||||
topK: Math.max(1, Math.min(100, limit)),
|
||||
cards,
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: response.totalCount ?? cards.length,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: cards.length,
|
||||
durationMs: response.durationMs ?? 0,
|
||||
usedVector: false,
|
||||
mode: 'legacy-fallback',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mapLegacyCard(result: SearchResult, index: number): EntityCard {
|
||||
return {
|
||||
entityKey: result.id || `${result.type}:${index}`,
|
||||
entityType: result.type as EntityCard['entityType'],
|
||||
domain: 'knowledge',
|
||||
title: result.title?.trim() || '(untitled)',
|
||||
snippet: this.normalizeSnippet(result.description ?? ''),
|
||||
score: Number.isFinite(result.matchScore) ? result.matchScore : 0,
|
||||
severity: result.severity,
|
||||
actions: this.mapLegacyActions(result),
|
||||
metadata: this.toStringRecord(result.metadata),
|
||||
sources: ['knowledge', 'legacy'],
|
||||
};
|
||||
}
|
||||
|
||||
private mapLegacyActions(result: SearchResult): EntityCard['actions'] {
|
||||
const actions: EntityCard['actions'] = [];
|
||||
|
||||
if (result.route) {
|
||||
actions.push({
|
||||
label: result.type === 'doctor' ? 'Run' : 'Open',
|
||||
actionType: 'navigate',
|
||||
route: result.route,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.type === 'doctor' && result.open.doctor?.runCommand) {
|
||||
actions.push({
|
||||
label: 'Copy Run Command',
|
||||
actionType: 'copy',
|
||||
command: result.open.doctor.runCommand,
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
else if (result.type === 'api' && result.open.api) {
|
||||
actions.push({
|
||||
label: 'Curl',
|
||||
actionType: 'copy',
|
||||
command: `curl -X ${result.open.api.method.toUpperCase()} \"$STELLAOPS_API_BASE${result.open.api.path}\"`,
|
||||
isPrimary: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
actions.push({
|
||||
label: 'Details',
|
||||
actionType: 'details',
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private toStringRecord(value?: Record<string, unknown>): Record<string, string> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
.filter(([, entryValue]) => entryValue !== undefined && entryValue !== null)
|
||||
.map(([key, entryValue]) => [
|
||||
key,
|
||||
typeof entryValue === 'string' ? entryValue : JSON.stringify(entryValue),
|
||||
] as const);
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
private mapResponse(
|
||||
response: UnifiedSearchResponseDto,
|
||||
queryFallback: string,
|
||||
): UnifiedSearchResponse {
|
||||
const cards: EntityCard[] = (response.cards ?? []).map((card) => ({
|
||||
entityKey: card.entityKey ?? '',
|
||||
entityType: (card.entityType as EntityCard['entityType']) ?? 'docs',
|
||||
domain: (card.domain as EntityCard['domain']) ?? 'knowledge',
|
||||
title: card.title?.trim() || '(untitled)',
|
||||
snippet: this.normalizeSnippet(card.snippet),
|
||||
score: Number.isFinite(card.score) ? card.score : 0,
|
||||
severity: card.severity,
|
||||
actions: (card.actions ?? []).map((action) => ({
|
||||
label: action.label ?? 'Open',
|
||||
actionType: (action.actionType as EntityCard['actions'][0]['actionType']) ?? 'navigate',
|
||||
route: action.route,
|
||||
command: action.command,
|
||||
isPrimary: action.isPrimary ?? false,
|
||||
})),
|
||||
metadata: card.metadata,
|
||||
sources: card.sources ?? [],
|
||||
}));
|
||||
|
||||
const synthesis: SynthesisResult | null = response.synthesis
|
||||
? {
|
||||
summary: response.synthesis.summary ?? '',
|
||||
template: response.synthesis.template ?? 'mixed',
|
||||
confidence: (response.synthesis.confidence as SynthesisResult['confidence']) ?? 'low',
|
||||
sourceCount: response.synthesis.sourceCount ?? 0,
|
||||
domainsCovered: response.synthesis.domainsCovered ?? [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const diagnostics: UnifiedSearchDiagnostics = {
|
||||
ftsMatches: response.diagnostics?.ftsMatches ?? 0,
|
||||
vectorMatches: response.diagnostics?.vectorMatches ?? 0,
|
||||
entityCardCount: response.diagnostics?.entityCardCount ?? 0,
|
||||
durationMs: response.diagnostics?.durationMs ?? 0,
|
||||
usedVector: response.diagnostics?.usedVector ?? false,
|
||||
mode: response.diagnostics?.mode ?? 'unknown',
|
||||
};
|
||||
|
||||
const suggestions: SearchSuggestion[] | undefined =
|
||||
response.suggestions && response.suggestions.length > 0
|
||||
? response.suggestions.map((s) => ({
|
||||
text: s.text ?? '',
|
||||
reason: s.reason ?? '',
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const refinements: SearchRefinement[] | undefined =
|
||||
response.refinements && response.refinements.length > 0
|
||||
? response.refinements.map((r) => ({
|
||||
text: r.text ?? '',
|
||||
source: r.source ?? '',
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
query: response.query?.trim() || queryFallback,
|
||||
topK: response.topK ?? 10,
|
||||
cards,
|
||||
synthesis,
|
||||
suggestions,
|
||||
refinements,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeFilter(
|
||||
filter?: UnifiedSearchFilter,
|
||||
): UnifiedSearchRequestDto['filters'] | undefined {
|
||||
if (!filter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const supportedDomains = new Set<UnifiedSearchDomain>(SUPPORTED_UNIFIED_DOMAINS);
|
||||
const supportedEntityTypes = new Set<UnifiedEntityType>(SUPPORTED_UNIFIED_ENTITY_TYPES);
|
||||
|
||||
const domains = (filter.domains ?? [])
|
||||
.map((domain) => domain.trim().toLowerCase() as UnifiedSearchDomain)
|
||||
.filter((domain) => domain.length > 0 && supportedDomains.has(domain))
|
||||
.sort();
|
||||
const entityTypes = (filter.entityTypes ?? [])
|
||||
.map((entityType) => entityType.trim().toLowerCase() as UnifiedEntityType)
|
||||
.filter((entityType) => entityType.length > 0 && supportedEntityTypes.has(entityType))
|
||||
.sort();
|
||||
const tags = (filter.tags ?? []).filter((t) => t.trim().length > 0).sort();
|
||||
|
||||
const normalized = {
|
||||
domains: domains.length > 0 ? domains : undefined,
|
||||
entityTypes: entityTypes.length > 0 ? entityTypes : undefined,
|
||||
entityKey: filter.entityKey?.trim() || undefined,
|
||||
product: filter.product?.trim() || undefined,
|
||||
version: filter.version?.trim() || undefined,
|
||||
service: filter.service?.trim() || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
};
|
||||
|
||||
if (
|
||||
!normalized.domains &&
|
||||
!normalized.entityTypes &&
|
||||
!normalized.entityKey &&
|
||||
!normalized.product &&
|
||||
!normalized.version &&
|
||||
!normalized.service &&
|
||||
!normalized.tags
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSnippet(value: string): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/<\/?mark>/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// --- Search analytics & history (Sprint: SPRINT_20260224_106 G6) ---
|
||||
|
||||
/**
|
||||
* Records a batch of search analytics events (fire-and-forget).
|
||||
* Used for query tracking, click-through analytics, and zero-result detection.
|
||||
*/
|
||||
recordAnalytics(events: SearchAnalyticsEventDto[]): void {
|
||||
if (events.length === 0) return;
|
||||
|
||||
this.http
|
||||
.post('/api/v1/advisory-ai/search/analytics', { events })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's server-side search history (up to 50 entries).
|
||||
*/
|
||||
getHistory(): Observable<SearchHistoryEntry[]> {
|
||||
return this.http
|
||||
.get<{ entries: SearchHistoryEntryDto[] }>('/api/v1/advisory-ai/search/history')
|
||||
.pipe(
|
||||
map((response) =>
|
||||
(response.entries ?? []).map((e) => ({
|
||||
historyId: e.historyId,
|
||||
query: e.query,
|
||||
resultCount: e.resultCount ?? undefined,
|
||||
searchedAt: e.searchedAt,
|
||||
})),
|
||||
),
|
||||
catchError(() => of([])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the user's server-side search history.
|
||||
*/
|
||||
clearHistory(): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>('/api/v1/advisory-ai/search/history')
|
||||
.pipe(catchError(() => of(undefined)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a single history entry by ID.
|
||||
*/
|
||||
deleteHistoryEntry(historyId: string): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`/api/v1/advisory-ai/search/history/${encodeURIComponent(historyId)}`)
|
||||
.pipe(catchError(() => of(undefined)));
|
||||
}
|
||||
|
||||
// --- Search feedback (Sprint: SPRINT_20260224_110 G10-001) ---
|
||||
|
||||
submitFeedback(feedback: SearchFeedbackRequest): void {
|
||||
this.http
|
||||
.post('/api/v1/advisory-ai/search/feedback', feedback)
|
||||
.pipe(catchError(() => EMPTY))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// --- Search quality (Sprint: SPRINT_20260224_110 G10-002/003) ---
|
||||
|
||||
getQualityAlerts(
|
||||
status?: string,
|
||||
alertType?: string,
|
||||
): Observable<SearchQualityAlert[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (status) params['status'] = status;
|
||||
if (alertType) params['alertType'] = alertType;
|
||||
|
||||
return this.http
|
||||
.get<SearchQualityAlert[]>('/api/v1/advisory-ai/search/quality/alerts', { params })
|
||||
.pipe(catchError(() => of([])));
|
||||
}
|
||||
|
||||
updateQualityAlert(
|
||||
alertId: string,
|
||||
update: SearchQualityAlertUpdateRequest,
|
||||
): Observable<SearchQualityAlert> {
|
||||
return this.http.patch<SearchQualityAlert>(
|
||||
`/api/v1/advisory-ai/search/quality/alerts/${alertId}`,
|
||||
update,
|
||||
);
|
||||
}
|
||||
|
||||
getQualityMetrics(period = '7d'): Observable<SearchQualityMetrics> {
|
||||
return this.http
|
||||
.get<SearchQualityMetrics>('/api/v1/advisory-ai/search/quality/metrics', {
|
||||
params: { period },
|
||||
})
|
||||
.pipe(
|
||||
catchError(() =>
|
||||
of({
|
||||
totalSearches: 0,
|
||||
zeroResultRate: 0,
|
||||
avgResultCount: 0,
|
||||
feedbackScore: 0,
|
||||
period,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search analytics types (Sprint 106 / G6) ---
|
||||
|
||||
export interface SearchAnalyticsEventDto {
|
||||
eventType: 'query' | 'click' | 'zero_result';
|
||||
query: string;
|
||||
entityKey?: string;
|
||||
domain?: string;
|
||||
resultCount?: number;
|
||||
position?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface SearchHistoryEntryDto {
|
||||
historyId: string;
|
||||
query: string;
|
||||
resultCount?: number;
|
||||
searchedAt: string;
|
||||
}
|
||||
|
||||
export interface SearchHistoryEntry {
|
||||
historyId: string;
|
||||
query: string;
|
||||
resultCount?: number;
|
||||
searchedAt: string;
|
||||
}
|
||||
179
src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts
Normal file
179
src/Web/StellaOps.Web/src/app/core/api/unified-search.models.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Sprint: SPRINT_20260223_099_FE_unified_search_bar_entity_cards_synthesis_panel
|
||||
|
||||
export type UnifiedSearchDomain = 'knowledge' | 'findings' | 'vex' | 'policy' | 'platform' | 'graph' | 'ops_memory' | 'timeline';
|
||||
export type UnifiedEntityType = 'docs' | 'api' | 'doctor' | 'finding' | 'vex_statement' | 'policy_rule' | 'platform_entity' | 'graph_node' | 'ops_event';
|
||||
|
||||
export const SUPPORTED_UNIFIED_DOMAINS: readonly UnifiedSearchDomain[] = ['knowledge', 'findings', 'vex', 'policy', 'platform'];
|
||||
export const SUPPORTED_UNIFIED_ENTITY_TYPES: readonly UnifiedEntityType[] = ['docs', 'api', 'doctor', 'finding', 'vex_statement', 'policy_rule', 'platform_entity'];
|
||||
|
||||
export interface EntityCardPreview {
|
||||
contentType: 'markdown' | 'code' | 'structured';
|
||||
content: string;
|
||||
language?: string;
|
||||
structuredFields?: { label: string; value: string; severity?: string }[];
|
||||
}
|
||||
|
||||
export interface EntityCard {
|
||||
entityKey: string;
|
||||
entityType: UnifiedEntityType;
|
||||
domain: UnifiedSearchDomain;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
severity?: string;
|
||||
actions: EntityCardAction[];
|
||||
metadata?: Record<string, string>;
|
||||
sources: string[];
|
||||
preview?: EntityCardPreview;
|
||||
}
|
||||
|
||||
export interface EntityCardAction {
|
||||
label: string;
|
||||
actionType: 'navigate' | 'copy' | 'run' | 'curl' | 'details';
|
||||
route?: string;
|
||||
command?: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface SynthesisCitation {
|
||||
index: number;
|
||||
entityKey: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SynthesisResult {
|
||||
summary: string;
|
||||
template: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
sourceCount: number;
|
||||
domainsCovered: string[];
|
||||
citations?: SynthesisCitation[];
|
||||
groundingScore?: number;
|
||||
}
|
||||
|
||||
export interface SearchSuggestion {
|
||||
text: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SearchRefinement {
|
||||
text: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface UnifiedSearchResponse {
|
||||
query: string;
|
||||
topK: number;
|
||||
cards: EntityCard[];
|
||||
synthesis: SynthesisResult | null;
|
||||
suggestions?: SearchSuggestion[];
|
||||
refinements?: SearchRefinement[];
|
||||
diagnostics: UnifiedSearchDiagnostics;
|
||||
}
|
||||
|
||||
export interface UnifiedSearchDiagnostics {
|
||||
ftsMatches: number;
|
||||
vectorMatches: number;
|
||||
entityCardCount: number;
|
||||
durationMs: number;
|
||||
usedVector: boolean;
|
||||
mode: string;
|
||||
plan?: QueryPlan;
|
||||
}
|
||||
|
||||
export interface QueryPlan {
|
||||
originalQuery: string;
|
||||
normalizedQuery: string;
|
||||
intent: string;
|
||||
detectedEntities: EntityMention[];
|
||||
domainWeights: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface EntityMention {
|
||||
value: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface UnifiedSearchFilter {
|
||||
domains?: UnifiedSearchDomain[];
|
||||
entityTypes?: UnifiedEntityType[];
|
||||
entityKey?: string;
|
||||
product?: string;
|
||||
version?: string;
|
||||
service?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const DOMAIN_LABELS: Record<UnifiedSearchDomain, string> = {
|
||||
knowledge: 'Knowledge',
|
||||
findings: 'Findings',
|
||||
vex: 'VEX Statements',
|
||||
policy: 'Policy Rules',
|
||||
platform: 'Platform',
|
||||
graph: 'Graph',
|
||||
ops_memory: 'Ops Memory',
|
||||
timeline: 'Timeline',
|
||||
};
|
||||
|
||||
export const DOMAIN_ICONS: Record<UnifiedSearchDomain, string> = {
|
||||
knowledge: 'book',
|
||||
findings: 'alert-triangle',
|
||||
vex: 'shield-check',
|
||||
policy: 'shield',
|
||||
platform: 'layers',
|
||||
graph: 'git-branch',
|
||||
ops_memory: 'database',
|
||||
timeline: 'clock',
|
||||
};
|
||||
|
||||
export const ENTITY_TYPE_LABELS: Record<UnifiedEntityType, string> = {
|
||||
docs: 'Documentation',
|
||||
api: 'API Endpoint',
|
||||
doctor: 'Doctor Check',
|
||||
finding: 'Finding',
|
||||
vex_statement: 'VEX Statement',
|
||||
policy_rule: 'Policy Rule',
|
||||
platform_entity: 'Platform Entity',
|
||||
graph_node: 'Graph Node',
|
||||
ops_event: 'Ops Event',
|
||||
};
|
||||
|
||||
// Search feedback models (Sprint: SPRINT_20260224_110)
|
||||
export type SearchFeedbackSignal = 'helpful' | 'not_helpful';
|
||||
|
||||
export interface SearchFeedbackRequest {
|
||||
query: string;
|
||||
entityKey: string;
|
||||
domain: string;
|
||||
position: number;
|
||||
signal: SearchFeedbackSignal;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface SearchQualityAlert {
|
||||
alertId: string;
|
||||
tenantId: string;
|
||||
alertType: 'zero_result' | 'low_feedback' | 'high_negative_feedback';
|
||||
query: string;
|
||||
occurrenceCount: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
status: 'open' | 'acknowledged' | 'resolved';
|
||||
resolution?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SearchQualityMetrics {
|
||||
totalSearches: number;
|
||||
zeroResultRate: number;
|
||||
avgResultCount: number;
|
||||
feedbackScore: number;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface SearchQualityAlertUpdateRequest {
|
||||
status: 'acknowledged' | 'resolved';
|
||||
resolution?: string;
|
||||
}
|
||||
@@ -1,25 +1,55 @@
|
||||
/**
|
||||
* i18n Service for StellaOps Console
|
||||
* Sprint: SPRINT_0340_0001_0001_first_signal_card_ui
|
||||
* Task: T17
|
||||
*
|
||||
* Provides translation lookup and interpolation for UI micro-copy.
|
||||
* Provides translation lookup and interpolation for UI text.
|
||||
* Fetches translations from Platform API (/platform/i18n/{locale}.json)
|
||||
* with offline fallback to embedded static bundles.
|
||||
*
|
||||
* Key format: flat dot-path (e.g., 'ui.actions.save', 'common.error.not_found')
|
||||
*/
|
||||
|
||||
import { HttpBackend, HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import enTranslations from '../../../i18n/micro-interactions.en.json';
|
||||
import fallbackBgBg from '../../../i18n/bg-BG.common.json';
|
||||
import fallbackDeDe from '../../../i18n/de-DE.common.json';
|
||||
import fallbackEnUs from '../../../i18n/en-US.common.json';
|
||||
import fallbackEsEs from '../../../i18n/es-ES.common.json';
|
||||
import fallbackFrFr from '../../../i18n/fr-FR.common.json';
|
||||
import fallbackRuRu from '../../../i18n/ru-RU.common.json';
|
||||
import fallbackUkUa from '../../../i18n/uk-UA.common.json';
|
||||
import fallbackZhCn from '../../../i18n/zh-CN.common.json';
|
||||
import fallbackZhTw from '../../../i18n/zh-TW.common.json';
|
||||
|
||||
export type Locale = 'en' | 'en-US';
|
||||
export type Locale = string;
|
||||
|
||||
export interface TranslationParams {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'stellaops_locale';
|
||||
export const DEFAULT_LOCALE = 'en-US';
|
||||
type RawTranslationBundle = Record<string, unknown>;
|
||||
|
||||
const FALLBACK_BUNDLES: Readonly<Record<string, RawTranslationBundle>> = {
|
||||
'en-US': fallbackEnUs,
|
||||
'de-DE': fallbackDeDe,
|
||||
'bg-BG': fallbackBgBg,
|
||||
'ru-RU': fallbackRuRu,
|
||||
'es-ES': fallbackEsEs,
|
||||
'fr-FR': fallbackFrFr,
|
||||
'uk-UA': fallbackUkUa,
|
||||
'zh-TW': fallbackZhTw,
|
||||
'zh-CN': fallbackZhCn,
|
||||
};
|
||||
export const SUPPORTED_LOCALES: readonly string[] = Object.freeze(Object.keys(FALLBACK_BUNDLES));
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class I18nService {
|
||||
private readonly _translations = signal<Record<string, unknown>>(enTranslations as Record<string, unknown>);
|
||||
private readonly _locale = signal<Locale>('en');
|
||||
private readonly http: HttpClient;
|
||||
private readonly _translations = signal<Record<string, string>>({});
|
||||
private readonly _locale = signal<Locale>(DEFAULT_LOCALE);
|
||||
|
||||
/** Current locale */
|
||||
readonly locale = computed(() => this._locale());
|
||||
@@ -27,36 +57,76 @@ export class I18nService {
|
||||
/** Whether translations are loaded */
|
||||
readonly isLoaded = computed(() => Object.keys(this._translations()).length > 0);
|
||||
|
||||
constructor() {
|
||||
// Translations are shipped as local assets for offline-first operation.
|
||||
constructor(httpBackend: HttpBackend) {
|
||||
// Use raw HttpClient to avoid DI cycles with interceptors that might
|
||||
// depend on config/auth services not yet initialized.
|
||||
this.http = new HttpClient(httpBackend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations for the current locale.
|
||||
* In production, this would fetch from a CDN or local asset.
|
||||
* Load translations from Platform API with offline fallback.
|
||||
* Called during APP_INITIALIZER after AppConfigService.load().
|
||||
*/
|
||||
async loadTranslations(locale: Locale = 'en'): Promise<void> {
|
||||
async loadTranslations(locale?: string): Promise<void> {
|
||||
const requestedLocale = locale ?? this.getSavedLocale() ?? DEFAULT_LOCALE;
|
||||
const effectiveLocale = normalizeLocale(requestedLocale);
|
||||
|
||||
try {
|
||||
void locale;
|
||||
this._translations.set(enTranslations as Record<string, unknown>);
|
||||
this._locale.set(locale);
|
||||
} catch (error) {
|
||||
console.error('Failed to load translations:', error);
|
||||
// Fallback to empty - will use keys as fallback
|
||||
const bundle = await firstValueFrom(
|
||||
this.http.get<RawTranslationBundle>(`/platform/i18n/${effectiveLocale}.json`, {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
);
|
||||
|
||||
if (bundle && typeof bundle === 'object') {
|
||||
// Remove metadata keys
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(bundle)) {
|
||||
if (!key.startsWith('_') && typeof value === 'string') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
this._translations.set(cleaned);
|
||||
this._locale.set(effectiveLocale);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Platform API unavailable, use embedded fallback.
|
||||
}
|
||||
|
||||
const fallbackTranslations = FALLBACK_BUNDLES[effectiveLocale] ?? FALLBACK_BUNDLES[DEFAULT_LOCALE];
|
||||
|
||||
// Offline fallback: load embedded locale bundle
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(fallbackTranslations)) {
|
||||
if (!key.startsWith('_') && typeof value === 'string') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
this._translations.set(cleaned);
|
||||
this._locale.set(effectiveLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translation by key path (e.g., 'firstSignal.label').
|
||||
* Returns the key itself if translation not found.
|
||||
* Switch to a different locale at runtime.
|
||||
* Re-fetches translations from Platform API.
|
||||
*/
|
||||
async setLocale(locale: string): Promise<void> {
|
||||
const effectiveLocale = normalizeLocale(locale);
|
||||
this.saveLocale(effectiveLocale);
|
||||
await this.loadTranslations(effectiveLocale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key. Returns the key itself if not found.
|
||||
*
|
||||
* @param key Dot-separated key path
|
||||
* @param params Optional interpolation parameters
|
||||
* @param key Flat dot-path key (e.g., 'ui.actions.save')
|
||||
* @param params Named interpolation parameters (e.g., { max: 100 })
|
||||
*/
|
||||
t(key: string, params?: TranslationParams): string {
|
||||
const value = this.getNestedValue(this._translations(), key);
|
||||
const template = this._translations()[key];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
if (template === undefined) {
|
||||
if (this.isLoaded()) {
|
||||
console.warn(`Translation key not found: ${key}`);
|
||||
}
|
||||
@@ -64,94 +134,86 @@ export class I18nService {
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
return value;
|
||||
return template;
|
||||
}
|
||||
|
||||
const formatted = this.formatIcu(value, params);
|
||||
return this.interpolate(formatted, params);
|
||||
return this.interpolate(template, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to translate without emitting warnings when missing.
|
||||
*/
|
||||
tryT(key: string, params?: TranslationParams): string | null {
|
||||
const value = this.getNestedValue(this._translations(), key);
|
||||
const template = this._translations()[key];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
if (template === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
return value;
|
||||
return template;
|
||||
}
|
||||
|
||||
const formatted = this.formatIcu(value, params);
|
||||
return this.interpolate(formatted, params);
|
||||
return this.interpolate(template, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using dot notation.
|
||||
*/
|
||||
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj as unknown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate parameters into a translation string.
|
||||
* Uses {param} syntax.
|
||||
* Replace {param} placeholders with values.
|
||||
*/
|
||||
private interpolate(template: string, params: TranslationParams): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
const value = params[key];
|
||||
return template.replace(/\{(\w+)\}/g, (match, paramKey) => {
|
||||
const value = params[paramKey];
|
||||
return value !== undefined ? String(value) : match;
|
||||
});
|
||||
}
|
||||
|
||||
private formatIcu(template: string, params: TranslationParams): string {
|
||||
return template.replace(
|
||||
/\{(\w+),\s*(plural|select),\s*([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g,
|
||||
(_match, key: string, type: string, body: string) => {
|
||||
const options = this.parseIcuOptions(body);
|
||||
if (options.size === 0) {
|
||||
return _match;
|
||||
}
|
||||
|
||||
if (type === 'plural') {
|
||||
const rawValue = params[key];
|
||||
const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return options.get('other') ?? _match;
|
||||
}
|
||||
|
||||
const exact = options.get(`=${numericValue}`);
|
||||
if (exact !== undefined) {
|
||||
return exact.replace(/#/g, String(numericValue));
|
||||
}
|
||||
|
||||
const pluralCategory = new Intl.PluralRules(this._locale()).select(numericValue);
|
||||
const pluralMessage = options.get(pluralCategory) ?? options.get('other') ?? _match;
|
||||
return pluralMessage.replace(/#/g, String(numericValue));
|
||||
}
|
||||
|
||||
const selectKey = String(params[key] ?? 'other');
|
||||
return options.get(selectKey) ?? options.get('other') ?? _match;
|
||||
}
|
||||
);
|
||||
private getSavedLocale(): string | null {
|
||||
try {
|
||||
const savedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
return savedLocale ? normalizeLocale(savedLocale) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseIcuOptions(body: string): Map<string, string> {
|
||||
const options = new Map<string, string>();
|
||||
const optionPattern = /([=\w-]+)\s*\{([^{}]*)\}/g;
|
||||
let match: RegExpExecArray | null = optionPattern.exec(body);
|
||||
while (match) {
|
||||
options.set(match[1], match[2]);
|
||||
match = optionPattern.exec(body);
|
||||
private saveLocale(locale: string): void {
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// localStorage unavailable (e.g., incognito Safari)
|
||||
}
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLocale(locale: string): string {
|
||||
const trimmed = locale?.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const exactMatch = SUPPORTED_LOCALES.find(
|
||||
(supported) => supported.localeCompare(trimmed, undefined, { sensitivity: 'accent' }) === 0
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
const caseInsensitiveMatch = SUPPORTED_LOCALES.find(
|
||||
(supported) => supported.localeCompare(trimmed, undefined, { sensitivity: 'base' }) === 0
|
||||
);
|
||||
if (caseInsensitiveMatch) {
|
||||
return caseInsensitiveMatch;
|
||||
}
|
||||
|
||||
const language = trimmed.split(/[-_]/, 1)[0]?.toLowerCase();
|
||||
if (language) {
|
||||
const languageMatch = SUPPORTED_LOCALES.find((supported) =>
|
||||
supported.toLowerCase().startsWith(`${language}-`)
|
||||
);
|
||||
if (languageMatch) {
|
||||
return languageMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
* Task: T17
|
||||
*/
|
||||
|
||||
export { I18nService, type Locale, type TranslationParams } from './i18n.service';
|
||||
export {
|
||||
I18nService,
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
type Locale,
|
||||
type TranslationParams,
|
||||
} from './i18n.service';
|
||||
export { TranslatePipe } from './translate.pipe';
|
||||
export { LocaleCatalogService } from './locale-catalog.service';
|
||||
export { UserLocalePreferenceService } from './user-locale-preference.service';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface LocaleCatalogResponse {
|
||||
locales?: string[] | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocaleCatalogService {
|
||||
private cachedLocales: readonly string[] | null = null;
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
async getAvailableLocalesAsync(fallbackLocales: readonly string[]): Promise<readonly string[]> {
|
||||
if (this.cachedLocales && this.cachedLocales.length > 0) {
|
||||
return this.cachedLocales;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<LocaleCatalogResponse>('/api/v1/platform/localization/locales')
|
||||
);
|
||||
|
||||
const locales = (response?.locales ?? [])
|
||||
.map((locale) => locale?.trim())
|
||||
.filter((locale): locale is string => Boolean(locale && locale.length > 0));
|
||||
|
||||
if (locales.length > 0) {
|
||||
this.cachedLocales = normalizeLocales(locales);
|
||||
return this.cachedLocales;
|
||||
}
|
||||
} catch {
|
||||
// Fallback remains local and deterministic when backend locale catalog is unavailable.
|
||||
}
|
||||
|
||||
this.cachedLocales = normalizeLocales(fallbackLocales);
|
||||
return this.cachedLocales;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cachedLocales = null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLocales(locales: readonly string[]): readonly string[] {
|
||||
return [...new Set(locales)].sort((left, right) =>
|
||||
left.localeCompare(right, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
interface LanguagePreferenceResponse {
|
||||
locale?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserLocalePreferenceService {
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
async getLocaleAsync(): Promise<string | null> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<LanguagePreferenceResponse>('/api/v1/platform/preferences/language')
|
||||
);
|
||||
const locale = response?.locale?.trim();
|
||||
return locale && locale.length > 0 ? locale : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setLocaleAsync(locale: string): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.put('/api/v1/platform/preferences/language', {
|
||||
locale,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Analyze - Scanning, vulnerabilities, and reachability
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'analyze',
|
||||
label: 'Analyze',
|
||||
@@ -90,9 +90,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Analytics - SBOM and attestation insights
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'analytics',
|
||||
label: 'Analytics',
|
||||
@@ -109,9 +109,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
// Triage - Artifact management and risk assessment
|
||||
// -------------------------------------------------------------------------
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'triage',
|
||||
label: 'Triage',
|
||||
@@ -209,6 +209,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
label: 'Ops',
|
||||
icon: 'server',
|
||||
items: [
|
||||
{
|
||||
id: 'search-quality',
|
||||
label: 'Search Quality',
|
||||
route: '/ops/operations/search-quality',
|
||||
icon: 'search',
|
||||
requiredScopes: ['advisory-ai:admin'],
|
||||
tooltip: 'Search feedback analytics, zero-result alerts, and quality metrics',
|
||||
},
|
||||
{
|
||||
id: 'sbom-sources',
|
||||
label: 'SBOM Sources',
|
||||
@@ -620,6 +628,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
icon: 'scan',
|
||||
tooltip: 'Scanner offline kits, baselines, and determinism settings',
|
||||
},
|
||||
{
|
||||
id: 'identity-providers',
|
||||
label: 'Identity Providers',
|
||||
route: '/settings/identity-providers',
|
||||
icon: 'id-card',
|
||||
requiredScopes: ['ui.admin'],
|
||||
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -640,6 +656,12 @@ export const USER_MENU_ITEMS = [
|
||||
route: '/settings',
|
||||
icon: 'settings',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: 'Language',
|
||||
route: '/settings/language',
|
||||
icon: 'settings',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import type { UnifiedSearchDomain, UnifiedSearchFilter } from '../api/unified-search.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AmbientContextService {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
currentDomain(): UnifiedSearchDomain | null {
|
||||
const url = this.router.url;
|
||||
|
||||
if (url.startsWith('/security/triage') || url.startsWith('/security/findings')) {
|
||||
return 'findings';
|
||||
}
|
||||
|
||||
if (url.startsWith('/security/advisories-vex') || url.startsWith('/vex-hub')) {
|
||||
return 'vex';
|
||||
}
|
||||
|
||||
if (url.startsWith('/ops/policy')) {
|
||||
return 'policy';
|
||||
}
|
||||
|
||||
if (url.startsWith('/ops/operations/doctor') || url.startsWith('/ops/operations/system-health')) {
|
||||
return 'knowledge';
|
||||
}
|
||||
|
||||
if (url.startsWith('/ops/graph') || url.startsWith('/security/reach')) {
|
||||
return 'graph';
|
||||
}
|
||||
|
||||
if (url.startsWith('/ops/operations/jobs') || url.startsWith('/ops/operations/scheduler')) {
|
||||
return 'ops_memory';
|
||||
}
|
||||
|
||||
if (url.startsWith('/ops/timeline') || url.startsWith('/audit')) {
|
||||
return 'timeline';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
buildContextFilter(): UnifiedSearchFilter {
|
||||
const domain = this.currentDomain();
|
||||
if (!domain) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { domains: [domain] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface SearchToChatContext {
|
||||
query: string;
|
||||
entityCards: any[]; // EntityCard[]
|
||||
synthesis: any | null; // SynthesisResult
|
||||
}
|
||||
|
||||
export interface ChatToSearchContext {
|
||||
query: string;
|
||||
domain?: string;
|
||||
entityKey?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchChatContextService {
|
||||
private readonly _searchToChat = signal<SearchToChatContext | null>(null);
|
||||
private readonly _chatToSearch = signal<ChatToSearchContext | null>(null);
|
||||
|
||||
setSearchToChat(context: SearchToChatContext): void {
|
||||
this._searchToChat.set(context);
|
||||
}
|
||||
|
||||
consumeSearchToChat(): SearchToChatContext | null {
|
||||
const ctx = this._searchToChat();
|
||||
this._searchToChat.set(null);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
setChatToSearch(context: ChatToSearchContext): void {
|
||||
this._chatToSearch.set(context);
|
||||
}
|
||||
|
||||
consumeChatToSearch(): ChatToSearchContext | null {
|
||||
const ctx = this._chatToSearch();
|
||||
this._chatToSearch.set(null);
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
// Task: CH-010 — Chat message component for rendering conversation turns
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
|
||||
import { Component, Input, Output, EventEmitter, computed, inject, signal } from '@angular/core';
|
||||
|
||||
import {
|
||||
ConversationTurn,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './chat.models';
|
||||
import { ObjectLinkChipComponent } from './object-link-chip.component';
|
||||
import { ActionButtonComponent } from './action-button.component';
|
||||
import { SearchChatContextService } from '../../../core/services/search-chat-context.service';
|
||||
|
||||
interface MessageSegment {
|
||||
type: 'text' | 'link';
|
||||
@@ -116,6 +117,21 @@ interface MessageSegment {
|
||||
</details>
|
||||
}
|
||||
|
||||
<!-- Search for more -->
|
||||
@if (turn.role === 'assistant') {
|
||||
<button
|
||||
type="button"
|
||||
class="search-more-link"
|
||||
(click)="onSearchForMore()"
|
||||
title="Search for more information in the knowledge base"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
Search for more
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Proposed actions -->
|
||||
@if (turn.proposedActions && turn.proposedActions.length > 0) {
|
||||
<div class="message-actions">
|
||||
@@ -321,6 +337,30 @@ interface MessageSegment {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.search-more-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border-secondary, #d1d5db);
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm, 0.75rem);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.search-more-link:hover {
|
||||
background: var(--color-nav-hover, #f3f4f6);
|
||||
border-color: var(--color-brand-primary, #2563eb);
|
||||
}
|
||||
|
||||
.search-more-link svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -364,6 +404,9 @@ export class ChatMessageComponent {
|
||||
@Input({ required: true }) turn!: ConversationTurn;
|
||||
@Output() linkNavigate = new EventEmitter<ParsedObjectLink>();
|
||||
@Output() actionExecute = new EventEmitter<ProposedAction>();
|
||||
@Output() searchForMore = new EventEmitter<string>();
|
||||
|
||||
private readonly searchChatContext = inject(SearchChatContextService);
|
||||
|
||||
readonly showCitations = signal(false);
|
||||
readonly copied = signal(false);
|
||||
@@ -473,6 +516,38 @@ export class ChatMessageComponent {
|
||||
this.actionExecute.emit(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a search query from the assistant message content.
|
||||
* Prefers CVE IDs if present, otherwise truncates the message text.
|
||||
*/
|
||||
onSearchForMore(): void {
|
||||
const query = this.extractSearchQuery(this.turn.content);
|
||||
this.searchChatContext.setChatToSearch({
|
||||
query,
|
||||
});
|
||||
this.searchForMore.emit(query);
|
||||
}
|
||||
|
||||
private extractSearchQuery(content: string): string {
|
||||
// Extract CVE IDs if present
|
||||
const cveRegex = /CVE-\d{4}-\d{4,}/gi;
|
||||
const cveMatches = content.match(cveRegex);
|
||||
if (cveMatches && cveMatches.length > 0) {
|
||||
return cveMatches[0];
|
||||
}
|
||||
|
||||
// Otherwise use the first 100 characters of the plain text content
|
||||
const plainText = content
|
||||
.replace(/\[([a-z]+):[^\]]+\s*↗?\]/gi, '') // strip object links
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1') // strip bold markdown
|
||||
.replace(/\*([^*]+)\*/g, '$1') // strip italic markdown
|
||||
.replace(/`([^`]+)`/g, '$1') // strip inline code
|
||||
.replace(/\n/g, ' ')
|
||||
.trim();
|
||||
|
||||
return plainText.length > 100 ? plainText.substring(0, 100) : plainText;
|
||||
}
|
||||
|
||||
async copyMessage(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.turn.content);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatMessageComponent } from './chat-message.component';
|
||||
@@ -109,7 +110,7 @@ import {
|
||||
<h3>Ask AdvisoryAI</h3>
|
||||
<p>Ask questions about vulnerabilities, exploitability, remediation, or integrations.</p>
|
||||
<div class="suggestions">
|
||||
@for (suggestion of suggestions; track suggestion) {
|
||||
@for (suggestion of suggestions(); track suggestion) {
|
||||
<button
|
||||
type="button"
|
||||
class="suggestion-btn"
|
||||
@@ -124,7 +125,8 @@ import {
|
||||
<stellaops-chat-message
|
||||
[turn]="turn"
|
||||
(linkNavigate)="onLinkNavigate($event)"
|
||||
(actionExecute)="onActionExecute($event)"/>
|
||||
(actionExecute)="onActionExecute($event)"
|
||||
(searchForMore)="onSearchForMore($event)"/>
|
||||
}
|
||||
|
||||
<!-- Streaming response -->
|
||||
@@ -534,11 +536,13 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() linkNavigate = new EventEmitter<ParsedObjectLink>();
|
||||
@Output() actionExecute = new EventEmitter<{ action: ProposedAction; turnId: string }>();
|
||||
@Output() searchForMore = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('inputField') inputField!: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
private readonly chatService = inject(ChatService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
inputValue = '';
|
||||
@@ -560,12 +564,47 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
return 'Ask AdvisoryAI about this finding...';
|
||||
});
|
||||
|
||||
readonly suggestions = [
|
||||
'Is this exploitable?',
|
||||
'What is the remediation?',
|
||||
'Show me the evidence',
|
||||
'Create a VEX statement',
|
||||
];
|
||||
readonly suggestions = computed(() => {
|
||||
const url = this.router.url;
|
||||
|
||||
// Vulnerability detail pages: keep original context-specific suggestions
|
||||
if (url.match(/\/security\/(findings|triage)\/[^/]+/)) {
|
||||
return [
|
||||
'Is this exploitable?',
|
||||
'What is the remediation?',
|
||||
'Show me the evidence',
|
||||
'Create a VEX statement',
|
||||
];
|
||||
}
|
||||
|
||||
// Policy pages
|
||||
if (url.startsWith('/ops/policy')) {
|
||||
return [
|
||||
'Explain the release promotion workflow',
|
||||
'What policy gates are failing?',
|
||||
'How do I create a policy exception?',
|
||||
'What health checks should I run first?',
|
||||
];
|
||||
}
|
||||
|
||||
// Doctor / health pages
|
||||
if (url.startsWith('/ops/operations/doctor')) {
|
||||
return [
|
||||
'What health checks should I run first?',
|
||||
'How do I troubleshoot database connectivity?',
|
||||
'Explain the system readiness checks',
|
||||
'What can Stella Ops do?',
|
||||
];
|
||||
}
|
||||
|
||||
// Default: general onboarding suggestions
|
||||
return [
|
||||
'What can Stella Ops do?',
|
||||
'How do I set up my first scan?',
|
||||
'Explain the release promotion workflow',
|
||||
'What health checks should I run first?',
|
||||
];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Auto-scroll on new content
|
||||
@@ -653,6 +692,10 @@ export class ChatComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onSearchForMore(query: string): void {
|
||||
this.searchForMore.emit(query);
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<div class="sources-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<h1>{{ 'ui.sources_dashboard.title' | translate }}</h1>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
[disabled]="verifying()"
|
||||
(click)="onVerifyLast24h()"
|
||||
>
|
||||
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
|
||||
{{ verifying() ? ('ui.sources_dashboard.verifying' | translate) : ('ui.sources_dashboard.verify_24h' | translate) }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">
|
||||
Refresh
|
||||
{{ 'ui.actions.refresh' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -18,14 +18,14 @@
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading AOC metrics...</p>
|
||||
<p>{{ 'ui.sources_dashboard.loading_aoc' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-state">
|
||||
<p class="error-message">{{ error() }}</p>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">{{ 'ui.actions.retry' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -33,24 +33,24 @@
|
||||
<div class="metrics-grid">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<div class="tile tile-pass-fail" [class]="passRateClass()">
|
||||
<h2 class="tile-title">AOC Pass/Fail</h2>
|
||||
<h2 class="tile-title">{{ 'ui.sources_dashboard.pass_fail_title' | translate }}</h2>
|
||||
<div class="tile-content">
|
||||
<div class="metric-large">
|
||||
<span class="value">{{ passRate() }}%</span>
|
||||
<span class="label">Pass Rate</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.pass_rate' | translate }}</span>
|
||||
</div>
|
||||
<div class="metric-details">
|
||||
<div class="detail">
|
||||
<span class="count pass">{{ m.passCount | number }}</span>
|
||||
<span class="label">Passed</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.passed' | translate }}</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count fail">{{ m.failCount | number }}</span>
|
||||
<span class="label">Failed</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.failed' | translate }}</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count total">{{ m.totalCount | number }}</span>
|
||||
<span class="label">Total</span>
|
||||
<span class="label">{{ 'ui.labels.total' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,10 +58,10 @@
|
||||
|
||||
<!-- Recent Violations Tile -->
|
||||
<div class="tile tile-violations">
|
||||
<h2 class="tile-title">Recent Violations</h2>
|
||||
<h2 class="tile-title">{{ 'ui.sources_dashboard.recent_violations' | translate }}</h2>
|
||||
<div class="tile-content">
|
||||
@if (m.recentViolations.length === 0) {
|
||||
<p class="empty-state">No violations in time window</p>
|
||||
<p class="empty-state">{{ 'ui.sources_dashboard.no_violations' | translate }}</p>
|
||||
} @else {
|
||||
<ul class="violations-list">
|
||||
@for (v of m.recentViolations; track v.code) {
|
||||
@@ -81,28 +81,28 @@
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<div class="tile tile-throughput" [class]="throughputStatus()">
|
||||
<h2 class="tile-title">Ingest Throughput</h2>
|
||||
<h2 class="tile-title">{{ 'ui.sources_dashboard.throughput_title' | translate }}</h2>
|
||||
<div class="tile-content">
|
||||
<div class="throughput-grid">
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
|
||||
<span class="label">docs/min</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.docs_per_min' | translate }}</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
|
||||
<span class="label">avg ms</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.avg_ms' | translate }}</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
|
||||
<span class="label">p95 ms</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.p95_ms' | translate }}</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
|
||||
<span class="label">queue</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.queue' | translate }}</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
|
||||
<span class="label">errors</span>
|
||||
<span class="label">{{ 'ui.sources_dashboard.errors' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,22 +112,22 @@
|
||||
<!-- Verification Result -->
|
||||
@if (verificationResult(); as result) {
|
||||
<div class="verification-result" [class]="'status-' + result.status">
|
||||
<h3>Verification Complete</h3>
|
||||
<h3>{{ 'ui.sources_dashboard.verification_complete' | translate }}</h3>
|
||||
<div class="result-summary">
|
||||
<span class="status-badge">{{ result.status | titlecase }}</span>
|
||||
<span>Checked: {{ result.checkedCount | number }}</span>
|
||||
<span>Passed: {{ result.passedCount | number }}</span>
|
||||
<span>Failed: {{ result.failedCount | number }}</span>
|
||||
<span>{{ 'ui.sources_dashboard.checked' | translate }} {{ result.checkedCount | number }}</span>
|
||||
<span>{{ 'ui.sources_dashboard.passed' | translate }}: {{ result.passedCount | number }}</span>
|
||||
<span>{{ 'ui.sources_dashboard.failed' | translate }}: {{ result.failedCount | number }}</span>
|
||||
</div>
|
||||
@if (result.violations.length > 0) {
|
||||
<details class="violations-details">
|
||||
<summary>View {{ result.violations.length }} violation(s)</summary>
|
||||
<summary>{{ 'ui.actions.view' | translate }} {{ result.violations.length }} {{ 'ui.sources_dashboard.violations' | translate }}</summary>
|
||||
<ul class="violation-list">
|
||||
@for (v of result.violations; track v.documentId) {
|
||||
<li>
|
||||
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
|
||||
@if (v.field) {
|
||||
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
|
||||
<br>{{ 'ui.sources_dashboard.field' | translate }} {{ v.field }} ({{ 'ui.sources_dashboard.expected' | translate }} {{ v.expected }}, {{ 'ui.sources_dashboard.actual' | translate }} {{ v.actual }})
|
||||
}
|
||||
</li>
|
||||
}
|
||||
@@ -135,14 +135,14 @@
|
||||
</details>
|
||||
}
|
||||
<p class="cli-hint">
|
||||
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
|
||||
{{ 'ui.sources_dashboard.cli_equivalent' | translate }} <code>stella aoc verify --since=24h --tenant=default</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="time-window">
|
||||
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
|
||||
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
|
||||
{{ 'ui.sources_dashboard.data_from' | translate }} {{ m.timeWindow.start | date:'short' }} {{ 'ui.sources_dashboard.to' | translate }} {{ m.timeWindow.end | date:'short' }}
|
||||
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}{{ 'ui.sources_dashboard.hour_window' | translate }})
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import { TranslatePipe } from '../../core/i18n';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocViolationSummary,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
|
||||
@Component({
|
||||
selector: 'app-sources-dashboard',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, TranslatePipe],
|
||||
templateUrl: './sources-dashboard.component.html',
|
||||
styleUrls: ['./sources-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Header -->
|
||||
<header class="thread-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="onBack()" matTooltip="Back to list">
|
||||
<button mat-icon-button (click)="onBack()" [matTooltip]="'ui.evidence_thread.back_to_list' | translate">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
@if (thread()?.thread?.artifactName) {
|
||||
{{ thread()?.thread?.artifactName }}
|
||||
} @else {
|
||||
Evidence Thread
|
||||
{{ 'ui.evidence_thread.title_default' | translate }}
|
||||
}
|
||||
</h1>
|
||||
<div class="thread-digest">
|
||||
<code>{{ shortDigest() }}</code>
|
||||
<button mat-icon-button (click)="copyDigest()" matTooltip="Copy full digest" class="copy-btn">
|
||||
<button mat-icon-button (click)="copyDigest()" [matTooltip]="'ui.evidence_thread.copy_digest' | translate" class="copy-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -35,25 +35,25 @@
|
||||
@if (thread()?.thread?.riskScore !== undefined && thread()?.thread?.riskScore !== null) {
|
||||
<mat-chip>
|
||||
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Risk: {{ thread()?.thread?.riskScore | number:'1.1-1' }}
|
||||
{{ 'ui.evidence_thread.risk_label' | translate }} {{ thread()?.thread?.riskScore | number:'1.1-1' }}
|
||||
</mat-chip>
|
||||
}
|
||||
|
||||
<mat-chip>
|
||||
<svg matChipAvatar xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
{{ nodeCount() }} nodes
|
||||
{{ nodeCount() }} {{ 'ui.evidence_thread.nodes' | translate }}
|
||||
</mat-chip>
|
||||
</mat-chip-set>
|
||||
}
|
||||
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="onRefresh()" matTooltip="Refresh" [disabled]="loading()">
|
||||
<button mat-icon-button (click)="onRefresh()" [matTooltip]="'ui.actions.refresh' | translate" [disabled]="loading()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button color="primary" (click)="onExport()" [disabled]="loading() || !thread()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Export
|
||||
{{ 'ui.actions.export' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<p>Loading evidence thread...</p>
|
||||
<p>{{ 'ui.evidence_thread.loading' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="onRefresh()">
|
||||
Try Again
|
||||
{{ 'ui.error.try_again' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -91,7 +91,7 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
|
||||
<span>Graph</span>
|
||||
<span>{{ 'ui.evidence_thread.graph_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
@@ -109,7 +109,7 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span>Timeline</span>
|
||||
<span>{{ 'ui.evidence_thread.timeline_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
@@ -126,7 +126,7 @@
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span>Transcript</span>
|
||||
<span>{{ 'ui.evidence_thread.transcript_tab' | translate }}</span>
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<div class="tab-content">
|
||||
@@ -161,9 +161,9 @@
|
||||
@if (!thread() && !loading() && !error()) {
|
||||
<div class="empty-container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
|
||||
<p>No evidence thread found for this artifact.</p>
|
||||
<p>{{ 'ui.evidence_thread.not_found' | translate }}</p>
|
||||
<button mat-raised-button (click)="onBack()">
|
||||
Back to List
|
||||
{{ 'ui.actions.back_to_list' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EvidenceTimelinePanelComponent } from '../evidence-timeline-panel/evide
|
||||
import { EvidenceTranscriptPanelComponent } from '../evidence-transcript-panel/evidence-transcript-panel.component';
|
||||
import { EvidenceNodeCardComponent } from '../evidence-node-card/evidence-node-card.component';
|
||||
import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidence-export-dialog.component';
|
||||
import { TranslatePipe } from '../../../../core/i18n/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-evidence-thread-view',
|
||||
@@ -40,7 +41,8 @@ import { EvidenceExportDialogComponent } from '../evidence-export-dialog/evidenc
|
||||
EvidenceGraphPanelComponent,
|
||||
EvidenceTimelinePanelComponent,
|
||||
EvidenceTranscriptPanelComponent,
|
||||
EvidenceNodeCardComponent
|
||||
EvidenceNodeCardComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './evidence-thread-view.component.html',
|
||||
styleUrls: ['./evidence-thread-view.component.scss'],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- Header -->
|
||||
<header class="center-header">
|
||||
<div class="header-left">
|
||||
<h2 class="center-title">Exception Center</h2>
|
||||
<h2 class="center-title">{{ 'ui.exception_center.title' | translate }}</h2>
|
||||
<div class="status-chips">
|
||||
@for (col of kanbanColumns; track col.status) {
|
||||
<span
|
||||
@@ -24,7 +24,7 @@
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'list'"
|
||||
(click)="setViewMode('list')"
|
||||
title="List view"
|
||||
[title]="'ui.exception_center.list_view' | translate"
|
||||
>
|
||||
=
|
||||
</button>
|
||||
@@ -32,16 +32,16 @@
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'kanban'"
|
||||
(click)="setViewMode('kanban')"
|
||||
title="Kanban view"
|
||||
[title]="'ui.exception_center.kanban_view' | translate"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-filter" (click)="toggleFilters()" [class.active]="showFilters()">
|
||||
Filters
|
||||
{{ 'ui.labels.filters' | translate }}
|
||||
</button>
|
||||
<button class="btn-create" (click)="onCreate()">
|
||||
+ New Exception
|
||||
{{ 'ui.exception_center.new_exception' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -51,18 +51,18 @@
|
||||
<div class="filters-panel">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Search</label>
|
||||
<label class="filter-label">{{ 'ui.actions.search' | translate }}</label>
|
||||
<input
|
||||
type="search"
|
||||
class="filter-input"
|
||||
placeholder="Search exceptions..."
|
||||
[placeholder]="'ui.exception_center.search_placeholder' | translate"
|
||||
[value]="filter().search || ''"
|
||||
(input)="updateFilter('search', $any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Type</label>
|
||||
<label class="filter-label">{{ 'ui.labels.type' | translate }}</label>
|
||||
<div class="filter-chips">
|
||||
@for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) {
|
||||
<button
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Severity</label>
|
||||
<label class="filter-label">{{ 'ui.labels.severity' | translate }}</label>
|
||||
<div class="filter-chips">
|
||||
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
|
||||
<button
|
||||
@@ -93,7 +93,7 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Tags</label>
|
||||
<label class="filter-label">{{ 'ui.labels.tags' | translate }}</label>
|
||||
<div class="filter-chips tags">
|
||||
@for (tag of allTags().slice(0, 8); track tag) {
|
||||
<button
|
||||
@@ -114,12 +114,12 @@
|
||||
[checked]="filter().expiringSoon"
|
||||
(change)="updateFilter('expiringSoon', $any($event.target).checked)"
|
||||
/>
|
||||
Expiring soon
|
||||
{{ 'ui.exception_center.expiring_soon' | translate }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-clear-filters" (click)="clearFilters()">Clear filters</button>
|
||||
<button class="btn-clear-filters" (click)="clearFilters()">{{ 'ui.exception_center.clear_filters' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -129,31 +129,31 @@
|
||||
<!-- Sort Header -->
|
||||
<div class="list-header">
|
||||
<button class="sort-btn" (click)="setSort('title')">
|
||||
Title
|
||||
{{ 'ui.labels.title' | translate }}
|
||||
@if (sort().field === 'title') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<button class="sort-btn" (click)="setSort('severity')">
|
||||
Severity
|
||||
{{ 'ui.labels.severity' | translate }}
|
||||
@if (sort().field === 'severity') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<span class="col-header">Status</span>
|
||||
<span class="col-header">{{ 'ui.labels.status' | translate }}</span>
|
||||
<button class="sort-btn" (click)="setSort('expiresAt')">
|
||||
Expires
|
||||
{{ 'ui.labels.expires' | translate }}
|
||||
@if (sort().field === 'expiresAt') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<button class="sort-btn" (click)="setSort('updatedAt')">
|
||||
Updated
|
||||
{{ 'ui.labels.updated' | translate }}
|
||||
@if (sort().field === 'updatedAt') {
|
||||
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
|
||||
}
|
||||
</button>
|
||||
<span class="col-header">Actions</span>
|
||||
<span class="col-header">{{ 'ui.labels.actions' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Exception Rows -->
|
||||
@@ -206,8 +206,8 @@
|
||||
{{ trans.action }}
|
||||
</button>
|
||||
}
|
||||
<button class="action-btn audit" (click)="onViewAudit(exc)" title="View audit log">
|
||||
[A]
|
||||
<button class="action-btn audit" (click)="onViewAudit(exc)" [title]="'ui.exception_center.audit_title' | translate">
|
||||
{{ 'ui.exception_center.audit_label' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,8 +215,8 @@
|
||||
|
||||
@if (filteredExceptions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No exceptions match the current filters</p>
|
||||
<button class="btn-link" (click)="clearFilters()">Clear filters</button>
|
||||
<p>{{ 'ui.exception_center.no_exceptions' | translate }}</p>
|
||||
<button class="btn-link" (click)="clearFilters()">{{ 'ui.exception_center.clear_filters' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -277,7 +277,7 @@
|
||||
}
|
||||
|
||||
@if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) {
|
||||
<div class="column-empty">No exceptions</div>
|
||||
<div class="column-empty">{{ 'ui.exception_center.column_empty' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,7 +288,7 @@
|
||||
<!-- Footer Stats -->
|
||||
<footer class="center-footer">
|
||||
<span class="total-count">
|
||||
{{ filteredExceptions().length }} of {{ exceptions().length }} exceptions
|
||||
{{ filteredExceptions().length }} {{ 'ui.labels.of' | translate }} {{ exceptions().length }} {{ 'ui.exception_center.exceptions_suffix' | translate }}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
EXCEPTION_TRANSITIONS,
|
||||
KANBAN_COLUMNS,
|
||||
} from '../../core/api/exception.models';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-center',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, TranslatePipe],
|
||||
templateUrl: './exception-center.component.html',
|
||||
styleUrls: ['./exception-center.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<!-- Header with filters -->
|
||||
<header class="findings-header">
|
||||
<div class="header-row">
|
||||
<h2 class="findings-title">Findings</h2>
|
||||
<h2 class="findings-title">{{ 'ui.findings.title' | translate }}</h2>
|
||||
<div class="findings-count">
|
||||
{{ displayFindings().length }} of {{ scoredFindings().length }}
|
||||
</div>
|
||||
@@ -10,7 +10,7 @@
|
||||
@if (scanId()) {
|
||||
<stella-export-audit-pack-button
|
||||
[scanId]="scanId()"
|
||||
aria-label="Export all findings" />
|
||||
[attr.aria-label]="'ui.findings.export_all' | translate" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search findings..."
|
||||
[placeholder]="'ui.findings.search_placeholder' | translate"
|
||||
[ngModel]="filter().search ?? ''"
|
||||
(ngModelChange)="setSearch($event)"
|
||||
class="search-input"
|
||||
@@ -65,7 +65,7 @@
|
||||
class="clear-filters-btn"
|
||||
(click)="clearFilters()"
|
||||
>
|
||||
Clear Filters
|
||||
{{ 'ui.findings.clear_filters' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -74,19 +74,19 @@
|
||||
<!-- Selection actions -->
|
||||
@if (selectionCount() > 0) {
|
||||
<div class="selection-bar">
|
||||
<span class="selection-count">{{ selectionCount() }} selected</span>
|
||||
<span class="selection-count">{{ selectionCount() }} {{ 'ui.labels.selected' | translate }}</span>
|
||||
<button type="button" class="action-btn" (click)="clearSelection()">
|
||||
Clear
|
||||
{{ 'ui.actions.clear' | translate }}
|
||||
</button>
|
||||
<!-- Placeholder for bulk actions -->
|
||||
<button type="button" class="action-btn primary">
|
||||
Bulk Triage
|
||||
{{ 'ui.findings.bulk_triage' | translate }}
|
||||
</button>
|
||||
@if (scanId()) {
|
||||
<stella-export-audit-pack-button
|
||||
[scanId]="scanId()"
|
||||
[findingIds]="selectedIdsArray()"
|
||||
aria-label="Export selected findings" />
|
||||
[attr.aria-label]="'ui.findings.export_selected' | translate" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -102,7 +102,7 @@
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="selectionCount() > 0 && !allSelected()"
|
||||
(change)="toggleSelectAll()"
|
||||
aria-label="Select all findings"
|
||||
[attr.aria-label]="'ui.findings.select_all' | translate"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
@@ -110,39 +110,39 @@
|
||||
(click)="setSort('score')"
|
||||
[attr.aria-sort]="sortField() === 'score' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Score <span [innerHTML]="getSortIcon('score')"></span>
|
||||
{{ 'ui.labels.score' | translate }} <span [innerHTML]="getSortIcon('score')"></span>
|
||||
</th>
|
||||
<th
|
||||
class="col-trust sortable"
|
||||
(click)="setSort('trust')"
|
||||
[attr.aria-sort]="sortField() === 'trust' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Trust <span [innerHTML]="getSortIcon('trust')"></span>
|
||||
{{ 'ui.findings.trust' | translate }} <span [innerHTML]="getSortIcon('trust')"></span>
|
||||
</th>
|
||||
<th
|
||||
class="col-advisory sortable"
|
||||
(click)="setSort('advisoryId')"
|
||||
[attr.aria-sort]="sortField() === 'advisoryId' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Advisory <span [innerHTML]="getSortIcon('advisoryId')"></span>
|
||||
{{ 'ui.findings.advisory' | translate }} <span [innerHTML]="getSortIcon('advisoryId')"></span>
|
||||
</th>
|
||||
<th
|
||||
class="col-package sortable"
|
||||
(click)="setSort('packageName')"
|
||||
[attr.aria-sort]="sortField() === 'packageName' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Package <span [innerHTML]="getSortIcon('packageName')"></span>
|
||||
{{ 'ui.findings.package' | translate }} <span [innerHTML]="getSortIcon('packageName')"></span>
|
||||
</th>
|
||||
<th class="col-flags">Flags</th>
|
||||
<th class="col-flags">{{ 'ui.findings.flags' | translate }}</th>
|
||||
<th
|
||||
class="col-severity sortable"
|
||||
(click)="setSort('severity')"
|
||||
[attr.aria-sort]="sortField() === 'severity' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
Severity <span [innerHTML]="getSortIcon('severity')"></span>
|
||||
{{ 'ui.labels.severity' | translate }} <span [innerHTML]="getSortIcon('severity')"></span>
|
||||
</th>
|
||||
<th class="col-status">Status</th>
|
||||
<th class="col-why">Why</th>
|
||||
<th class="col-status">{{ 'ui.labels.status' | translate }}</th>
|
||||
<th class="col-why">{{ 'ui.findings.why' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -227,9 +227,9 @@
|
||||
<tr class="empty-row">
|
||||
<td colspan="9">
|
||||
@if (scoredFindings().length === 0) {
|
||||
No findings to display.
|
||||
{{ 'ui.findings.no_findings' | translate }}
|
||||
} @else {
|
||||
No findings match the current filters.
|
||||
{{ 'ui.findings.no_match' | translate }}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ExportAuditPackButtonComponent } from '../../shared/components/audit-pack';
|
||||
import { VexTrustChipComponent, VexTrustPopoverComponent, TrustChipPopoverEvent } from '../../shared/components';
|
||||
import { ReasonCapsuleComponent } from '../triage/components/reason-capsule/reason-capsule.component';
|
||||
import { TranslatePipe } from '../../core/i18n';
|
||||
|
||||
/**
|
||||
* Finding model for display in the list.
|
||||
@@ -109,7 +110,8 @@ export interface FindingsFilter {
|
||||
ExportAuditPackButtonComponent,
|
||||
VexTrustChipComponent,
|
||||
VexTrustPopoverComponent,
|
||||
ReasonCapsuleComponent
|
||||
ReasonCapsuleComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './findings-list.component.html',
|
||||
styleUrls: ['./findings-list.component.scss'],
|
||||
|
||||
@@ -127,4 +127,11 @@ export const OPERATIONS_ROUTES: Routes = [
|
||||
(m) => m.ConsoleStatusComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'search-quality',
|
||||
loadComponent: () =>
|
||||
import('./search-quality/search-quality-dashboard.component').then(
|
||||
(m) => m.SearchQualityDashboardComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
// Sprint: SPRINT_20260224_110 (G10-003) - Search Quality Dashboard
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
|
||||
import type {
|
||||
SearchQualityAlert,
|
||||
SearchQualityMetrics,
|
||||
} from '../../../core/api/unified-search.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-quality-dashboard',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="sqd">
|
||||
<div class="sqd__header">
|
||||
<h1 class="sqd__title">Search Quality Dashboard</h1>
|
||||
<div class="sqd__period-selector">
|
||||
@for (p of periods; track p.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="sqd__period-btn"
|
||||
[class.sqd__period-btn--active]="selectedPeriod() === p.value"
|
||||
(click)="loadMetrics(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary metrics cards -->
|
||||
<div class="sqd__metrics">
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.totalSearches ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Total Searches</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--warn]="(metrics()?.zeroResultRate ?? 0) > 10">
|
||||
{{ metrics()?.zeroResultRate ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Zero-Result Rate</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.avgResultCount ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Avg Results / Query</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--good]="(metrics()?.feedbackScore ?? 0) > 70">
|
||||
{{ metrics()?.feedbackScore ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Feedback Score (Helpful)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zero-result alerts table -->
|
||||
<div class="sqd__section">
|
||||
<h2 class="sqd__section-title">Zero-Result & Low-Quality Alerts</h2>
|
||||
@if (isLoadingAlerts()) {
|
||||
<div class="sqd__loading">Loading alerts...</div>
|
||||
} @else if (alerts().length === 0) {
|
||||
<div class="sqd__empty">No open alerts. Search quality looks good.</div>
|
||||
} @else {
|
||||
<div class="sqd__table-wrapper">
|
||||
<table class="sqd__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Query</th>
|
||||
<th>Type</th>
|
||||
<th>Occurrences</th>
|
||||
<th>First Seen</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of alerts(); track alert.alertId) {
|
||||
<tr>
|
||||
<td class="sqd__query-cell">{{ alert.query }}</td>
|
||||
<td>
|
||||
<span class="sqd__alert-type" [attr.data-type]="alert.alertType">
|
||||
{{ formatAlertType(alert.alertType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="sqd__count-cell">{{ alert.occurrenceCount }}</td>
|
||||
<td>{{ formatDate(alert.firstSeen) }}</td>
|
||||
<td>{{ formatDate(alert.lastSeen) }}</td>
|
||||
<td>
|
||||
<span class="sqd__status" [attr.data-status]="alert.status">
|
||||
{{ alert.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alert.status === 'open') {
|
||||
<button
|
||||
type="button"
|
||||
class="sqd__action-btn"
|
||||
(click)="acknowledgeAlert(alert.alertId)"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sqd__action-btn sqd__action-btn--resolve"
|
||||
(click)="resolveAlert(alert.alertId)"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
} @else if (alert.status === 'acknowledged') {
|
||||
<button
|
||||
type="button"
|
||||
class="sqd__action-btn sqd__action-btn--resolve"
|
||||
(click)="resolveAlert(alert.alertId)"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
} @else {
|
||||
<span class="sqd__resolved-label">{{ alert.resolution ?? 'Resolved' }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.sqd {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sqd__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sqd__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sqd__period-selector {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sqd__period-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.sqd__period-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.sqd__period-btn--active {
|
||||
background: #1e3a8a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sqd__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sqd__metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.sqd__metric-card {
|
||||
padding: 1rem 1.25rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sqd__metric-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sqd__metric-value--warn {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sqd__metric-value--good {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sqd__metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.sqd__section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sqd__section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.sqd__loading,
|
||||
.sqd__empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sqd__table-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sqd__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sqd__table th {
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.sqd__table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.sqd__table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sqd__query-cell {
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sqd__count-cell {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sqd__alert-type {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.sqd__alert-type[data-type="zero_result"] {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.sqd__alert-type[data-type="high_negative_feedback"] {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.sqd__alert-type[data-type="low_feedback"] {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.sqd__status {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sqd__status[data-status="open"] {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.sqd__status[data-status="acknowledged"] {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.sqd__status[data-status="resolved"] {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.sqd__action-btn {
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border: 1px solid #9ca3af;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sqd__action-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.sqd__action-btn--resolve {
|
||||
border-color: #16a34a;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sqd__action-btn--resolve:hover {
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.sqd__resolved-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchQualityDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly searchClient = inject(UnifiedSearchClient);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
readonly periods = [
|
||||
{ label: '24h', value: '24h' },
|
||||
{ label: '7d', value: '7d' },
|
||||
{ label: '30d', value: '30d' },
|
||||
] as const;
|
||||
|
||||
readonly selectedPeriod = signal<string>('7d');
|
||||
readonly metrics = signal<SearchQualityMetrics | null>(null);
|
||||
readonly alerts = signal<SearchQualityAlert[]>([]);
|
||||
readonly isLoadingAlerts = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMetrics('7d');
|
||||
this.loadAlerts();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadMetrics(period: string): void {
|
||||
this.selectedPeriod.set(period);
|
||||
this.searchClient
|
||||
.getQualityMetrics(period)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((m) => this.metrics.set(m));
|
||||
}
|
||||
|
||||
loadAlerts(): void {
|
||||
this.isLoadingAlerts.set(true);
|
||||
this.searchClient
|
||||
.getQualityAlerts()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((a) => {
|
||||
this.alerts.set(a);
|
||||
this.isLoadingAlerts.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
acknowledgeAlert(alertId: string): void {
|
||||
this.searchClient
|
||||
.updateQualityAlert(alertId, { status: 'acknowledged' })
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.loadAlerts());
|
||||
}
|
||||
|
||||
resolveAlert(alertId: string): void {
|
||||
this.searchClient
|
||||
.updateQualityAlert(alertId, { status: 'resolved', resolution: 'Manually resolved by operator' })
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.loadAlerts());
|
||||
}
|
||||
|
||||
formatAlertType(type: string): string {
|
||||
switch (type) {
|
||||
case 'zero_result': return 'Zero Results';
|
||||
case 'low_feedback': return 'Low Feedback';
|
||||
case 'high_negative_feedback': return 'High Negative';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
<div class="release-dashboard">
|
||||
<header class="release-dashboard__header">
|
||||
<div class="release-dashboard__title-section">
|
||||
<h1 class="release-dashboard__title">Release Orchestrator</h1>
|
||||
<p class="release-dashboard__subtitle">Pipeline overview and release management</p>
|
||||
<h1 class="release-dashboard__title">{{ 'ui.release_orchestrator.title' | translate }}</h1>
|
||||
<p class="release-dashboard__subtitle">{{ 'ui.release_orchestrator.subtitle' | translate }}</p>
|
||||
</div>
|
||||
<div class="release-dashboard__actions">
|
||||
<a routerLink="/release-orchestrator/runs" class="release-dashboard__runs-link">Pipeline Runs</a>
|
||||
<a routerLink="/release-orchestrator/runs" class="release-dashboard__runs-link">{{ 'ui.release_orchestrator.pipeline_runs' | translate }}</a>
|
||||
@if (store.lastUpdated(); as lastUpdated) {
|
||||
<span class="release-dashboard__last-updated">
|
||||
Last updated: {{ lastUpdated | date:'medium' }}
|
||||
{{ 'ui.labels.last_updated' | translate }} {{ lastUpdated | date:'medium' }}
|
||||
</span>
|
||||
}
|
||||
<button
|
||||
class="release-dashboard__refresh-btn"
|
||||
(click)="onRefresh()"
|
||||
[disabled]="store.loading()"
|
||||
title="Refresh dashboard">
|
||||
[title]="'ui.release_orchestrator.refresh_dashboard' | translate">
|
||||
<span class="refresh-icon" [class.spinning]="store.loading()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="release-dashboard__error">
|
||||
<span class="error-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
<button class="error-dismiss" (click)="store.clearError()">Dismiss</button>
|
||||
<button class="error-dismiss" (click)="store.clearError()">{{ 'ui.actions.dismiss' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PipelineOverviewComponent } from './components/pipeline-overview/pipeli
|
||||
import { PendingApprovalsComponent } from './components/pending-approvals/pending-approvals.component';
|
||||
import { ActiveDeploymentsComponent } from './components/active-deployments/active-deployments.component';
|
||||
import { RecentReleasesComponent } from './components/recent-releases/recent-releases.component';
|
||||
import { TranslatePipe } from '../../../core/i18n';
|
||||
|
||||
/**
|
||||
* Release Orchestrator Dashboard
|
||||
@@ -23,6 +24,7 @@ import { RecentReleasesComponent } from './components/recent-releases/recent-rel
|
||||
PendingApprovalsComponent,
|
||||
ActiveDeploymentsComponent,
|
||||
RecentReleasesComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrl: './dashboard.component.scss',
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<section class="risk-dashboard">
|
||||
<header class="risk-dashboard__header">
|
||||
<div>
|
||||
<p class="eyebrow">Gateway · Risk</p>
|
||||
<h1>Risk Profiles</h1>
|
||||
<p class="sub">Tenant-scoped risk posture with deterministic ordering.</p>
|
||||
<p class="eyebrow">{{ 'ui.risk_dashboard.eyebrow' | translate }}</p>
|
||||
<h1>{{ 'ui.risk_dashboard.title' | translate }}</h1>
|
||||
<p class="sub">{{ 'ui.risk_dashboard.subtitle' | translate }}</p>
|
||||
</div>
|
||||
@if (loading()) {
|
||||
<div class="status">Loading…</div>
|
||||
<div class="status">{{ 'ui.loading.skeleton' | translate }}</div>
|
||||
} @else {
|
||||
@if (!error()) {
|
||||
<div class="status status--ok">Up to date</div>
|
||||
<div class="status status--ok">{{ 'ui.risk_dashboard.up_to_date' | translate }}</div>
|
||||
} @else {
|
||||
<div class="status status--error" role="alert">{{ error() }}</div>
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
}
|
||||
<div class="stat stat--meta">
|
||||
<div class="stat__label">Last Computation</div>
|
||||
<div class="stat__label">{{ 'ui.risk_dashboard.last_computation' | translate }}</div>
|
||||
<div class="stat__value">{{ s.lastComputation }}</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -33,19 +33,19 @@
|
||||
|
||||
<section class="risk-dashboard__filters">
|
||||
<label>
|
||||
Severity
|
||||
{{ 'ui.labels.severity' | translate }}
|
||||
<select [ngModel]="selectedSeverity()" (ngModelChange)="selectedSeverity.set($event); applyFilters()">
|
||||
<option value="">All</option>
|
||||
<option value="">{{ 'ui.labels.all' | translate }}</option>
|
||||
@for (sev of severities; track sev) {
|
||||
<option [value]="sev">{{ sev | titlecase }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Search
|
||||
<input type="search" [ngModel]="search()" (ngModelChange)="search.set($event); applyFilters()" placeholder="Title contains" />
|
||||
{{ 'ui.actions.search' | translate }}
|
||||
<input type="search" [ngModel]="search()" (ngModelChange)="search.set($event); applyFilters()" [placeholder]="'ui.risk_dashboard.search_placeholder' | translate" />
|
||||
</label>
|
||||
<button type="button" (click)="applyFilters()">Refresh</button>
|
||||
<button type="button" (click)="applyFilters()">{{ 'ui.actions.refresh' | translate }}</button>
|
||||
</section>
|
||||
|
||||
@if (list(); as page) {
|
||||
@@ -53,12 +53,12 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Score</th>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Evaluated</th>
|
||||
<th>Details</th>
|
||||
<th>{{ 'ui.labels.severity' | translate }}</th>
|
||||
<th>{{ 'ui.labels.score' | translate }}</th>
|
||||
<th>{{ 'ui.labels.title' | translate }}</th>
|
||||
<th>{{ 'ui.labels.description' | translate }}</th>
|
||||
<th>{{ 'ui.risk_dashboard.evaluated' | translate }}</th>
|
||||
<th>{{ 'ui.labels.details' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -70,21 +70,21 @@
|
||||
<td>{{ risk.description }}</td>
|
||||
<td>{{ risk.lastEvaluatedAt }}</td>
|
||||
<td>
|
||||
<a [routerLink]="['/vulnerabilities', risk.id]" class="link">View</a>
|
||||
<a [routerLink]="['/vulnerabilities', risk.id]" class="link">{{ 'ui.actions.view' | translate }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="meta">Showing {{ page.items.length }} of {{ page.total }} risks.</p>
|
||||
<p class="meta">{{ 'ui.labels.showing' | translate }} {{ page.items.length }} {{ 'ui.labels.of' | translate }} {{ page.total }} {{ 'ui.risk_dashboard.risks_suffix' | translate }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
@if (error()) {
|
||||
<div class="empty empty--error" role="alert">Unable to load risk profiles. {{ error() }}</div>
|
||||
<div class="empty empty--error" role="alert">{{ 'ui.risk_dashboard.error_unable_to_load' | translate }} {{ error() }}</div>
|
||||
} @else if (!loading()) {
|
||||
<div class="empty">No risks found for current filters.</div>
|
||||
<div class="empty">{{ 'ui.risk_dashboard.no_risks_found' | translate }}</div>
|
||||
} @else {
|
||||
<div class="empty">Loading risks…</div>
|
||||
<div class="empty">{{ 'ui.risk_dashboard.loading_risks' | translate }}</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { RouterLink } from '@angular/router';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { RiskStore } from '../../core/api/risk.store';
|
||||
import { RiskProfile, RiskSeverity } from '../../core/api/risk.models';
|
||||
import { TranslatePipe } from '../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'st-risk-dashboard',
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe],
|
||||
templateUrl: './risk-dashboard.component.html',
|
||||
styleUrl: './risk-dashboard.component.scss'
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<header class="first-signal-card__header">
|
||||
<div class="first-signal-card__title">
|
||||
<span class="first-signal-card__label">{{ 'firstSignal.label' | translate }}</span>
|
||||
<span class="first-signal-card__label">{{ 'ui.first_signal.label' | translate }}</span>
|
||||
<span class="badge" [class]="badgeClass()">{{ badgeText() }}</span>
|
||||
</div>
|
||||
<div class="first-signal-card__meta">
|
||||
@if (realtimeMode() === 'sse') {
|
||||
<span class="realtime-indicator realtime-indicator--live">{{ 'firstSignal.live' | translate }}</span>
|
||||
<span class="realtime-indicator realtime-indicator--live">{{ 'ui.first_signal.live' | translate }}</span>
|
||||
} @else if (realtimeMode() === 'polling') {
|
||||
<span class="realtime-indicator realtime-indicator--polling">{{ 'firstSignal.polling' | translate }}</span>
|
||||
<span class="realtime-indicator realtime-indicator--polling">{{ 'ui.first_signal.polling' | translate }}</span>
|
||||
}
|
||||
@if (stageText(); as stage) {
|
||||
<span class="first-signal-card__stage">{{ stage }}</span>
|
||||
}
|
||||
<span class="first-signal-card__run-id">{{ 'firstSignal.runPrefix' | translate }} {{ runId() }}</span>
|
||||
<span class="first-signal-card__run-id">{{ 'ui.first_signal.run_prefix' | translate }} {{ runId() }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="first-signal-card__artifact-kind">{{ sig.artifact.kind }}</span>
|
||||
@if (sig.artifact.range) {
|
||||
<span class="first-signal-card__artifact-range">
|
||||
{{ 'firstSignal.rangePrefix' | translate }} {{ sig.artifact.range.start }}{{ 'firstSignal.rangeSeparator' | translate }}{{ sig.artifact.range.end }}
|
||||
{{ 'ui.first_signal.range_prefix' | translate }} {{ sig.artifact.range.start }}{{ 'ui.first_signal.range_separator' | translate }}{{ sig.artifact.range.end }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
} @else if (response()) {
|
||||
<div class="first-signal-card__empty" role="status">
|
||||
<p>{{ 'firstSignal.waiting' | translate }}</p>
|
||||
<p>{{ 'ui.first_signal.waiting' | translate }}</p>
|
||||
</div>
|
||||
} @else if (state() === 'loading' && showSkeleton()) {
|
||||
<div class="first-signal-card__skeleton" aria-hidden="true">
|
||||
@@ -47,17 +47,17 @@
|
||||
</div>
|
||||
} @else if (state() === 'unavailable') {
|
||||
<div class="first-signal-card__empty" role="status">
|
||||
<p>{{ 'firstSignal.notAvailable' | translate }}</p>
|
||||
<p>{{ 'ui.first_signal.not_available' | translate }}</p>
|
||||
</div>
|
||||
} @else if (state() === 'offline') {
|
||||
<div class="first-signal-card__error" role="alert">
|
||||
<p>{{ 'firstSignal.offline' | translate }}</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">{{ 'firstSignal.retry' | translate }}</button>
|
||||
<p>{{ 'ui.first_signal.offline' | translate }}</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">{{ 'ui.first_signal.retry' | translate }}</button>
|
||||
</div>
|
||||
} @else if (state() === 'error') {
|
||||
<div class="first-signal-card__error" role="alert">
|
||||
<p>{{ error() ?? ('firstSignal.failed' | translate) }}</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">{{ 'firstSignal.tryAgain' | translate }}</button>
|
||||
<p>{{ error() ?? ('ui.first_signal.failed' | translate) }}</p>
|
||||
<button type="button" class="retry-button" (click)="retry()">{{ 'ui.first_signal.try_again' | translate }}</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export class FirstSignalCardComponent implements OnDestroy {
|
||||
readonly realtimeMode = this.store.realtimeMode;
|
||||
readonly showSkeleton = this.showSkeletonSignal.asReadonly();
|
||||
|
||||
readonly cardAriaLabel = computed(() => this.i18n.t('firstSignal.aria.cardLabel'));
|
||||
readonly cardAriaLabel = computed(() => this.i18n.t('ui.first_signal.aria.card_label'));
|
||||
|
||||
readonly badgeText = computed(() => this.formatBadgeText(this.signal()?.type));
|
||||
readonly badgeClass = computed(() => this.formatBadgeClass(this.signal()?.type));
|
||||
@@ -169,15 +169,15 @@ export class FirstSignalCardComponent implements OnDestroy {
|
||||
private formatBadgeText(type: string | null | undefined): string {
|
||||
const normalized = (type ?? '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return this.i18n.t('firstSignal.kind.unknown');
|
||||
return this.i18n.t('ui.first_signal.kind.unknown');
|
||||
}
|
||||
|
||||
return this.i18n.tryT(`firstSignal.kind.${normalized}`)
|
||||
return this.i18n.tryT(`ui.first_signal.kind.${normalized}`)
|
||||
?? normalized
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
private formatBadgeClass(type: string | null | undefined): string {
|
||||
@@ -198,8 +198,11 @@ export class FirstSignalCardComponent implements OnDestroy {
|
||||
const step = (signal.step ?? '').trim();
|
||||
if (!stage && !step) return null;
|
||||
|
||||
const stageLabel = stage ? this.i18n.tryT(`firstSignal.stage.${stage.toLowerCase()}`) ?? stage : '';
|
||||
const separator = this.i18n.t('firstSignal.stageSeparator');
|
||||
const stageLabel = stage
|
||||
? this.i18n.tryT(`ui.first_signal.stage.${stage.toLowerCase()}`)
|
||||
?? stage
|
||||
: '';
|
||||
const separator = this.i18n.t('ui.first_signal.stage_separator');
|
||||
|
||||
if (stageLabel && step) return `${stageLabel}${separator}${step}`;
|
||||
return stageLabel || step;
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* Add Identity Provider Wizard
|
||||
* Multi-step wizard for adding a new identity provider configuration.
|
||||
* Steps: 1) Select type 2) Configure 3) Test connection 4) Save
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
EventEmitter,
|
||||
Output,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import {
|
||||
IDENTITY_PROVIDER_API,
|
||||
IdentityProviderApi,
|
||||
IdentityProviderConfigDto,
|
||||
IdentityProviderTypeSchema,
|
||||
IdentityProviderFieldSchema,
|
||||
TestConnectionResult,
|
||||
} from '../../../core/api/identity-provider.client';
|
||||
|
||||
type WizardStep = 'select-type' | 'configure' | 'test' | 'save';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-provider-wizard',
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="wizard-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="wizard-panel">
|
||||
<!-- Header -->
|
||||
<header class="wizard-header">
|
||||
<h3 class="wizard-title">Add Identity Provider</h3>
|
||||
<button type="button" class="wizard-close" (click)="cancelled.emit()" aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="wizard-steps">
|
||||
@for (step of steps; track step.id; let i = $index) {
|
||||
<div class="wizard-step-indicator" [class.wizard-step-indicator--active]="currentStep() === step.id" [class.wizard-step-indicator--done]="stepIndex(currentStep()) > i">
|
||||
<span class="wizard-step-number">{{ i + 1 }}</span>
|
||||
<span class="wizard-step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Step: Select Type -->
|
||||
@if (currentStep() === 'select-type') {
|
||||
<div class="wizard-body">
|
||||
<p class="wizard-instruction">Choose the type of identity provider to configure.</p>
|
||||
<div class="type-selector-grid">
|
||||
@for (t of availableTypes(); track t.type) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-card"
|
||||
[class.type-card--selected]="selectedType() === t.type"
|
||||
(click)="selectType(t.type)"
|
||||
>
|
||||
<span class="type-card__icon" aria-hidden="true">
|
||||
@switch (t.type) {
|
||||
@case ('standard') { <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg> }
|
||||
@case ('ldap') { <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg> }
|
||||
@case ('saml') { <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> }
|
||||
@case ('oidc') { <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20"/><path d="M2 12h20"/></svg> }
|
||||
@default { <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/></svg> }
|
||||
}
|
||||
</span>
|
||||
<span class="type-card__name">{{ t.displayName }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="wizard-footer">
|
||||
<button type="button" class="btn btn--secondary" (click)="cancelled.emit()">Cancel</button>
|
||||
<button type="button" class="btn btn--primary" [disabled]="!selectedType()" (click)="goToStep('configure')">Next</button>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- Step: Configure -->
|
||||
@if (currentStep() === 'configure') {
|
||||
<div class="wizard-body">
|
||||
<div class="config-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="idp-name">Provider Name</label>
|
||||
<input
|
||||
id="idp-name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
[ngModel]="providerName()"
|
||||
(ngModelChange)="providerName.set($event)"
|
||||
placeholder="e.g. Corporate LDAP"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="idp-description">Description (optional)</label>
|
||||
<input
|
||||
id="idp-description"
|
||||
type="text"
|
||||
class="form-input"
|
||||
[ngModel]="providerDescription()"
|
||||
(ngModelChange)="providerDescription.set($event)"
|
||||
placeholder="Short description of this provider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (selectedSchema()) {
|
||||
<h4 class="form-section-title">Required Fields</h4>
|
||||
@for (field of selectedSchema()!.requiredFields; track field.name) {
|
||||
<div class="form-group">
|
||||
<label class="form-label" [for]="'idp-field-' + field.name">
|
||||
{{ field.displayName }}
|
||||
<span class="form-required">*</span>
|
||||
</label>
|
||||
@if (field.fieldType === 'password') {
|
||||
<input
|
||||
[id]="'idp-field-' + field.name"
|
||||
type="password"
|
||||
class="form-input"
|
||||
[ngModel]="getConfigValue(field.name)"
|
||||
(ngModelChange)="setConfigValue(field.name, $event)"
|
||||
[placeholder]="field.description || ''"
|
||||
/>
|
||||
} @else if (field.fieldType === 'boolean') {
|
||||
<label class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="getConfigValue(field.name) === 'true'"
|
||||
(ngModelChange)="setConfigValue(field.name, $event ? 'true' : 'false')"
|
||||
/>
|
||||
{{ field.description || field.displayName }}
|
||||
</label>
|
||||
} @else {
|
||||
<input
|
||||
[id]="'idp-field-' + field.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
[ngModel]="getConfigValue(field.name)"
|
||||
(ngModelChange)="setConfigValue(field.name, $event)"
|
||||
[placeholder]="field.description || ''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedSchema()!.optionalFields.length > 0) {
|
||||
<h4 class="form-section-title">Optional Fields</h4>
|
||||
@for (field of selectedSchema()!.optionalFields; track field.name) {
|
||||
<div class="form-group">
|
||||
<label class="form-label" [for]="'idp-field-' + field.name">{{ field.displayName }}</label>
|
||||
@if (field.fieldType === 'password') {
|
||||
<input
|
||||
[id]="'idp-field-' + field.name"
|
||||
type="password"
|
||||
class="form-input"
|
||||
[ngModel]="getConfigValue(field.name)"
|
||||
(ngModelChange)="setConfigValue(field.name, $event)"
|
||||
[placeholder]="field.description || ''"
|
||||
/>
|
||||
} @else if (field.fieldType === 'boolean') {
|
||||
<label class="form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="getConfigValue(field.name) === 'true'"
|
||||
(ngModelChange)="setConfigValue(field.name, $event ? 'true' : 'false')"
|
||||
/>
|
||||
{{ field.description || field.displayName }}
|
||||
</label>
|
||||
} @else {
|
||||
<input
|
||||
[id]="'idp-field-' + field.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
[ngModel]="getConfigValue(field.name)"
|
||||
(ngModelChange)="setConfigValue(field.name, $event)"
|
||||
[placeholder]="field.description || ''"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="wizard-footer">
|
||||
<button type="button" class="btn btn--secondary" (click)="goToStep('select-type')">Back</button>
|
||||
<button type="button" class="btn btn--primary" [disabled]="!isConfigValid()" (click)="goToStep('test')">Next</button>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- Step: Test Connection -->
|
||||
@if (currentStep() === 'test') {
|
||||
<div class="wizard-body">
|
||||
<p class="wizard-instruction">Test the connection before saving. This step is optional but recommended.</p>
|
||||
|
||||
<div class="test-panel">
|
||||
@if (!testResult() && !testing()) {
|
||||
<p class="test-prompt">Click "Test Connection" to verify the configuration.</p>
|
||||
}
|
||||
@if (testing()) {
|
||||
<div class="test-loading">
|
||||
<span class="spinner"></span>
|
||||
Testing connection...
|
||||
</div>
|
||||
}
|
||||
@if (testResult()) {
|
||||
<div class="test-result" [class.test-result--success]="testResult()!.success" [class.test-result--failure]="!testResult()!.success">
|
||||
<span class="test-result__icon">{{ testResult()!.success ? '\u2713' : '\u2717' }}</span>
|
||||
<div class="test-result__body">
|
||||
<span class="test-result__message">{{ testResult()!.message }}</span>
|
||||
@if (testResult()!.latencyMs !== null) {
|
||||
<span class="test-result__latency">Latency: {{ testResult()!.latencyMs }}ms</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="wizard-footer">
|
||||
<button type="button" class="btn btn--secondary" (click)="goToStep('configure')">Back</button>
|
||||
<button type="button" class="btn btn--outline" [disabled]="testing()" (click)="runTest()">Test Connection</button>
|
||||
<button type="button" class="btn btn--primary" (click)="saveProvider()">{{ saving() ? 'Saving...' : 'Save Provider' }}</button>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- Step: Save (confirmation) -->
|
||||
@if (currentStep() === 'save') {
|
||||
<div class="wizard-body">
|
||||
@if (saving()) {
|
||||
<div class="test-loading">
|
||||
<span class="spinner"></span>
|
||||
Saving identity provider...
|
||||
</div>
|
||||
}
|
||||
@if (saveError()) {
|
||||
<div class="test-result test-result--failure">
|
||||
<span class="test-result__icon">\u2717</span>
|
||||
<span class="test-result__message">{{ saveError() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.wizard-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.wizard-panel {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 680px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wizard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.wizard-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.wizard-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wizard-close:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.wizard-step-indicator--active {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.wizard-step-indicator--done {
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.wizard-step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.wizard-step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.wizard-step-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wizard-instruction {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.type-selector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.25rem 0.75rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 2px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-card:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.type-card--selected {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-severity-info-bg, var(--color-surface-secondary));
|
||||
}
|
||||
|
||||
.type-card__icon {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.type-card--selected .type-card__icon {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.type-card__name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-required {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--color-brand-primary-rgb, 99, 102, 241), 0.2);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
border: none;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.btn--outline:hover:not(:disabled) {
|
||||
background: var(--color-severity-info-bg, rgba(99, 102, 241, 0.08));
|
||||
}
|
||||
|
||||
.test-panel {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.test-prompt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.test-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.test-result--success {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.test-result--failure {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.test-result__icon {
|
||||
font-size: 1.25rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.test-result__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.test-result__message {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.test-result__latency {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AddProviderWizardComponent {
|
||||
@Output() saved = new EventEmitter<IdentityProviderConfigDto>();
|
||||
@Output() cancelled = new EventEmitter<void>();
|
||||
|
||||
private readonly api = inject(IDENTITY_PROVIDER_API);
|
||||
|
||||
readonly steps = [
|
||||
{ id: 'select-type' as WizardStep, label: 'Type' },
|
||||
{ id: 'configure' as WizardStep, label: 'Configure' },
|
||||
{ id: 'test' as WizardStep, label: 'Test' },
|
||||
{ id: 'save' as WizardStep, label: 'Save' },
|
||||
];
|
||||
|
||||
readonly currentStep = signal<WizardStep>('select-type');
|
||||
readonly selectedType = signal<string | null>(null);
|
||||
readonly providerName = signal('');
|
||||
readonly providerDescription = signal('');
|
||||
readonly configValues = signal<Record<string, string | null>>({});
|
||||
readonly testing = signal(false);
|
||||
readonly testResult = signal<TestConnectionResult | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly saveError = signal<string | null>(null);
|
||||
readonly availableTypes = signal<IdentityProviderTypeSchema[]>([]);
|
||||
|
||||
readonly selectedSchema = computed(() => {
|
||||
const type = this.selectedType();
|
||||
if (!type) return null;
|
||||
return this.availableTypes().find(t => t.type === type) ?? null;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.api.getTypes().subscribe({
|
||||
next: (types) => this.availableTypes.set(types),
|
||||
});
|
||||
}
|
||||
|
||||
stepIndex(step: WizardStep): number {
|
||||
return this.steps.findIndex(s => s.id === step);
|
||||
}
|
||||
|
||||
selectType(type: string): void {
|
||||
this.selectedType.set(type);
|
||||
|
||||
// Pre-fill default values from schema
|
||||
const schema = this.availableTypes().find(t => t.type === type);
|
||||
if (schema) {
|
||||
const defaults: Record<string, string | null> = {};
|
||||
for (const field of [...schema.requiredFields, ...schema.optionalFields]) {
|
||||
if (field.defaultValue !== null) {
|
||||
defaults[field.name] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
this.configValues.set(defaults);
|
||||
}
|
||||
}
|
||||
|
||||
goToStep(step: WizardStep): void {
|
||||
this.currentStep.set(step);
|
||||
}
|
||||
|
||||
getConfigValue(name: string): string {
|
||||
return this.configValues()[name] ?? '';
|
||||
}
|
||||
|
||||
setConfigValue(name: string, value: string): void {
|
||||
this.configValues.update(v => ({ ...v, [name]: value || null }));
|
||||
}
|
||||
|
||||
isConfigValid(): boolean {
|
||||
if (!this.providerName().trim()) return false;
|
||||
const schema = this.selectedSchema();
|
||||
if (!schema) return false;
|
||||
for (const field of schema.requiredFields) {
|
||||
const val = this.configValues()[field.name];
|
||||
if (!val || !val.trim()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
runTest(): void {
|
||||
this.testing.set(true);
|
||||
this.testResult.set(null);
|
||||
|
||||
this.api.testConnection({
|
||||
type: this.selectedType()!,
|
||||
configuration: { ...this.configValues() },
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.testResult.set(result);
|
||||
this.testing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.testResult.set({
|
||||
success: false,
|
||||
message: 'Failed to test connection. Check configuration and try again.',
|
||||
latencyMs: null,
|
||||
});
|
||||
this.testing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
saveProvider(): void {
|
||||
this.saving.set(true);
|
||||
this.saveError.set(null);
|
||||
this.currentStep.set('save');
|
||||
|
||||
this.api.create({
|
||||
name: this.providerName().trim(),
|
||||
type: this.selectedType()!,
|
||||
enabled: true,
|
||||
configuration: { ...this.configValues() },
|
||||
description: this.providerDescription().trim() || undefined,
|
||||
}).subscribe({
|
||||
next: (provider) => {
|
||||
this.saving.set(false);
|
||||
this.saved.emit(provider);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.saveError.set(err?.message ?? 'Failed to save provider');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onOverlayClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('wizard-overlay')) {
|
||||
this.cancelled.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import {
|
||||
IDENTITY_PROVIDER_API,
|
||||
IdentityProviderApi,
|
||||
IdentityProviderConfigDto,
|
||||
IdentityProviderTypeSchema,
|
||||
} from '../../../core/api/identity-provider.client';
|
||||
import { IdentityProvidersSettingsPageComponent } from './identity-providers-settings-page.component';
|
||||
|
||||
const MOCK_PROVIDERS: IdentityProviderConfigDto[] = [
|
||||
{
|
||||
id: 'idp-ldap-1',
|
||||
name: 'Corporate LDAP',
|
||||
type: 'ldap',
|
||||
enabled: true,
|
||||
configuration: { server: 'ldaps://ldap.example.com' },
|
||||
description: 'Corp AD',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
updatedAt: '2026-02-20T08:30:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'idp-oidc-1',
|
||||
name: 'Okta SSO',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
configuration: { authority: 'https://dev-12345.okta.com' },
|
||||
description: 'Okta OIDC',
|
||||
healthStatus: 'healthy',
|
||||
createdAt: '2026-01-20T14:00:00Z',
|
||||
updatedAt: '2026-02-18T16:00:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'idp-saml-1',
|
||||
name: 'Azure AD SAML',
|
||||
type: 'saml',
|
||||
enabled: false,
|
||||
configuration: { spEntityId: 'https://stellaops.example.com' },
|
||||
description: 'Azure AD federation',
|
||||
healthStatus: 'degraded',
|
||||
createdAt: '2026-02-01T09:00:00Z',
|
||||
updatedAt: '2026-02-22T11:00:00Z',
|
||||
createdBy: 'admin',
|
||||
updatedBy: null,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_TYPES: IdentityProviderTypeSchema[] = [
|
||||
{
|
||||
type: 'standard',
|
||||
displayName: 'Standard Authentication',
|
||||
requiredFields: [],
|
||||
optionalFields: [],
|
||||
},
|
||||
{
|
||||
type: 'ldap',
|
||||
displayName: 'LDAP / Active Directory',
|
||||
requiredFields: [
|
||||
{ name: 'server', displayName: 'LDAP Server URL', fieldType: 'text', defaultValue: null, description: null },
|
||||
],
|
||||
optionalFields: [],
|
||||
},
|
||||
];
|
||||
|
||||
class MockIdentityProviderApi implements Partial<IdentityProviderApi> {
|
||||
readonly list = jasmine.createSpy('list').and.returnValue(of([]));
|
||||
readonly get = jasmine.createSpy('get').and.returnValue(of(MOCK_PROVIDERS[0]));
|
||||
readonly create = jasmine.createSpy('create').and.returnValue(of(MOCK_PROVIDERS[0]));
|
||||
readonly update = jasmine.createSpy('update').and.returnValue(of(MOCK_PROVIDERS[0]));
|
||||
readonly remove = jasmine.createSpy('remove').and.returnValue(of(undefined));
|
||||
readonly enable = jasmine.createSpy('enable').and.returnValue(of(MOCK_PROVIDERS[0]));
|
||||
readonly disable = jasmine.createSpy('disable').and.returnValue(of(MOCK_PROVIDERS[0]));
|
||||
readonly testConnection = jasmine.createSpy('testConnection').and.returnValue(of({ success: true, message: 'OK', latencyMs: 42 }));
|
||||
readonly getHealth = jasmine.createSpy('getHealth').and.returnValue(of({ success: true, message: 'Healthy', latencyMs: 38 }));
|
||||
readonly applyToAuthority = jasmine.createSpy('applyToAuthority').and.returnValue(of(undefined));
|
||||
readonly getTypes = jasmine.createSpy('getTypes').and.returnValue(of(MOCK_TYPES));
|
||||
}
|
||||
|
||||
describe('IdentityProvidersSettingsPageComponent', () => {
|
||||
let fixture: ComponentFixture<IdentityProvidersSettingsPageComponent>;
|
||||
let component: IdentityProvidersSettingsPageComponent;
|
||||
let api: MockIdentityProviderApi;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = new MockIdentityProviderApi();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IdentityProvidersSettingsPageComponent],
|
||||
providers: [
|
||||
{ provide: IDENTITY_PROVIDER_API, useValue: api },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IdentityProvidersSettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should render empty state when no providers are returned', () => {
|
||||
api.list.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyState = fixture.nativeElement.querySelector('.empty-state');
|
||||
expect(emptyState).toBeTruthy();
|
||||
expect(emptyState.textContent).toContain('No identity providers configured');
|
||||
});
|
||||
|
||||
it('should render provider cards from mocked API', () => {
|
||||
api.list.and.returnValue(of(MOCK_PROVIDERS));
|
||||
fixture.detectChanges();
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.provider-card');
|
||||
expect(cards.length).toBe(3);
|
||||
|
||||
const firstCardName = cards[0].querySelector('.provider-card__name');
|
||||
expect(firstCardName.textContent.trim()).toBe('Corporate LDAP');
|
||||
|
||||
const secondCardName = cards[1].querySelector('.provider-card__name');
|
||||
expect(secondCardName.textContent.trim()).toBe('Okta SSO');
|
||||
|
||||
const thirdCardName = cards[2].querySelector('.provider-card__name');
|
||||
expect(thirdCardName.textContent.trim()).toBe('Azure AD SAML');
|
||||
});
|
||||
|
||||
it('should display correct KPI counts', () => {
|
||||
api.list.and.returnValue(of(MOCK_PROVIDERS));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.totalCount()).toBe(3);
|
||||
expect(component.enabledCount()).toBe(2);
|
||||
expect(component.healthyCount()).toBe(2);
|
||||
expect(component.degradedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should show disabled styling on disabled providers', () => {
|
||||
api.list.and.returnValue(of(MOCK_PROVIDERS));
|
||||
fixture.detectChanges();
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.provider-card');
|
||||
const disabledCards = fixture.nativeElement.querySelectorAll('.provider-card--disabled');
|
||||
expect(cards.length).toBe(3);
|
||||
expect(disabledCards.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should open add wizard on button click', () => {
|
||||
api.list.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showAddWizard()).toBe(false);
|
||||
|
||||
const addButton = fixture.nativeElement.querySelector('.page-header .btn--primary');
|
||||
addButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showAddWizard()).toBe(true);
|
||||
|
||||
const wizard = fixture.nativeElement.querySelector('app-add-provider-wizard');
|
||||
expect(wizard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should add provider to list on wizard saved event', () => {
|
||||
api.list.and.returnValue(of([]));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.providers().length).toBe(0);
|
||||
|
||||
const newProvider: IdentityProviderConfigDto = {
|
||||
id: 'idp-new',
|
||||
name: 'New Provider',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
description: null,
|
||||
healthStatus: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'admin',
|
||||
updatedBy: null,
|
||||
};
|
||||
|
||||
component.onProviderSaved(newProvider);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.providers().length).toBe(1);
|
||||
expect(component.providers()[0].name).toBe('New Provider');
|
||||
expect(component.showAddWizard()).toBe(false);
|
||||
});
|
||||
|
||||
it('should display type badge in uppercase', () => {
|
||||
api.list.and.returnValue(of([MOCK_PROVIDERS[0]]));
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('.provider-card__type-badge');
|
||||
expect(badge.textContent.trim()).toBe('LDAP');
|
||||
});
|
||||
|
||||
it('should call list on construction', () => {
|
||||
fixture.detectChanges();
|
||||
expect(api.list).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Identity Providers Settings Page
|
||||
* Displays configured identity providers with KPI strip, card grid, and actions.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import {
|
||||
IDENTITY_PROVIDER_API,
|
||||
IdentityProviderApi,
|
||||
IdentityProviderConfigDto,
|
||||
} from '../../../core/api/identity-provider.client';
|
||||
import { AddProviderWizardComponent } from './add-provider-wizard.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity-providers-settings-page',
|
||||
imports: [CommonModule, AddProviderWizardComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="idp-settings-page">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Identity Providers</h1>
|
||||
<p class="page-subtitle">Configure external authentication sources</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddWizard.set(true)">
|
||||
+ Add Provider
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
@if (loading()) {
|
||||
<div class="loading-indicator">
|
||||
<span class="spinner"></span>
|
||||
Loading providers...
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error state -->
|
||||
@if (loadError()) {
|
||||
<div class="error-banner">
|
||||
<span>Failed to load providers: {{ loadError() }}</span>
|
||||
<button type="button" class="btn btn--secondary" (click)="loadProviders()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KPI strip -->
|
||||
@if (!loading() && !loadError()) {
|
||||
<div class="kpi-strip">
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-value">{{ totalCount() }}</span>
|
||||
<span class="kpi-label">Total</span>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-value">{{ enabledCount() }}</span>
|
||||
<span class="kpi-label">Enabled</span>
|
||||
</div>
|
||||
<div class="kpi-card kpi-card--success">
|
||||
<span class="kpi-value">{{ healthyCount() }}</span>
|
||||
<span class="kpi-label">Healthy</span>
|
||||
</div>
|
||||
<div class="kpi-card kpi-card--warning">
|
||||
<span class="kpi-value">{{ degradedCount() }}</span>
|
||||
<span class="kpi-label">Degraded</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty state -->
|
||||
@if (providers().length === 0 && !loading() && !loadError()) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 010 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-state__text">No identity providers configured.</p>
|
||||
<p class="empty-state__hint">Add a provider to enable external authentication (LDAP, SAML, OIDC).</p>
|
||||
<button type="button" class="btn btn--primary" (click)="showAddWizard.set(true)">
|
||||
Add your first provider
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Provider card grid -->
|
||||
@if (providers().length > 0 && !loading()) {
|
||||
<div class="provider-grid">
|
||||
@for (provider of providers(); track provider.id) {
|
||||
<div class="provider-card" [class.provider-card--disabled]="!provider.enabled">
|
||||
<div class="provider-card__header">
|
||||
<span class="provider-card__type-badge">{{ provider.type | uppercase }}</span>
|
||||
<span class="provider-card__status" [class]="'provider-card__status--' + (provider.healthStatus || 'unknown')">
|
||||
{{ provider.healthStatus || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="provider-card__name">{{ provider.name }}</h3>
|
||||
<p class="provider-card__description">{{ provider.description || 'No description' }}</p>
|
||||
<div class="provider-card__meta">
|
||||
<span class="provider-card__meta-item">
|
||||
{{ provider.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
<span class="provider-card__meta-item">
|
||||
Updated: {{ provider.updatedAt | date:'short' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="provider-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
[class.btn--success]="!provider.enabled"
|
||||
[class.btn--warning]="provider.enabled"
|
||||
[disabled]="actionInProgress() === provider.id"
|
||||
(click)="toggleEnabled(provider)"
|
||||
>
|
||||
{{ provider.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--outline"
|
||||
[disabled]="actionInProgress() === provider.id"
|
||||
(click)="testProvider(provider)"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--outline"
|
||||
[disabled]="actionInProgress() === provider.id"
|
||||
(click)="applyProvider(provider)"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--outline"
|
||||
[disabled]="actionInProgress() === provider.id"
|
||||
(click)="editProvider(provider)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--danger"
|
||||
[disabled]="actionInProgress() === provider.id"
|
||||
(click)="deleteProvider(provider)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline test result -->
|
||||
@if (testResults()[provider.id]) {
|
||||
<div
|
||||
class="provider-card__test-result"
|
||||
[class.provider-card__test-result--success]="testResults()[provider.id]!.success"
|
||||
[class.provider-card__test-result--failure]="!testResults()[provider.id]!.success"
|
||||
>
|
||||
{{ testResults()[provider.id]!.success ? '\u2713' : '\u2717' }}
|
||||
{{ testResults()[provider.id]!.message }}
|
||||
@if (testResults()[provider.id]!.latencyMs !== null) {
|
||||
({{ testResults()[provider.id]!.latencyMs }}ms)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add Provider Wizard overlay -->
|
||||
@if (showAddWizard()) {
|
||||
<app-add-provider-wizard
|
||||
(saved)="onProviderSaved($event)"
|
||||
(cancelled)="showAddWizard.set(false)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.idp-settings-page {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-brand-primary);
|
||||
border: none;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--color-brand-secondary);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn--outline:hover:not(:disabled) {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.btn--success {
|
||||
background: var(--color-severity-low-bg);
|
||||
border: 1px solid var(--color-severity-low-border, var(--color-border-primary));
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.btn--warning {
|
||||
background: var(--color-severity-medium-bg);
|
||||
border: 1px solid var(--color-severity-medium-border, var(--color-border-primary));
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-severity-critical-border, var(--color-border-primary));
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--color-severity-critical-bg);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--color-severity-critical-bg);
|
||||
border: 1px solid var(--color-severity-critical-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kpi-card--success {
|
||||
border-color: var(--color-severity-low-border, var(--color-border-primary));
|
||||
background: var(--color-severity-low-bg);
|
||||
}
|
||||
|
||||
.kpi-card--warning {
|
||||
border-color: var(--color-severity-medium-border);
|
||||
background: var(--color-severity-medium-bg);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
font-size: 2rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-state__text {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state__hint {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
padding: 1.25rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.provider-card--disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.provider-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-card__type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.625rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.provider-card__status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.625rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.provider-card__status--healthy {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.provider-card__status--degraded {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.provider-card__status--unhealthy,
|
||||
.provider-card__status--error {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.provider-card__status--unknown {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.provider-card__name {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.provider-card__description {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.provider-card__meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-card__meta-item {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.provider-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-card__test-result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-card__test-result--success {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.provider-card__test-result--failure {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class IdentityProvidersSettingsPageComponent {
|
||||
private readonly api = inject(IDENTITY_PROVIDER_API);
|
||||
|
||||
readonly providers = signal<IdentityProviderConfigDto[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly showAddWizard = signal(false);
|
||||
readonly actionInProgress = signal<string | null>(null);
|
||||
readonly testResults = signal<Record<string, { success: boolean; message: string; latencyMs: number | null }>>({});
|
||||
|
||||
readonly totalCount = computed(() => this.providers().length);
|
||||
readonly enabledCount = computed(() => this.providers().filter(p => p.enabled).length);
|
||||
readonly healthyCount = computed(() => this.providers().filter(p => p.healthStatus === 'healthy').length);
|
||||
readonly degradedCount = computed(() => this.providers().filter(p => p.healthStatus === 'degraded' || p.healthStatus === 'unhealthy' || p.healthStatus === 'error').length);
|
||||
|
||||
constructor() {
|
||||
this.loadProviders();
|
||||
}
|
||||
|
||||
loadProviders(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
|
||||
this.api.list().subscribe({
|
||||
next: (list) => {
|
||||
this.providers.set(list);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.loadError.set(err?.message ?? 'Unknown error');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleEnabled(provider: IdentityProviderConfigDto): void {
|
||||
this.actionInProgress.set(provider.id);
|
||||
|
||||
const action$ = provider.enabled
|
||||
? this.api.disable(provider.id)
|
||||
: this.api.enable(provider.id);
|
||||
|
||||
action$.subscribe({
|
||||
next: (updated) => {
|
||||
this.providers.update(list =>
|
||||
list.map(p => p.id === updated.id ? updated : p)
|
||||
);
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
error: () => {
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
testProvider(provider: IdentityProviderConfigDto): void {
|
||||
this.actionInProgress.set(provider.id);
|
||||
|
||||
this.api.getHealth(provider.id).subscribe({
|
||||
next: (result) => {
|
||||
this.testResults.update(r => ({ ...r, [provider.id]: result }));
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
error: () => {
|
||||
this.testResults.update(r => ({
|
||||
...r,
|
||||
[provider.id]: { success: false, message: 'Health check failed', latencyMs: null },
|
||||
}));
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
applyProvider(provider: IdentityProviderConfigDto): void {
|
||||
this.actionInProgress.set(provider.id);
|
||||
|
||||
this.api.applyToAuthority(provider.id).subscribe({
|
||||
next: () => {
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
error: () => {
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
editProvider(provider: IdentityProviderConfigDto): void {
|
||||
// For now, re-open the wizard (future: pre-populate with existing config)
|
||||
this.showAddWizard.set(true);
|
||||
}
|
||||
|
||||
deleteProvider(provider: IdentityProviderConfigDto): void {
|
||||
this.actionInProgress.set(provider.id);
|
||||
|
||||
this.api.remove(provider.id).subscribe({
|
||||
next: () => {
|
||||
this.providers.update(list => list.filter(p => p.id !== provider.id));
|
||||
this.testResults.update(r => {
|
||||
const copy = { ...r };
|
||||
delete copy[provider.id];
|
||||
return copy;
|
||||
});
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
error: () => {
|
||||
this.actionInProgress.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onProviderSaved(provider: IdentityProviderConfigDto): void {
|
||||
this.showAddWizard.set(false);
|
||||
this.providers.update(list => [...list, provider]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { I18nService } from '../../../core/i18n';
|
||||
import { LocaleCatalogService } from '../../../core/i18n/locale-catalog.service';
|
||||
import { UserLocalePreferenceService } from '../../../core/i18n/user-locale-preference.service';
|
||||
import { LanguageSettingsPageComponent } from './language-settings-page.component';
|
||||
|
||||
class MockAuthSessionStore {
|
||||
readonly isAuthenticated = signal(true);
|
||||
}
|
||||
|
||||
class MockI18nService {
|
||||
private readonly localeSignal = signal('en-US');
|
||||
|
||||
readonly locale = computed(() => this.localeSignal());
|
||||
readonly setLocale = jasmine.createSpy('setLocale').and.callFake(async (locale: string) => {
|
||||
this.localeSignal.set(locale);
|
||||
});
|
||||
readonly tryT = jasmine.createSpy('tryT').and.returnValue(null);
|
||||
}
|
||||
|
||||
class MockUserLocalePreferenceService {
|
||||
readonly setLocaleAsync = jasmine.createSpy('setLocaleAsync').and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
class MockLocaleCatalogService {
|
||||
readonly getAvailableLocalesAsync = jasmine
|
||||
.createSpy('getAvailableLocalesAsync')
|
||||
.and.returnValue(Promise.resolve(['en-US', 'de-DE', 'bg-BG', 'ru-RU', 'es-ES', 'fr-FR', 'uk-UA', 'zh-TW', 'zh-CN']));
|
||||
}
|
||||
|
||||
describe('LanguageSettingsPageComponent', () => {
|
||||
let fixture: ComponentFixture<LanguageSettingsPageComponent>;
|
||||
let component: LanguageSettingsPageComponent;
|
||||
let authStore: MockAuthSessionStore;
|
||||
let i18nService: MockI18nService;
|
||||
let localePreference: MockUserLocalePreferenceService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LanguageSettingsPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
{ provide: I18nService, useClass: MockI18nService },
|
||||
{ provide: LocaleCatalogService, useClass: MockLocaleCatalogService },
|
||||
{ provide: UserLocalePreferenceService, useClass: MockUserLocalePreferenceService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(LanguageSettingsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
authStore = TestBed.inject(AuthSessionStore) as unknown as MockAuthSessionStore;
|
||||
i18nService = TestBed.inject(I18nService) as unknown as MockI18nService;
|
||||
localePreference = TestBed.inject(UserLocalePreferenceService) as unknown as MockUserLocalePreferenceService;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('persists locale for authenticated users', async () => {
|
||||
const select = fixture.nativeElement.querySelector('#language-settings-select') as HTMLSelectElement;
|
||||
select.value = 'de-DE';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(i18nService.setLocale).toHaveBeenCalledWith('de-DE');
|
||||
expect(localePreference.setLocaleAsync).toHaveBeenCalledWith('de-DE');
|
||||
expect(component.saveState()).toBe('saved');
|
||||
});
|
||||
|
||||
it('updates locale locally without persistence when unauthenticated', async () => {
|
||||
authStore.isAuthenticated.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const select = fixture.nativeElement.querySelector('#language-settings-select') as HTMLSelectElement;
|
||||
select.value = 'fr-FR';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(i18nService.setLocale).toHaveBeenCalledWith('fr-FR');
|
||||
expect(localePreference.setLocaleAsync).not.toHaveBeenCalled();
|
||||
expect(component.saveState()).toBe('saved');
|
||||
});
|
||||
|
||||
it('surfaces sync failure state when persistence call fails', async () => {
|
||||
localePreference.setLocaleAsync.and.returnValue(Promise.reject(new Error('sync failed')));
|
||||
|
||||
const select = fixture.nativeElement.querySelector('#language-settings-select') as HTMLSelectElement;
|
||||
select.value = 'es-ES';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.saveState()).toBe('syncFailed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
|
||||
|
||||
type LocaleSaveState = 'idle' | 'saved' | 'syncFailed';
|
||||
|
||||
@Component({
|
||||
selector: 'app-language-settings-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="language-settings">
|
||||
<h1 class="page-title">{{ title() }}</h1>
|
||||
<p class="page-subtitle">{{ subtitle() }}</p>
|
||||
|
||||
<section class="language-settings__card">
|
||||
<p class="language-settings__description">{{ description() }}</p>
|
||||
|
||||
<label class="language-settings__label" for="language-settings-select">
|
||||
{{ selectorLabel() }}
|
||||
</label>
|
||||
<select
|
||||
id="language-settings-select"
|
||||
class="language-settings__select"
|
||||
[value]="currentLocale()"
|
||||
[disabled]="isSaving()"
|
||||
[attr.aria-label]="selectorLabel()"
|
||||
(change)="onLocaleSelected($event)"
|
||||
>
|
||||
@for (locale of localeOptions(); track locale) {
|
||||
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@if (statusMessage(); as message) {
|
||||
<p
|
||||
class="language-settings__status"
|
||||
[class.language-settings__status--warning]="saveState() === 'syncFailed'"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.language-settings {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.language-settings__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.language-settings__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.language-settings__label {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.language-settings__select {
|
||||
width: min(320px, 100%);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.language-settings__status {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.language-settings__status--warning {
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class LanguageSettingsPageComponent {
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localeCatalog = inject(LocaleCatalogService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
private readonly authSession = inject(AuthSessionStore);
|
||||
|
||||
readonly currentLocale = this.i18n.locale;
|
||||
readonly isAuthenticated = this.authSession.isAuthenticated;
|
||||
readonly localeOptions = signal<string[]>([...SUPPORTED_LOCALES]);
|
||||
readonly localeCatalogSyncAttempted = signal(false);
|
||||
readonly isSaving = signal(false);
|
||||
readonly saveState = signal<LocaleSaveState>('idle');
|
||||
|
||||
readonly title = computed(() =>
|
||||
this.i18n.tryT('ui.settings.language.title') ?? 'Language'
|
||||
);
|
||||
readonly subtitle = computed(() =>
|
||||
this.i18n.tryT('ui.settings.language.subtitle') ?? 'Set your preferred console language.'
|
||||
);
|
||||
readonly description = computed(() =>
|
||||
this.i18n.tryT('ui.settings.language.description') ?? 'Changes apply immediately in the UI.'
|
||||
);
|
||||
readonly selectorLabel = computed(() =>
|
||||
this.i18n.tryT('ui.settings.language.selector_label') ?? 'Preferred language'
|
||||
);
|
||||
readonly statusMessage = computed(() => {
|
||||
if (this.saveState() === 'syncFailed') {
|
||||
return this.i18n.tryT('ui.settings.language.persisted_error')
|
||||
?? 'Saved locally, but account sync failed.';
|
||||
}
|
||||
|
||||
if (this.saveState() === 'saved') {
|
||||
if (this.isAuthenticated()) {
|
||||
return this.i18n.tryT('ui.settings.language.persisted')
|
||||
?? 'Saved for your account and reused by CLI.';
|
||||
}
|
||||
|
||||
return this.i18n.tryT('ui.settings.language.sign_in_hint')
|
||||
?? 'Sign in to sync this preference with CLI.';
|
||||
}
|
||||
|
||||
return this.isAuthenticated()
|
||||
? this.i18n.tryT('ui.settings.language.persisted')
|
||||
?? 'Saved for your account and reused by CLI.'
|
||||
: this.i18n.tryT('ui.settings.language.sign_in_hint')
|
||||
?? 'Sign in to sync this preference with CLI.';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.loadLocaleOptions();
|
||||
}
|
||||
|
||||
async onLocaleSelected(event: Event): Promise<void> {
|
||||
const selectedLocale = (event.target as HTMLSelectElement | null)?.value?.trim();
|
||||
if (!selectedLocale || selectedLocale === this.currentLocale()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving.set(true);
|
||||
this.saveState.set('idle');
|
||||
|
||||
try {
|
||||
await this.i18n.setLocale(selectedLocale);
|
||||
|
||||
if (this.isAuthenticated()) {
|
||||
try {
|
||||
await this.localePreference.setLocaleAsync(selectedLocale);
|
||||
this.saveState.set('saved');
|
||||
} catch {
|
||||
this.saveState.set('syncFailed');
|
||||
}
|
||||
} else {
|
||||
this.saveState.set('saved');
|
||||
}
|
||||
} finally {
|
||||
this.isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
localeDisplayName(locale: string): string {
|
||||
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
|
||||
return this.i18n.tryT(key) ?? locale;
|
||||
}
|
||||
|
||||
private async loadLocaleOptions(): Promise<void> {
|
||||
if (this.localeCatalogSyncAttempted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.localeCatalogSyncAttempted.set(true);
|
||||
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
|
||||
this.localeOptions.set([...locales]);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,13 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
},
|
||||
{
|
||||
path: 'language',
|
||||
title: 'Language',
|
||||
loadComponent: () =>
|
||||
import('./language/language-settings-page.component').then(m => m.LanguageSettingsPageComponent),
|
||||
data: { breadcrumb: 'Language' },
|
||||
},
|
||||
{
|
||||
path: 'ai-preferences',
|
||||
title: 'AI Preferences',
|
||||
@@ -125,6 +132,15 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
|
||||
data: { breadcrumb: 'Offline Settings' },
|
||||
},
|
||||
{
|
||||
path: 'identity-providers',
|
||||
title: 'Identity Providers',
|
||||
loadComponent: () =>
|
||||
import('./identity-providers/identity-providers-settings-page.component').then(
|
||||
(m) => m.IdentityProvidersSettingsPageComponent,
|
||||
),
|
||||
data: { breadcrumb: 'Identity Providers' },
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
title: 'System',
|
||||
|
||||
@@ -278,7 +278,7 @@ export type SettingsStoreProvider =
|
||||
| 'aws-appconfig';
|
||||
|
||||
/** Authority provider types */
|
||||
export type AuthorityProvider = 'standard' | 'ldap';
|
||||
export type AuthorityProvider = 'standard' | 'ldap' | 'saml' | 'oidc';
|
||||
|
||||
/** SCM (Source Control Management) provider types */
|
||||
export type ScmProvider = 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'azure-devops';
|
||||
@@ -333,6 +333,31 @@ export const AUTHORITY_PROVIDERS: ProviderInfo[] = [
|
||||
{ id: 'adminGroup', label: 'Admin Group DN', type: 'text', required: false, placeholder: 'cn=admins,ou=groups,dc=example,dc=com', helpText: 'Members get admin privileges' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'saml',
|
||||
name: 'SAML 2.0',
|
||||
description: 'SAML 2.0 federation for enterprise SSO (Azure AD, ADFS, Shibboleth)',
|
||||
icon: 'shield',
|
||||
fields: [
|
||||
{ id: 'spEntityId', label: 'SP Entity ID', type: 'text', required: true, placeholder: 'https://stellaops.example.com', helpText: 'Service Provider entity identifier (your app)' },
|
||||
{ id: 'idpEntityId', label: 'IdP Entity ID', type: 'text', required: true, placeholder: 'https://sts.windows.net/tenant-id/', helpText: 'Identity Provider entity identifier' },
|
||||
{ id: 'idpSsoUrl', label: 'IdP SSO URL', type: 'text', required: true, placeholder: 'https://login.microsoftonline.com/tenant-id/saml2', helpText: 'Single Sign-On service endpoint' },
|
||||
{ id: 'idpMetadataUrl', label: 'IdP Metadata URL', type: 'text', required: false, placeholder: 'https://login.microsoftonline.com/tenant-id/federationmetadata/2007-06/federationmetadata.xml', helpText: 'Federation metadata endpoint (optional, auto-configures fields)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'oidc',
|
||||
name: 'OpenID Connect',
|
||||
description: 'OpenID Connect (OIDC) integration for modern SSO (Okta, Auth0, Keycloak)',
|
||||
icon: 'globe',
|
||||
fields: [
|
||||
{ id: 'authority', label: 'Authority URL', type: 'text', required: true, placeholder: 'https://dev-12345.okta.com', helpText: 'OIDC issuer / authority endpoint (must support .well-known/openid-configuration)' },
|
||||
{ id: 'clientId', label: 'Client ID', type: 'text', required: true, placeholder: 'stellaops-client-id', helpText: 'OAuth 2.0 client identifier' },
|
||||
{ id: 'clientSecret', label: 'Client Secret', type: 'password', required: false, helpText: 'OAuth 2.0 client secret (required for confidential clients)' },
|
||||
{ id: 'audience', label: 'Audience', type: 'text', required: false, placeholder: 'api://stellaops', helpText: 'API audience identifier for token validation' },
|
||||
{ id: 'scopes', label: 'Scopes', type: 'text', required: false, defaultValue: 'openid profile email', helpText: 'Space-separated list of OAuth scopes to request' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** User role options */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="timeline-page" role="main" aria-label="Event Timeline">
|
||||
<div class="timeline-page" role="main" [attr.aria-label]="'ui.timeline.event_timeline' | translate">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||
Timeline
|
||||
{{ 'ui.timeline.title' | translate }}
|
||||
</h1>
|
||||
@if (correlationId()) {
|
||||
<span class="correlation-id">{{ correlationId() }}</span>
|
||||
@@ -16,7 +16,7 @@
|
||||
[fromHlc]="currentFilters().fromHlc"
|
||||
[toHlc]="currentFilters().toHlc"
|
||||
></app-export-button>
|
||||
<button mat-icon-button (click)="refreshTimeline()" aria-label="Refresh timeline">
|
||||
<button mat-icon-button (click)="refreshTimeline()" [attr.aria-label]="'ui.timeline.refresh_timeline' | translate" [title]="'ui.timeline.refresh_timeline' | translate">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<p>Loading timeline...</p>
|
||||
<p>{{ 'ui.timeline.loading' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-stroked-button (click)="refreshTimeline()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
Retry
|
||||
{{ 'ui.actions.retry' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
@if (!loading() && !error() && !correlationId()) {
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<p>Enter a correlation ID to view the event timeline</p>
|
||||
<p>{{ 'ui.timeline.empty_state' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@
|
||||
@if (!loading() && !error() && timeline()) {
|
||||
<div class="timeline-content">
|
||||
<!-- Critical path -->
|
||||
<section class="critical-path-section" aria-label="Critical path analysis">
|
||||
<section class="critical-path-section" [attr.aria-label]="'ui.timeline.critical_path' | translate">
|
||||
<app-critical-path [criticalPath]="criticalPath()"></app-critical-path>
|
||||
</section>
|
||||
|
||||
<!-- Main content grid -->
|
||||
<div class="main-grid">
|
||||
<!-- Causal lanes -->
|
||||
<section class="lanes-section" aria-label="Event causal lanes">
|
||||
<section class="lanes-section" [attr.aria-label]="'ui.timeline.causal_lanes' | translate">
|
||||
<app-causal-lanes
|
||||
[events]="timeline()?.events ?? []"
|
||||
[selectedEventId]="selectedEvent()?.eventId ?? null"
|
||||
@@ -76,14 +76,14 @@
|
||||
<div class="load-more">
|
||||
<button mat-stroked-button (click)="loadMore()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
Load more events
|
||||
{{ 'ui.timeline.load_more' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Event detail panel -->
|
||||
<aside class="detail-section" aria-label="Event details">
|
||||
<aside class="detail-section" [attr.aria-label]="'ui.timeline.event_details' | translate">
|
||||
<app-event-detail-panel
|
||||
[event]="selectedEvent()"
|
||||
></app-event-detail-panel>
|
||||
@@ -92,9 +92,9 @@
|
||||
|
||||
<!-- Stats footer -->
|
||||
<footer class="stats-footer">
|
||||
<span>{{ timeline()?.events?.length ?? 0 }} events</span>
|
||||
<span>{{ timeline()?.events?.length ?? 0 }} {{ 'ui.timeline.events' | translate }}</span>
|
||||
@if (timeline()?.hasMore) {
|
||||
<span>of {{ timeline()?.totalCount ?? 0 }} total</span>
|
||||
<span>{{ 'ui.labels.of' | translate }} {{ timeline()?.totalCount ?? 0 }} total</span>
|
||||
}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CriticalPathComponent } from '../../components/critical-path/critical-p
|
||||
import { EventDetailPanelComponent } from '../../components/event-detail-panel/event-detail-panel.component';
|
||||
import { TimelineFilterComponent } from '../../components/timeline-filter/timeline-filter.component';
|
||||
import { ExportButtonComponent } from '../../components/export-button/export-button.component';
|
||||
import { TranslatePipe } from '../../../../core/i18n';
|
||||
|
||||
/**
|
||||
* Timeline page component.
|
||||
@@ -36,7 +37,8 @@ import { ExportButtonComponent } from '../../components/export-button/export-but
|
||||
CriticalPathComponent,
|
||||
EventDetailPanelComponent,
|
||||
TimelineFilterComponent,
|
||||
ExportButtonComponent
|
||||
ExportButtonComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './timeline-page.component.html',
|
||||
styleUrls: ['./timeline-page.component.scss'],
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
@if (vulnerability(); as vuln) {
|
||||
<section class="vuln-detail">
|
||||
<header>
|
||||
<p class="eyebrow">Vulnerability</p>
|
||||
<p class="eyebrow">{{ 'ui.vulnerability_detail.eyebrow' | translate }}</p>
|
||||
<h1>{{ vuln.title }}</h1>
|
||||
<p class="meta">{{ vuln.cveId }} - Severity {{ vuln.severity | titlecase }} - CVSS {{ vuln.cvssScore }}</p>
|
||||
<p class="meta">{{ vuln.cveId }} - {{ 'ui.labels.severity' | translate }} {{ vuln.severity | titlecase }} - {{ 'ui.vulnerability_detail.cvss' | translate }} {{ vuln.cvssScore }}</p>
|
||||
<p class="sub">{{ vuln.description }}</p>
|
||||
</header>
|
||||
|
||||
@if (impactSummary(); as impact) {
|
||||
<section class="vuln-detail__section vuln-detail__impact">
|
||||
<h2>Impact First</h2>
|
||||
<h2>{{ 'ui.vulnerability_detail.impact_first' | translate }}</h2>
|
||||
<div class="impact-grid">
|
||||
<article class="impact-card">
|
||||
<p class="impact-label">EPSS</p>
|
||||
<p class="impact-label">{{ 'ui.vulnerability_detail.epss' | translate }}</p>
|
||||
<p class="impact-value" [class]="'impact-value--' + impact.epssBand">
|
||||
{{ (impact.epssProbability * 100).toFixed(0) }}%
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="impact-card">
|
||||
<p class="impact-label">KEV</p>
|
||||
<p class="impact-label">{{ 'ui.vulnerability_detail.kev' | translate }}</p>
|
||||
<p class="impact-value" [class.impact-value--high]="impact.kevListed">
|
||||
{{ impact.kevListed ? 'Listed' : 'Not listed' }}
|
||||
{{ impact.kevListed ? ('ui.vulnerability_detail.kev_listed' | translate) : ('ui.vulnerability_detail.kev_not_listed' | translate) }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="impact-card">
|
||||
<p class="impact-label">Reachability</p>
|
||||
<p class="impact-label">{{ 'ui.vulnerability_detail.reachability' | translate }}</p>
|
||||
<p class="impact-value" [class]="'impact-value--' + impact.reachabilityStatus">
|
||||
{{ impact.reachabilityStatus | titlecase }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="impact-card">
|
||||
<p class="impact-label">Blast Radius</p>
|
||||
<p class="impact-value">{{ impact.blastRadiusAssets }} assets</p>
|
||||
<p class="impact-label">{{ 'ui.vulnerability_detail.blast_radius' | translate }}</p>
|
||||
<p class="impact-value">{{ impact.blastRadiusAssets }} {{ 'ui.vulnerability_detail.assets' | translate }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -43,7 +43,7 @@
|
||||
<!-- Binary Resolution Section -->
|
||||
@if (binaryResolution(); as resolution) {
|
||||
<section class="vuln-detail__section vuln-detail__resolution">
|
||||
<h2>Binary Resolution</h2>
|
||||
<h2>{{ 'ui.vulnerability_detail.binary_resolution' | translate }}</h2>
|
||||
<div class="resolution-row">
|
||||
<stella-resolution-chip
|
||||
[resolution]="resolution"
|
||||
@@ -55,24 +55,24 @@
|
||||
class="evidence-toggle"
|
||||
(click)="toggleEvidence()"
|
||||
>
|
||||
{{ showEvidence() ? 'Hide' : 'Show' }} evidence
|
||||
{{ showEvidence() ? ('ui.actions.hide' | translate) : ('ui.actions.show' | translate) }} {{ 'ui.vulnerability_detail.evidence_suffix' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (resolution.matchType === 'fingerprint' || resolution.matchType === 'build_id') {
|
||||
<p class="resolution-note">
|
||||
This binary was identified as patched using fingerprint analysis, not just version matching.
|
||||
{{ 'ui.vulnerability_detail.fingerprint_note' | translate }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="vuln-detail__section">
|
||||
<h2>Affected Components</h2>
|
||||
<h2>{{ 'ui.vulnerability_detail.affected_components' | translate }}</h2>
|
||||
<ul>
|
||||
@for (comp of vuln.affectedComponents; track comp) {
|
||||
<li>
|
||||
<strong>{{ comp.name }}</strong> {{ comp.version }} -> fix {{ comp.fixedVersion || 'n/a' }}
|
||||
<strong>{{ comp.name }}</strong> {{ comp.version }} -> {{ 'ui.vulnerability_detail.fix' | translate }} {{ comp.fixedVersion || ('ui.labels.not_applicable' | translate) }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -80,13 +80,13 @@
|
||||
|
||||
<section class="vuln-detail__section vuln-detail__evidence-explorer">
|
||||
<div class="vuln-detail__section-header">
|
||||
<h2>Evidence Tree and Citation Links</h2>
|
||||
<h2>{{ 'ui.vulnerability_detail.evidence_tree' | translate }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-toggle"
|
||||
(click)="toggleEvidenceExplorer()"
|
||||
>
|
||||
{{ showEvidenceExplorer() ? 'Hide' : 'Show' }} evidence explorer
|
||||
{{ showEvidenceExplorer() ? ('ui.actions.hide' | translate) : ('ui.actions.show' | translate) }} {{ 'ui.vulnerability_detail.evidence_explorer' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (showEvidenceExplorer()) {
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
@if (vuln.references?.length) {
|
||||
<section class="vuln-detail__section">
|
||||
<h2>References</h2>
|
||||
<h2>{{ 'ui.vulnerability_detail.references' | translate }}</h2>
|
||||
<ul>
|
||||
@for (ref of vuln.references; track ref) {
|
||||
<li>{{ ref }}</li>
|
||||
@@ -108,13 +108,13 @@
|
||||
</section>
|
||||
}
|
||||
|
||||
<a routerLink="/risk" class="link">Back to Risk</a>
|
||||
<a routerLink="/risk" class="link">{{ 'ui.vulnerability_detail.back_to_risk' | translate }}</a>
|
||||
</section>
|
||||
} @else {
|
||||
@if (error()) {
|
||||
<p>{{ error() }}</p>
|
||||
} @else {
|
||||
<p>Loading...</p>
|
||||
<p>{{ 'ui.loading.skeleton' | translate }}</p>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ResolutionChipComponent } from '../../shared/components/resolution-chip
|
||||
import { EvidenceDrawerComponent } from '../../shared/components/evidence-drawer/evidence-drawer.component';
|
||||
import { EvidenceSubgraphComponent } from '../vuln-explorer/components/evidence-subgraph/evidence-subgraph.component';
|
||||
import { TriageAction } from '../vuln-explorer/models/evidence-subgraph.models';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
|
||||
interface ImpactSummary {
|
||||
epssProbability: number;
|
||||
@@ -21,7 +22,7 @@ interface ImpactSummary {
|
||||
|
||||
@Component({
|
||||
selector: 'st-vulnerability-detail',
|
||||
imports: [CommonModule, RouterLink, ResolutionChipComponent, EvidenceDrawerComponent, EvidenceSubgraphComponent],
|
||||
imports: [CommonModule, RouterLink, ResolutionChipComponent, EvidenceDrawerComponent, EvidenceSubgraphComponent, TranslatePipe],
|
||||
templateUrl: './vulnerability-detail.component.html',
|
||||
styleUrl: './vulnerability-detail.component.scss',
|
||||
providers: []
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
NgZone,
|
||||
} from '@angular/core';
|
||||
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
|
||||
import type { StellaOpsScope } from '../../core/auth';
|
||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||
import type { ApprovalApi } from '../../core/api/approval.client';
|
||||
|
||||
import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
|
||||
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
|
||||
|
||||
@@ -52,8 +51,6 @@ export interface NavSection {
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
SidebarNavGroupComponent,
|
||||
SidebarNavItemComponent
|
||||
],
|
||||
template: `
|
||||
@@ -63,21 +60,6 @@ export interface NavSection {
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="sidebar__brand">
|
||||
<a routerLink="/" class="sidebar__logo">
|
||||
<div class="sidebar__logo-mark">
|
||||
<img src="assets/img/site.png" alt="" width="26" height="26" />
|
||||
</div>
|
||||
@if (!collapsed) {
|
||||
<div class="sidebar__logo-wordmark">
|
||||
<span class="sidebar__logo-name">Stella Ops</span>
|
||||
<span class="sidebar__logo-tagline">Release Control</span>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Close button (mobile only) -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -91,49 +73,23 @@ export interface NavSection {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Collapse toggle (desktop) -->
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar__collapse-toggle"
|
||||
(click)="collapseToggle.emit()"
|
||||
[attr.aria-label]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
[attr.title]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"
|
||||
[class.sidebar__collapse-icon--flipped]="collapsed"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<!-- Navigation - flat list, no collapsible groups -->
|
||||
<nav class="sidebar__nav" #sidebarNav>
|
||||
@for (section of visibleSections(); track section.id) {
|
||||
@if (section.children && section.children.length > 0) {
|
||||
<app-sidebar-nav-group
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[children]="section.children"
|
||||
[sparklineData]="section.sparklineData$ ? section.sparklineData$() : []"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-group>
|
||||
} @else {
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.badge$ ? section.badge$() : null"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
}
|
||||
@for (section of flatNavItems(); track section.id) {
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.badge ?? null"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar__footer">
|
||||
<div class="sidebar__footer-divider"></div>
|
||||
<span class="sidebar__version">v1.0.0</span>
|
||||
<span class="sidebar__version">Stella Ops v1.0.0-alpha</span>
|
||||
</div>
|
||||
</aside>
|
||||
`,
|
||||
@@ -165,73 +121,12 @@ export interface NavSection {
|
||||
background: var(--color-sidebar-border);
|
||||
}
|
||||
|
||||
/* ---- Brand ---- */
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 1rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-sidebar-divider);
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__logo-mark {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(245, 166, 35, 0.1);
|
||||
border: 1px solid rgba(245, 166, 35, 0.15);
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__logo-wordmark {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sidebar__logo-name {
|
||||
color: var(--color-sidebar-brand-text);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar__logo-tagline {
|
||||
color: var(--color-sidebar-text-muted);
|
||||
font-size: 0.5625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ---- Mobile close ---- */
|
||||
.sidebar__close {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.875rem;
|
||||
top: 0.5rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
@@ -239,6 +134,7 @@ export interface NavSection {
|
||||
background: transparent;
|
||||
color: var(--color-sidebar-text-muted);
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-sidebar-hover);
|
||||
@@ -254,67 +150,12 @@ export interface NavSection {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Collapse toggle (desktop) ---- */
|
||||
.sidebar__collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-sidebar-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-sidebar-bg);
|
||||
color: var(--color-sidebar-text-muted);
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 64px;
|
||||
z-index: 10;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-sidebar-hover);
|
||||
color: var(--color-sidebar-text);
|
||||
border-color: var(--color-sidebar-active-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-sidebar-active-border);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__collapse-icon--flipped {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar__collapse-toggle svg {
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.sidebar__collapse-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Collapsed brand ---- */
|
||||
.sidebar--collapsed .sidebar__brand {
|
||||
padding: 0 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar__logo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---- Nav ---- */
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.75rem 0.5rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
|
||||
@@ -332,29 +173,30 @@ export interface NavSection {
|
||||
|
||||
/* ---- Collapsed nav ---- */
|
||||
.sidebar--collapsed .sidebar__nav {
|
||||
padding: 0.75rem 0.25rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.sidebar__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar__footer-divider {
|
||||
height: 1px;
|
||||
background: var(--color-sidebar-divider);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar__version {
|
||||
display: block;
|
||||
font-size: 0.5625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.1em;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-sidebar-version);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar__footer {
|
||||
@@ -477,7 +319,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
id: 'evidence',
|
||||
label: 'Evidence',
|
||||
icon: 'file-text',
|
||||
route: '/evidence',
|
||||
route: '/evidence/overview',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
@@ -495,9 +337,9 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
label: 'Ops',
|
||||
label: 'Operations',
|
||||
icon: 'settings',
|
||||
route: '/ops',
|
||||
route: '/ops/operations',
|
||||
sparklineData$: () => this.doctorTrendService.platformTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
@@ -508,7 +350,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
],
|
||||
children: [
|
||||
{ id: 'ops-operations', label: 'Operations', route: '/ops/operations', icon: 'activity' },
|
||||
{ id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' },
|
||||
{ id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' },
|
||||
],
|
||||
@@ -517,7 +358,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
id: 'setup',
|
||||
label: 'Setup',
|
||||
icon: 'server',
|
||||
route: '/setup',
|
||||
route: '/setup/system',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
@@ -531,7 +372,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
{ id: 'setup-branding', label: 'Tenant & Branding', route: '/setup/tenant-branding', icon: 'paintbrush' },
|
||||
{ id: 'setup-notifications', label: 'Notifications', route: '/setup/notifications', icon: 'bell' },
|
||||
{ id: 'setup-usage', label: 'Usage & Limits', route: '/setup/usage', icon: 'bar-chart' },
|
||||
{ id: 'setup-system', label: 'System Settings', route: '/setup/system', icon: 'settings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -543,6 +383,28 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
.filter((section): section is NavSection => section !== null);
|
||||
});
|
||||
|
||||
/** Flat list of all nav items (sections + children flattened) */
|
||||
readonly flatNavItems = computed(() => {
|
||||
const items: NavItem[] = [];
|
||||
for (const section of this.visibleSections()) {
|
||||
// Add section itself as a nav item
|
||||
items.push({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
icon: section.icon,
|
||||
route: section.route,
|
||||
badge: section.badge$?.() ?? undefined,
|
||||
});
|
||||
// Add children directly after
|
||||
if (section.children) {
|
||||
for (const child of section.children) {
|
||||
items.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.loadPendingApprovalsBadge();
|
||||
this.router.events
|
||||
|
||||
@@ -5,6 +5,9 @@ import { RouterLink, provideRouter } from '@angular/router';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { I18nService } from '../../core/i18n';
|
||||
import { LocaleCatalogService } from '../../core/i18n/locale-catalog.service';
|
||||
import { UserLocalePreferenceService } from '../../core/i18n/user-locale-preference.service';
|
||||
import { AppTopbarComponent } from './app-topbar.component';
|
||||
|
||||
@Component({ selector: 'app-global-search', standalone: true, template: '' })
|
||||
@@ -50,11 +53,34 @@ class MockConsoleSessionService {
|
||||
.and.callFake(async () => undefined);
|
||||
}
|
||||
|
||||
class MockI18nService {
|
||||
private readonly localeSignal = signal('en-US');
|
||||
|
||||
readonly locale = computed(() => this.localeSignal());
|
||||
readonly setLocale = jasmine.createSpy('setLocale').and.callFake(async (locale: string) => {
|
||||
this.localeSignal.set(locale);
|
||||
});
|
||||
readonly tryT = jasmine.createSpy('tryT').and.returnValue(null);
|
||||
}
|
||||
|
||||
class MockUserLocalePreferenceService {
|
||||
readonly getLocaleAsync = jasmine.createSpy('getLocaleAsync').and.returnValue(Promise.resolve(null));
|
||||
readonly setLocaleAsync = jasmine.createSpy('setLocaleAsync').and.returnValue(Promise.resolve());
|
||||
}
|
||||
|
||||
class MockLocaleCatalogService {
|
||||
readonly getAvailableLocalesAsync = jasmine
|
||||
.createSpy('getAvailableLocalesAsync')
|
||||
.and.returnValue(Promise.resolve(['en-US', 'de-DE', 'bg-BG', 'ru-RU', 'es-ES', 'fr-FR', 'uk-UA', 'zh-TW', 'zh-CN']));
|
||||
}
|
||||
|
||||
describe('AppTopbarComponent', () => {
|
||||
let fixture: ComponentFixture<AppTopbarComponent>;
|
||||
let component: AppTopbarComponent;
|
||||
let sessionService: MockConsoleSessionService;
|
||||
let sessionStore: MockConsoleSessionStore;
|
||||
let i18nService: MockI18nService;
|
||||
let localePreferenceService: MockUserLocalePreferenceService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -64,6 +90,9 @@ describe('AppTopbarComponent', () => {
|
||||
{ provide: AuthSessionStore, useClass: MockAuthSessionStore },
|
||||
{ provide: ConsoleSessionStore, useClass: MockConsoleSessionStore },
|
||||
{ provide: ConsoleSessionService, useClass: MockConsoleSessionService },
|
||||
{ provide: I18nService, useClass: MockI18nService },
|
||||
{ provide: LocaleCatalogService, useClass: MockLocaleCatalogService },
|
||||
{ provide: UserLocalePreferenceService, useClass: MockUserLocalePreferenceService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(AppTopbarComponent, {
|
||||
@@ -82,6 +111,8 @@ describe('AppTopbarComponent', () => {
|
||||
component = fixture.componentInstance;
|
||||
sessionService = TestBed.inject(ConsoleSessionService) as unknown as MockConsoleSessionService;
|
||||
sessionStore = TestBed.inject(ConsoleSessionStore) as unknown as MockConsoleSessionStore;
|
||||
i18nService = TestBed.inject(I18nService) as unknown as MockI18nService;
|
||||
localePreferenceService = TestBed.inject(UserLocalePreferenceService) as unknown as MockUserLocalePreferenceService;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -127,4 +158,17 @@ describe('AppTopbarComponent', () => {
|
||||
|
||||
expect(sessionService.loadConsoleContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches locale when a new locale is selected', async () => {
|
||||
const select = fixture.nativeElement.querySelector('#topbar-locale-select') as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
select.value = 'de-DE';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(i18nService.setLocale).toHaveBeenCalledWith('de-DE');
|
||||
expect(localePreferenceService.setLocaleAsync).toHaveBeenCalledWith('de-DE');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||
import { GlobalSearchComponent } from '../global-search/global-search.component';
|
||||
import { ContextChipsComponent } from '../context-chips/context-chips.component';
|
||||
import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component';
|
||||
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../core/i18n';
|
||||
|
||||
/**
|
||||
* AppTopbarComponent - Top bar with global search, context chips, tenant, and user menu.
|
||||
@@ -42,56 +43,50 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
],
|
||||
template: `
|
||||
<header class="topbar" role="banner">
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__menu-toggle"
|
||||
(click)="menuToggle.emit()"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Row 1: Logo + Search + Create + User -->
|
||||
<div class="topbar__row topbar__row--primary">
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__menu-toggle"
|
||||
(click)="menuToggle.emit()"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Global Search -->
|
||||
<div class="topbar__search">
|
||||
<app-global-search></app-global-search>
|
||||
</div>
|
||||
<!-- Brand (moved from sidebar) -->
|
||||
<a routerLink="/" class="topbar__brand">
|
||||
<div class="topbar__brand-mark">
|
||||
<img src="assets/img/site.png" alt="" width="24" height="24" />
|
||||
</div>
|
||||
<span class="topbar__brand-name">Stella Ops</span>
|
||||
</a>
|
||||
|
||||
<!-- Context chips row (desktop) -->
|
||||
<div class="topbar__context topbar__context--desktop">
|
||||
<app-context-chips></app-context-chips>
|
||||
</div>
|
||||
|
||||
<!-- Right section: Tenant + User -->
|
||||
<div class="topbar__right">
|
||||
@if (primaryAction(); as action) {
|
||||
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a>
|
||||
}
|
||||
|
||||
<!-- Scope controls (tablet/mobile) -->
|
||||
<div class="topbar__scope-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__scope-toggle"
|
||||
[attr.aria-expanded]="scopePanelOpen()"
|
||||
aria-haspopup="dialog"
|
||||
(click)="toggleScopePanel()"
|
||||
>
|
||||
Scope
|
||||
</button>
|
||||
@if (scopePanelOpen()) {
|
||||
<div class="topbar__scope-panel" role="dialog" aria-label="Global scope controls">
|
||||
<app-context-chips></app-context-chips>
|
||||
</div>
|
||||
}
|
||||
<!-- Global Search -->
|
||||
<div class="topbar__search">
|
||||
<app-global-search></app-global-search>
|
||||
</div>
|
||||
|
||||
<!-- Tenant selector -->
|
||||
@if (isAuthenticated()) {
|
||||
<!-- Right section: Create + User -->
|
||||
<div class="topbar__right">
|
||||
@if (primaryAction(); as action) {
|
||||
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a>
|
||||
}
|
||||
|
||||
<!-- User menu -->
|
||||
<app-user-menu></app-user-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Tenant (if multi) + Context chips + Locale -->
|
||||
<div class="topbar__row topbar__row--secondary">
|
||||
<!-- Tenant selector (only shown when multiple tenants) -->
|
||||
@if (showTenantSelector()) {
|
||||
<div class="topbar__tenant">
|
||||
<button
|
||||
type="button"
|
||||
@@ -105,7 +100,7 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
(keydown)="onTenantTriggerKeydown($event)"
|
||||
>
|
||||
<span class="topbar__tenant-label">{{ activeTenantDisplayName() }}</span>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -152,32 +147,76 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="topbar__row-sep"></div>
|
||||
}
|
||||
|
||||
<!-- User menu -->
|
||||
<app-user-menu></app-user-menu>
|
||||
<app-context-chips></app-context-chips>
|
||||
|
||||
<div class="topbar__locale">
|
||||
<label class="topbar__locale-label" for="topbar-locale-select">{{ localeLabel() }}</label>
|
||||
<select
|
||||
id="topbar-locale-select"
|
||||
class="topbar__locale-select"
|
||||
[value]="currentLocale()"
|
||||
[attr.aria-label]="'Locale selector. Current locale: ' + currentLocale()"
|
||||
(change)="onLocaleSelected($event)"
|
||||
>
|
||||
@for (locale of localeOptions(); track locale) {
|
||||
<option [value]="locale">{{ localeDisplayName(locale) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
styles: [`
|
||||
/* ---- Shell ---- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* ---- Row layout ---- */
|
||||
.topbar__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 44px;
|
||||
padding: 0 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar__row--primary {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.topbar__row--secondary {
|
||||
height: 32px;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
padding: 0 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.topbar__row--secondary::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar {
|
||||
.topbar__row {
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.topbar__row--secondary {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Hamburger ---- */
|
||||
.topbar__menu-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
@@ -206,6 +245,45 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Brand ---- */
|
||||
.topbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__brand-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.topbar__brand-mark img {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.topbar__brand-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__brand-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Search ---- */
|
||||
.topbar__search {
|
||||
flex: 1;
|
||||
max-width: 540px;
|
||||
@@ -218,64 +296,7 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
}
|
||||
}
|
||||
|
||||
.topbar__context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.topbar__scope-wrap {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar__scope-toggle {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.6875rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.35rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar__scope-toggle:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topbar__scope-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar__scope-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.4rem);
|
||||
z-index: 120;
|
||||
min-width: 340px;
|
||||
max-width: min(92vw, 420px);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.topbar__context {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__scope-wrap {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Right section (row 1) ---- */
|
||||
.topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -284,16 +305,6 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__right {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.topbar__primary-action {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar__primary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -316,31 +327,98 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.topbar__tenant {
|
||||
position: relative;
|
||||
@media (max-width: 575px) {
|
||||
.topbar__right {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.topbar__primary-action {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar__scope-panel {
|
||||
right: -3.5rem;
|
||||
/* ---- Row 2 separator ---- */
|
||||
.topbar__row-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--color-border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Locale ---- */
|
||||
.topbar__locale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.topbar__locale-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.topbar__locale-select {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.6875rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.02em;
|
||||
min-width: 86px;
|
||||
padding: 0.3rem 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar__locale-select:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topbar__locale-select:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__locale-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__locale-select {
|
||||
min-width: 72px;
|
||||
padding: 0.26rem 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Tenant selector (row 2) ---- */
|
||||
.topbar__tenant {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__tenant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
gap: 0.25rem;
|
||||
height: 24px;
|
||||
padding: 0 0.45rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
@@ -360,7 +438,7 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
|
||||
.topbar__tenant-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: calc(100% + 0.4rem);
|
||||
z-index: 120;
|
||||
min-width: 260px;
|
||||
@@ -455,16 +533,6 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__tenant-btn {
|
||||
padding: 0.32rem 0.45rem;
|
||||
}
|
||||
|
||||
.topbar__tenant-label {
|
||||
max-width: 72px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -472,6 +540,9 @@ export class AppTopbarComponent {
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleSession = inject(ConsoleSessionService);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localeCatalog = inject(LocaleCatalogService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
@@ -486,6 +557,24 @@ export class AppTopbarComponent {
|
||||
readonly activeTenantDisplayName = computed(() =>
|
||||
this.consoleStore.currentTenant()?.displayName ?? this.activeTenant() ?? 'Tenant',
|
||||
);
|
||||
readonly currentLocale = this.i18n.locale;
|
||||
readonly localeLabel = computed(() => this.i18n.tryT('ui.locale.label') ?? 'Locale');
|
||||
readonly availableLocales = signal<string[]>([...SUPPORTED_LOCALES]);
|
||||
readonly localeOptions = computed(() => {
|
||||
const current = this.currentLocale();
|
||||
const options = [...this.availableLocales()];
|
||||
|
||||
if (current && !options.includes(current)) {
|
||||
options.unshift(current);
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
readonly showTenantSelector = computed(() =>
|
||||
this.isAuthenticated() && this.tenants().length > 1,
|
||||
);
|
||||
readonly localeCatalogSyncAttempted = signal(false);
|
||||
readonly localePreferenceSyncAttempted = signal(false);
|
||||
readonly scopePanelOpen = signal(false);
|
||||
readonly tenantPanelOpen = signal(false);
|
||||
readonly tenantSwitchInFlight = signal(false);
|
||||
@@ -522,6 +611,28 @@ export class AppTopbarComponent {
|
||||
this.tenantBootstrapAttempted.set(true);
|
||||
void this.consoleSession.loadConsoleContext().catch(() => undefined);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const authenticated = this.isAuthenticated();
|
||||
if (!authenticated) {
|
||||
this.localeCatalogSyncAttempted.set(false);
|
||||
this.availableLocales.set([...SUPPORTED_LOCALES]);
|
||||
this.localePreferenceSyncAttempted.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.localeCatalogSyncAttempted()) {
|
||||
this.localeCatalogSyncAttempted.set(true);
|
||||
void this.loadLocaleCatalog();
|
||||
}
|
||||
|
||||
if (this.localePreferenceSyncAttempted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.localePreferenceSyncAttempted.set(true);
|
||||
void this.syncLocaleFromPreference();
|
||||
});
|
||||
}
|
||||
|
||||
toggleScopePanel(): void {
|
||||
@@ -577,6 +688,18 @@ export class AppTopbarComponent {
|
||||
}
|
||||
}
|
||||
|
||||
async onLocaleSelected(event: Event): Promise<void> {
|
||||
const selectedLocale = (event.target as HTMLSelectElement | null)?.value?.trim();
|
||||
if (!selectedLocale || selectedLocale === this.currentLocale()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.i18n.setLocale(selectedLocale);
|
||||
if (this.isAuthenticated()) {
|
||||
void this.localePreference.setLocaleAsync(selectedLocale).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
onTenantTriggerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
@@ -712,4 +835,23 @@ export class AppTopbarComponent {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
localeDisplayName(locale: string): string {
|
||||
const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`;
|
||||
return this.i18n.tryT(key) ?? locale;
|
||||
}
|
||||
|
||||
private async syncLocaleFromPreference(): Promise<void> {
|
||||
const preferredLocale = await this.localePreference.getLocaleAsync();
|
||||
if (!preferredLocale || preferredLocale === this.currentLocale()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.i18n.setLocale(preferredLocale);
|
||||
}
|
||||
|
||||
private async loadLocaleCatalog(): Promise<void> {
|
||||
const locales = await this.localeCatalog.getAvailableLocalesAsync(SUPPORTED_LOCALES);
|
||||
this.availableLocales.set([...locales]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,16 +38,19 @@ interface DropdownOption {
|
||||
type="button"
|
||||
class="ctx__dropdown-btn"
|
||||
[class.ctx__dropdown-btn--open]="regionOpen()"
|
||||
[class.ctx__dropdown-btn--empty]="context.regions().length === 0"
|
||||
[disabled]="context.loading()"
|
||||
(click)="toggleRegion()"
|
||||
>
|
||||
<span class="ctx__dropdown-key">Region</span>
|
||||
<span class="ctx__dropdown-val">{{ context.regionSummary() }}</span>
|
||||
<svg class="ctx__dropdown-caret" viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2.5"/>
|
||||
</svg>
|
||||
<span class="ctx__dropdown-val">{{ context.regions().length === 0 ? 'No regions defined' : context.regionSummary() }}</span>
|
||||
@if (context.regions().length > 0) {
|
||||
<svg class="ctx__dropdown-caret" viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2.5"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
@if (regionOpen()) {
|
||||
@if (regionOpen() && context.regions().length > 0) {
|
||||
<div class="ctx__dropdown-panel">
|
||||
@for (region of context.regions(); track region.regionId) {
|
||||
<label class="ctx__dropdown-option">
|
||||
@@ -69,16 +72,19 @@ interface DropdownOption {
|
||||
type="button"
|
||||
class="ctx__dropdown-btn"
|
||||
[class.ctx__dropdown-btn--open]="envOpen()"
|
||||
[class.ctx__dropdown-btn--empty]="context.environments().length === 0"
|
||||
[disabled]="context.loading()"
|
||||
(click)="toggleEnv()"
|
||||
>
|
||||
<span class="ctx__dropdown-key">Env</span>
|
||||
<span class="ctx__dropdown-val">{{ context.environmentSummary() }}</span>
|
||||
<svg class="ctx__dropdown-caret" viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2.5"/>
|
||||
</svg>
|
||||
<span class="ctx__dropdown-val">{{ context.environments().length === 0 ? 'No env defined yet' : context.environmentSummary() }}</span>
|
||||
@if (context.environments().length > 0) {
|
||||
<svg class="ctx__dropdown-caret" viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2.5"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
@if (envOpen()) {
|
||||
@if (envOpen() && context.environments().length > 0) {
|
||||
<div class="ctx__dropdown-panel">
|
||||
@for (env of context.environments(); track env.environmentId) {
|
||||
<label class="ctx__dropdown-option">
|
||||
@@ -242,6 +248,17 @@ interface DropdownOption {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx__dropdown-btn--empty {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ctx__dropdown-btn--empty .ctx__dropdown-val {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ctx__dropdown-key {
|
||||
font-size: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import type { EntityCardAction } from '../../../core/api/unified-search.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-action-waterfall',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="action-waterfall" role="toolbar" aria-label="Entity actions">
|
||||
@for (action of actions(); track action.label; let i = $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="action-waterfall__btn"
|
||||
[class.action-waterfall__btn--primary]="action.isPrimary"
|
||||
(click)="onExecute(action, $event)"
|
||||
[attr.aria-keyshortcuts]="i < 9 ? (i + 1).toString() : undefined"
|
||||
[title]="action.label + (i < 9 ? ' (' + (i + 1) + ')' : '')"
|
||||
>
|
||||
{{ action.label }}
|
||||
@if (i < 9) {
|
||||
<kbd class="action-waterfall__key">{{ i + 1 }}</kbd>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.action-waterfall {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-waterfall__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.35rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-waterfall__btn:hover,
|
||||
.action-waterfall__btn--primary {
|
||||
background: var(--color-brand-soft);
|
||||
color: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.action-waterfall__key {
|
||||
font-size: 0.5625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
padding: 0 0.15rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 2px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ActionWaterfallComponent {
|
||||
readonly actions = input<EntityCardAction[]>([]);
|
||||
readonly execute = output<EntityCardAction>();
|
||||
|
||||
onExecute(action: EntityCardAction, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.execute.emit(action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import type { EntityCard, EntityCardAction } from '../../../core/api/unified-search.models';
|
||||
import { DOMAIN_LABELS, ENTITY_TYPE_LABELS } from '../../../core/api/unified-search.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-card',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div
|
||||
class="entity-card"
|
||||
[class.entity-card--selected]="selected()"
|
||||
[attr.data-domain]="card().domain"
|
||||
role="listitem"
|
||||
>
|
||||
<div class="entity-card__icon" [attr.data-domain]="card().domain">
|
||||
@switch (card().domain) {
|
||||
@case ('knowledge') {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M4 5a2 2 0 0 1 2-2h11a3 3 0 0 1 3 3v13a2 2 0 0 1-2 2H7a3 3 0 0 0-3 3" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@case ('findings') {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/><line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" stroke-width="2"/><line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@case ('vex') {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2"/><polyline points="9 12 11 14 15 10" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@case ('policy') {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
@default {
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/><line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/></svg>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="entity-card__body">
|
||||
<div class="entity-card__header">
|
||||
<span class="entity-card__title">{{ card().title }}</span>
|
||||
<span class="entity-card__badge" [attr.data-domain]="card().domain">
|
||||
{{ domainLabel() }}
|
||||
</span>
|
||||
@if (card().severity) {
|
||||
<span class="entity-card__severity" [attr.data-severity]="card().severity">
|
||||
{{ card().severity }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (card().snippet) {
|
||||
<div class="entity-card__snippet">{{ card().snippet }}</div>
|
||||
}
|
||||
|
||||
<div class="entity-card__meta">
|
||||
<span class="entity-card__entity-type">{{ entityTypeLabel() }}</span>
|
||||
@if (card().sources.length > 0) {
|
||||
<span class="entity-card__sources">{{ card().sources.length }} source{{ card().sources.length === 1 ? '' : 's' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (card().preview) {
|
||||
<button class="entity-card__expand-toggle"
|
||||
(click)="onToggleExpand($event)"
|
||||
[attr.aria-expanded]="expandedInput()"
|
||||
[attr.aria-controls]="'preview-' + card().entityKey"
|
||||
aria-label="Toggle preview">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" [class.rotated]="expandedInput()">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (expandedInput() && card().preview) {
|
||||
<div class="entity-card__preview"
|
||||
role="region"
|
||||
aria-label="Result preview"
|
||||
[id]="'preview-' + card().entityKey">
|
||||
@switch (card().preview!.contentType) {
|
||||
@case ('markdown') {
|
||||
<div class="entity-card__preview-markdown" [innerHTML]="renderedMarkdown()"></div>
|
||||
}
|
||||
@case ('code') {
|
||||
<pre class="entity-card__preview-code"><code>{{ card().preview!.content }}</code></pre>
|
||||
}
|
||||
@case ('structured') {
|
||||
<div class="entity-card__preview-structured">
|
||||
@for (field of card().preview!.structuredFields ?? []; track field.label) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-field__label">{{ field.label }}</span>
|
||||
<span class="preview-field__value" [attr.data-severity]="field.severity">{{ field.value }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (card().preview!.content) {
|
||||
<pre class="entity-card__preview-code"><code>{{ card().preview!.content }}</code></pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (card().actions.length > 0) {
|
||||
<div class="entity-card__actions">
|
||||
@for (action of card().actions; track action.label) {
|
||||
<button
|
||||
type="button"
|
||||
class="entity-card__action"
|
||||
[class.entity-card__action--primary]="action.isPrimary"
|
||||
(click)="onAction(action, $event)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="entity-card__action entity-card__action--ask-ai"
|
||||
(click)="onAskAi($event)"
|
||||
title="Ask AdvisoryAI about this result"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>
|
||||
<path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/>
|
||||
</svg>
|
||||
Ask AI
|
||||
</button>
|
||||
<div class="entity-card__feedback">
|
||||
<button
|
||||
type="button"
|
||||
class="entity-card__feedback-btn"
|
||||
[class.entity-card__feedback-btn--active-helpful]="feedbackGiven() === 'helpful'"
|
||||
[disabled]="!!feedbackGiven()"
|
||||
(click)="onFeedback('helpful', $event)"
|
||||
title="This result was helpful"
|
||||
aria-label="Mark as helpful"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/>
|
||||
<path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="entity-card__feedback-btn"
|
||||
[class.entity-card__feedback-btn--active-not-helpful]="feedbackGiven() === 'not_helpful'"
|
||||
[disabled]="!!feedbackGiven()"
|
||||
(click)="onFeedback('not_helpful', $event)"
|
||||
title="This result was not helpful"
|
||||
aria-label="Mark as not helpful"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 15V19a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/>
|
||||
<path d="M17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.entity-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: background-color 0.1s;
|
||||
cursor: pointer;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.entity-card:hover,
|
||||
.entity-card--selected {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.entity-card__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.entity-card__icon[data-domain="findings"] { color: var(--color-severity-high, #ea580c); }
|
||||
.entity-card__icon[data-domain="vex"] { color: var(--color-severity-medium, #ca8a04); }
|
||||
.entity-card__icon[data-domain="policy"] { color: var(--color-brand-primary, #2563eb); }
|
||||
|
||||
.entity-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.entity-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entity-card__title {
|
||||
font-size: 0.875rem;
|
||||
color: #111827;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entity-card__badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.entity-card__badge[data-domain="findings"] {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.entity-card__badge[data-domain="vex"] {
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.entity-card__badge[data-domain="policy"] {
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.entity-card__severity {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.entity-card__severity[data-severity="critical"] { background: #fee2e2; color: #991b1b; }
|
||||
.entity-card__severity[data-severity="high"] { background: #ffedd5; color: #9a3412; }
|
||||
.entity-card__severity[data-severity="medium"] { background: #fef3c7; color: #92400e; }
|
||||
.entity-card__severity[data-severity="low"] { background: #dbeafe; color: #1e3a8a; }
|
||||
|
||||
.entity-card__snippet {
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.entity-card__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.entity-card__expand-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.125rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.entity-card__expand-toggle:hover { color: #111827; }
|
||||
.entity-card__expand-toggle svg { transition: transform 0.2s ease; }
|
||||
.entity-card__expand-toggle svg.rotated { transform: rotate(180deg); }
|
||||
|
||||
.entity-card__preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
animation: previewExpand 0.2s ease-out;
|
||||
}
|
||||
@keyframes previewExpand {
|
||||
from { max-height: 0; opacity: 0; }
|
||||
to { max-height: 400px; opacity: 1; }
|
||||
}
|
||||
.entity-card__preview-markdown { color: #1f2937; }
|
||||
.entity-card__preview-code {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.6875rem;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
}
|
||||
.preview-field {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.preview-field__label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
min-width: 100px;
|
||||
}
|
||||
.preview-field__value { color: #1f2937; }
|
||||
.preview-field__value[data-severity="critical"] { color: #991b1b; font-weight: 600; }
|
||||
.preview-field__value[data-severity="high"] { color: #9a3412; font-weight: 600; }
|
||||
.preview-field__value[data-severity="medium"] { color: #92400e; }
|
||||
.preview-field__value[data-severity="low"] { color: #1e3a8a; }
|
||||
|
||||
.entity-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.entity-card__action {
|
||||
border: 1px solid #9ca3af;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
border-radius: 6px;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.35rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.entity-card__action:hover,
|
||||
.entity-card__action--primary {
|
||||
background: #e5efff;
|
||||
color: #1e3a8a;
|
||||
border-color: #1e3a8a;
|
||||
}
|
||||
|
||||
.entity-card__action--ask-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
border-color: #7dd3fc;
|
||||
}
|
||||
|
||||
.entity-card__action--ask-ai:hover {
|
||||
background: #e0f2fe;
|
||||
color: #0c4a6e;
|
||||
border-color: #0369a1;
|
||||
}
|
||||
|
||||
.entity-card__action--ask-ai svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-card__feedback {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.entity-card__feedback-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.125rem;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.entity-card__feedback-btn:hover:not(:disabled) {
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.entity-card__feedback-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.entity-card__feedback-btn--active-helpful {
|
||||
color: #16a34a !important;
|
||||
border-color: #16a34a !important;
|
||||
}
|
||||
|
||||
.entity-card__feedback-btn--active-not-helpful {
|
||||
color: #dc2626 !important;
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entity-card {
|
||||
transition: none;
|
||||
}
|
||||
.entity-card__expand-toggle svg {
|
||||
transition: none;
|
||||
}
|
||||
.entity-card__preview {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntityCardComponent {
|
||||
readonly card = input.required<EntityCard>();
|
||||
readonly selected = input(false);
|
||||
readonly expandedInput = input(false);
|
||||
readonly actionExecuted = output<EntityCardAction>();
|
||||
readonly askAi = output<EntityCard>();
|
||||
readonly toggleExpand = output<string>();
|
||||
readonly feedbackSubmitted = output<{ entityKey: string; signal: 'helpful' | 'not_helpful' }>();
|
||||
readonly feedbackGiven = signal<'helpful' | 'not_helpful' | null>(null);
|
||||
|
||||
readonly domainLabel = computed(() => DOMAIN_LABELS[this.card().domain] ?? this.card().domain);
|
||||
readonly entityTypeLabel = computed(() => ENTITY_TYPE_LABELS[this.card().entityType] ?? this.card().entityType);
|
||||
|
||||
readonly renderedMarkdown = computed(() => {
|
||||
const preview = this.card().preview;
|
||||
if (!preview || preview.contentType !== 'markdown') return '';
|
||||
return this.renderSimpleMarkdown(preview.content);
|
||||
});
|
||||
|
||||
onAction(action: EntityCardAction, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.actionExecuted.emit(action);
|
||||
}
|
||||
|
||||
onAskAi(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.askAi.emit(this.card());
|
||||
}
|
||||
|
||||
onFeedback(feedbackSignal: 'helpful' | 'not_helpful', event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (this.feedbackGiven()) return;
|
||||
this.feedbackGiven.set(feedbackSignal);
|
||||
this.feedbackSubmitted.emit({ entityKey: this.card().entityKey, signal: feedbackSignal });
|
||||
}
|
||||
|
||||
onToggleExpand(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.toggleExpand.emit(this.card().entityKey);
|
||||
}
|
||||
|
||||
private renderSimpleMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="entity-card__preview-code"><code>$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code style="background:#e5e7eb;padding:0 0.25rem;border-radius:3px;font-size:0.6875rem">$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { SlicePipe } from '@angular/common';
|
||||
import type { SynthesisCitation, SynthesisResult } from '../../../core/api/unified-search.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-synthesis-panel',
|
||||
standalone: true,
|
||||
imports: [SlicePipe],
|
||||
template: `
|
||||
@if (synthesis()) {
|
||||
<div class="synthesis-panel" role="region" aria-label="Search synthesis summary">
|
||||
<div class="synthesis-panel__header">
|
||||
<svg class="synthesis-panel__icon" viewBox="0 0 24 24" width="14" height="14">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<span class="synthesis-panel__label">Summary</span>
|
||||
@if (isLlmGrounded()) {
|
||||
<span class="synthesis-panel__ai-badge">AI-generated</span>
|
||||
}
|
||||
<span
|
||||
class="synthesis-panel__confidence"
|
||||
[attr.data-confidence]="synthesis()!.confidence"
|
||||
>
|
||||
{{ confidenceLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="synthesis-panel__body">
|
||||
{{ synthesis()!.summary }}
|
||||
</div>
|
||||
@if (isLlmGrounded() && citations().length > 0) {
|
||||
<div class="synthesis-panel__citations">
|
||||
<span class="synthesis-panel__citations-label">Based on {{ citations().length }} source{{ citations().length === 1 ? '' : 's' }}:</span>
|
||||
@for (citation of citations(); track citation.index) {
|
||||
<button
|
||||
type="button"
|
||||
class="synthesis-panel__citation-chip"
|
||||
(click)="onCitationClick(citation)"
|
||||
[title]="citation.title"
|
||||
>
|
||||
[{{ citation.index }}] {{ citation.title | slice:0:40 }}{{ citation.title.length > 40 ? '...' : '' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (synthesis()!.domainsCovered.length > 0) {
|
||||
<div class="synthesis-panel__domains">
|
||||
@for (domain of synthesis()!.domainsCovered; track domain) {
|
||||
<span class="synthesis-panel__domain-tag">{{ domain }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isLlmGrounded() && citations().length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="synthesis-panel__toggle-sources"
|
||||
(click)="toggleSources()"
|
||||
>
|
||||
{{ showSources() ? 'Hide sources' : 'Show sources' }}
|
||||
</button>
|
||||
@if (showSources()) {
|
||||
<div class="synthesis-panel__sources-list">
|
||||
@for (citation of citations(); track citation.index) {
|
||||
<div class="synthesis-panel__source-item">
|
||||
<span class="synthesis-panel__source-index">[{{ citation.index }}]</span>
|
||||
<span class="synthesis-panel__source-title">{{ citation.title }}</span>
|
||||
<span class="synthesis-panel__source-key">{{ citation.entityKey }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="synthesis-panel__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="synthesis-panel__ask-ai"
|
||||
(click)="onAskAi()"
|
||||
title="Ask AdvisoryAI for more details about this summary"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/>
|
||||
<path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/>
|
||||
</svg>
|
||||
Ask AI for details
|
||||
</button>
|
||||
|
||||
<div class="synthesis-panel__feedback">
|
||||
<span class="synthesis-panel__feedback-label">Was this summary helpful?</span>
|
||||
<button
|
||||
type="button"
|
||||
class="synthesis-panel__feedback-btn"
|
||||
[class.synthesis-panel__feedback-btn--active-helpful]="synthesisFeedbackGiven() === 'helpful'"
|
||||
[disabled]="!!synthesisFeedbackGiven()"
|
||||
(click)="onSynthesisFeedback('helpful')"
|
||||
title="Summary was helpful"
|
||||
aria-label="Mark summary as helpful"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/>
|
||||
<path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="synthesis-panel__feedback-btn"
|
||||
[class.synthesis-panel__feedback-btn--active-not-helpful]="synthesisFeedbackGiven() === 'not_helpful'"
|
||||
[disabled]="!!synthesisFeedbackGiven()"
|
||||
(click)="onSynthesisFeedback('not_helpful')"
|
||||
title="Summary was not helpful"
|
||||
aria-label="Mark summary as not helpful"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10 15V19a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/>
|
||||
<path d="M17 2h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.synthesis-panel {
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.synthesis-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__icon {
|
||||
color: #374151;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.synthesis-panel__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.synthesis-panel__ai-badge {
|
||||
font-size: 0.5625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.synthesis-panel__confidence {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.synthesis-panel__confidence[data-confidence="high"] {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.synthesis-panel__confidence[data-confidence="medium"] {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.synthesis-panel__confidence[data-confidence="low"] {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.synthesis-panel__body {
|
||||
font-size: 0.8125rem;
|
||||
color: #111827;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.synthesis-panel__citations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__citations-label {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.synthesis-panel__citation-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border: 1px solid #c4b5fd;
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
border-radius: 999px;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.synthesis-panel__citation-chip:hover {
|
||||
background: #ede9fe;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.synthesis-panel__domains {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__domain-tag {
|
||||
font-size: 0.5625rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
border: 1px solid #9ca3af;
|
||||
}
|
||||
|
||||
.synthesis-panel__toggle-sources {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.synthesis-panel__toggle-sources:hover {
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.synthesis-panel__sources-list {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.synthesis-panel__source-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
padding: 0.125rem 0;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__source-index {
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.synthesis-panel__source-title {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.synthesis-panel__source-key {
|
||||
color: #9ca3af;
|
||||
font-size: 0.625rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.synthesis-panel__ask-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid #7dd3fc;
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.synthesis-panel__ask-ai:hover {
|
||||
background: #e0f2fe;
|
||||
color: #0c4a6e;
|
||||
border-color: #0369a1;
|
||||
}
|
||||
|
||||
.synthesis-panel__ask-ai svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.synthesis-panel__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-label {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.125rem;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-btn:hover:not(:disabled) {
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-btn--active-helpful {
|
||||
color: #16a34a !important;
|
||||
border-color: #16a34a !important;
|
||||
}
|
||||
|
||||
.synthesis-panel__feedback-btn--active-not-helpful {
|
||||
color: #dc2626 !important;
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SynthesisPanelComponent {
|
||||
readonly synthesis = input<SynthesisResult | null>(null);
|
||||
readonly askAi = output<void>();
|
||||
readonly citationClick = output<SynthesisCitation>();
|
||||
readonly synthesisFeedbackSubmitted = output<{ signal: 'helpful' | 'not_helpful' }>();
|
||||
readonly synthesisFeedbackGiven = signal<'helpful' | 'not_helpful' | null>(null);
|
||||
|
||||
readonly showSources = signal(false);
|
||||
|
||||
readonly isLlmGrounded = computed(() => {
|
||||
const s = this.synthesis();
|
||||
return s?.template === 'llm_grounded';
|
||||
});
|
||||
|
||||
readonly citations = computed((): SynthesisCitation[] => {
|
||||
const s = this.synthesis();
|
||||
if (!s?.citations) return [];
|
||||
return s.citations;
|
||||
});
|
||||
|
||||
readonly confidenceLabel = computed(() => {
|
||||
const s = this.synthesis();
|
||||
if (!s) return '';
|
||||
|
||||
switch (s.confidence) {
|
||||
case 'high':
|
||||
return `High (${s.sourceCount} sources)`;
|
||||
case 'medium':
|
||||
return `Medium (${s.sourceCount} sources)`;
|
||||
default:
|
||||
return `Low (${s.sourceCount} sources)`;
|
||||
}
|
||||
});
|
||||
|
||||
onAskAi(): void {
|
||||
this.askAi.emit();
|
||||
}
|
||||
|
||||
onCitationClick(citation: SynthesisCitation): void {
|
||||
this.citationClick.emit(citation);
|
||||
}
|
||||
|
||||
toggleSources(): void {
|
||||
this.showSources.update(v => !v);
|
||||
}
|
||||
|
||||
onSynthesisFeedback(feedbackSignal: 'helpful' | 'not_helpful'): void {
|
||||
if (this.synthesisFeedbackGiven()) return;
|
||||
this.synthesisFeedbackGiven.set(feedbackSignal);
|
||||
this.synthesisFeedbackSubmitted.emit({ signal: feedbackSignal });
|
||||
}
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/bg-BG.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/bg-BG.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "bg-BG", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Ezik",
|
||||
"ui.locale.en_us": "Angliiski (USA)",
|
||||
"ui.locale.de_de": "Nemski (Germania)",
|
||||
"ui.locale.bg_bg": "Balgarski (Balgaria)",
|
||||
"ui.locale.ru_ru": "Ruski (Rusia)",
|
||||
"ui.locale.es_es": "Ispanski (Ispania)",
|
||||
"ui.locale.fr_fr": "Frenski (Francia)",
|
||||
"ui.locale.zh_tw": "Kitaiski tradicionen (Taiwan)",
|
||||
"ui.locale.zh_cn": "Kitaiski oprosten (Kitai)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Ezik",
|
||||
"ui.settings.language.subtitle": "Zadadeite predpochtaniya ezik na konzolata.",
|
||||
"ui.settings.language.description": "Promenite se prilagat vednaga v UI.",
|
||||
"ui.settings.language.selector_label": "Predpochtan ezik",
|
||||
"ui.settings.language.persisted": "Zapazeno za vashiya akaunt i preizpolzvano ot CLI.",
|
||||
"ui.settings.language.persisted_error": "Zapazeno lokalno, no sinkhronizatsiyata na akaunta se provali.",
|
||||
"ui.settings.language.sign_in_hint": "Vlezte v sistema, za da sinkhronizirate tazi nastroika s CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/de-DE.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/de-DE.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "de-DE", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Etwas ist schiefgelaufen.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Speichern",
|
||||
"common.actions.cancel": "Abbrechen",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Sprache",
|
||||
"ui.locale.en_us": "Englisch (USA)",
|
||||
"ui.locale.de_de": "Deutsch (Deutschland)",
|
||||
"ui.locale.bg_bg": "Bulgarisch (Bulgarien)",
|
||||
"ui.locale.ru_ru": "Russisch (Russland)",
|
||||
"ui.locale.es_es": "Spanisch (Spanien)",
|
||||
"ui.locale.fr_fr": "Franzoesisch (Frankreich)",
|
||||
"ui.locale.zh_tw": "Chinesisch (Traditionell, Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinesisch (Vereinfacht, China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Sprache",
|
||||
"ui.settings.language.subtitle": "Legen Sie Ihre bevorzugte Konsolensprache fest.",
|
||||
"ui.settings.language.description": "Aenderungen werden sofort in der UI angewendet.",
|
||||
"ui.settings.language.selector_label": "Bevorzugte Sprache",
|
||||
"ui.settings.language.persisted": "Fuer Ihr Konto gespeichert und im CLI wiederverwendet.",
|
||||
"ui.settings.language.persisted_error": "Lokal gespeichert, aber Kontosynchronisierung fehlgeschlagen.",
|
||||
"ui.settings.language.sign_in_hint": "Melden Sie sich an, um diese Einstellung mit dem CLI zu synchronisieren.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Warten auf erstes Signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Signal konnte nicht geladen werden.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/en-US.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/en-US.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Locale",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese (Traditional, Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese (Simplified, China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Language",
|
||||
"ui.settings.language.subtitle": "Set your preferred console language.",
|
||||
"ui.settings.language.description": "Changes apply immediately in the UI.",
|
||||
"ui.settings.language.selector_label": "Preferred language",
|
||||
"ui.settings.language.persisted": "Saved for your account and reused by CLI.",
|
||||
"ui.settings.language.persisted_error": "Saved locally, but account sync failed.",
|
||||
"ui.settings.language.sign_in_hint": "Sign in to sync this preference with CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/es-ES.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/es-ES.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "es-ES", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Idioma",
|
||||
"ui.locale.en_us": "Ingles (EE. UU.)",
|
||||
"ui.locale.de_de": "Aleman (Alemania)",
|
||||
"ui.locale.bg_bg": "Bulgaro (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Ruso (Rusia)",
|
||||
"ui.locale.es_es": "Espanol (Espana)",
|
||||
"ui.locale.fr_fr": "Frances (Francia)",
|
||||
"ui.locale.zh_tw": "Chino tradicional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chino simplificado (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Idioma",
|
||||
"ui.settings.language.subtitle": "Define tu idioma preferido de la consola.",
|
||||
"ui.settings.language.description": "Los cambios se aplican de inmediato en la UI.",
|
||||
"ui.settings.language.selector_label": "Idioma preferido",
|
||||
"ui.settings.language.persisted": "Guardado para tu cuenta y reutilizado por CLI.",
|
||||
"ui.settings.language.persisted_error": "Guardado localmente, pero fallo la sincronizacion de la cuenta.",
|
||||
"ui.settings.language.sign_in_hint": "Inicia sesion para sincronizar esta preferencia con CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/fr-FR.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/fr-FR.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "fr-FR", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Langue",
|
||||
"ui.locale.en_us": "Anglais (Etats-Unis)",
|
||||
"ui.locale.de_de": "Allemand (Allemagne)",
|
||||
"ui.locale.bg_bg": "Bulgare (Bulgarie)",
|
||||
"ui.locale.ru_ru": "Russe (Russie)",
|
||||
"ui.locale.es_es": "Espagnol (Espagne)",
|
||||
"ui.locale.fr_fr": "Francais (France)",
|
||||
"ui.locale.zh_tw": "Chinois traditionnel (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinois simplifie (Chine)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Langue",
|
||||
"ui.settings.language.subtitle": "Definissez votre langue de console preferee.",
|
||||
"ui.settings.language.description": "Les changements sont appliques immediatement dans l UI.",
|
||||
"ui.settings.language.selector_label": "Langue preferee",
|
||||
"ui.settings.language.persisted": "Enregistre pour votre compte et reutilise par le CLI.",
|
||||
"ui.settings.language.persisted_error": "Enregistre localement, mais la synchronisation du compte a echoue.",
|
||||
"ui.settings.language.sign_in_hint": "Connectez-vous pour synchroniser cette preference avec le CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/ru-RU.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/ru-RU.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "ru-RU", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Yazyk",
|
||||
"ui.locale.en_us": "Angliyskiy (USA)",
|
||||
"ui.locale.de_de": "Nemetskiy (Germaniya)",
|
||||
"ui.locale.bg_bg": "Bolgarskiy (Bolgariya)",
|
||||
"ui.locale.ru_ru": "Russkiy (Rossiya)",
|
||||
"ui.locale.es_es": "Ispanskiy (Ispaniya)",
|
||||
"ui.locale.fr_fr": "Frantsuzskiy (Frantsiya)",
|
||||
"ui.locale.zh_tw": "Kitayskiy tradicionnyy (Taiwan)",
|
||||
"ui.locale.zh_cn": "Kitayskiy uproshchennyy (Kitay)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yazyk",
|
||||
"ui.settings.language.subtitle": "Vyberite predpochtitelnyy yazyk konsoli.",
|
||||
"ui.settings.language.description": "Izmeneniya primenyayutsya srazu v UI.",
|
||||
"ui.settings.language.selector_label": "Predpochtitelnyy yazyk",
|
||||
"ui.settings.language.persisted": "Sohraneno dlya vashego akkaunta i ispolzuetsya v CLI.",
|
||||
"ui.settings.language.persisted_error": "Lokalno sohraneno, no sinkhronizatsiya akkaunta ne udalas.",
|
||||
"ui.settings.language.sign_in_hint": "Vypolnite vkhod, chtoby sinkhronizirovat etu nastroiku s CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/uk-UA.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/uk-UA.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "uk-UA", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Mova",
|
||||
"ui.locale.en_us": "Angliiska (SSHA)",
|
||||
"ui.locale.de_de": "Nimetska (Nimechchyna)",
|
||||
"ui.locale.bg_bg": "Bolgarska (Bolhariia)",
|
||||
"ui.locale.ru_ru": "Rosiiska (Rosiia)",
|
||||
"ui.locale.es_es": "Ispanska (Ispaniia)",
|
||||
"ui.locale.fr_fr": "Frantsuzka (Frantsiia)",
|
||||
"ui.locale.zh_tw": "Kytaiska tradytsiina (Taivan)",
|
||||
"ui.locale.zh_cn": "Kytaiska sproshchena (Kytai)",
|
||||
"ui.locale.uk_ua": "Ukrainska (Ukraina)",
|
||||
"ui.settings.language.title": "Mova",
|
||||
"ui.settings.language.subtitle": "Vstanovit bazhanu movu konsoli.",
|
||||
"ui.settings.language.description": "Zminy zastosovuiutsia v UI odrazu.",
|
||||
"ui.settings.language.selector_label": "Bazhana mova",
|
||||
"ui.settings.language.persisted": "Zberezheno dlia vashoho oblikovoho zapysu ta povtorno vykorystovuietsia v CLI.",
|
||||
"ui.settings.language.persisted_error": "Lokalno zberezheno, ale synkhronizatsiia oblikovoho zapysu ne vdlasia.",
|
||||
"ui.settings.language.sign_in_hint": "Uvijdit, shchob synkhronizuvaty tsiu nalashtunku z CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/zh-CN.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/zh-CN.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-CN", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Language",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese Traditional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese Simplified (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yuyan",
|
||||
"ui.settings.language.subtitle": "Shezhi nin shouxuan de kongzhi tai yuyan.",
|
||||
"ui.settings.language.description": "Genggai hui liji yingyong dao UI.",
|
||||
"ui.settings.language.selector_label": "Shouxuan yuyan",
|
||||
"ui.settings.language.persisted": "Yi baocun dao nin de zhanghu bing zai CLI zhong chongyong.",
|
||||
"ui.settings.language.persisted_error": "Yi ben di baocun, dan zhanghu tongbu shibai.",
|
||||
"ui.settings.language.sign_in_hint": "Qing denglu yi jiang ci pianhao tongbu dao CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
332
src/Web/StellaOps.Web/src/i18n/zh-TW.common.json
Normal file
332
src/Web/StellaOps.Web/src/i18n/zh-TW.common.json
Normal file
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-TW", "description": "Offline fallback bundle for StellaOps Console" },
|
||||
|
||||
"common.error.generic": "Something went wrong.",
|
||||
"common.error.not_found": "The requested resource was not found.",
|
||||
"common.error.unauthorized": "You do not have permission to perform this action.",
|
||||
"common.error.forbidden": "Access denied.",
|
||||
"common.error.bad_request": "The request is invalid.",
|
||||
"common.error.timeout": "Request timed out.",
|
||||
"common.error.server_error": "An internal server error occurred. Please try again later.",
|
||||
"common.error.network": "Network error. Check your connection.",
|
||||
|
||||
"common.validation.required": "This field is required.",
|
||||
"common.validation.invalid": "Invalid value.",
|
||||
"common.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"common.validation.too_short": "Minimum {min} characters required.",
|
||||
|
||||
"common.actions.save": "Save",
|
||||
"common.actions.cancel": "Cancel",
|
||||
"common.actions.delete": "Delete",
|
||||
"common.actions.confirm": "Confirm",
|
||||
"common.actions.submit": "Submit",
|
||||
"common.actions.close": "Close",
|
||||
"common.actions.retry": "Retry",
|
||||
"common.actions.expand": "Expand",
|
||||
"common.actions.collapse": "Collapse",
|
||||
"common.actions.show_more": "Show more",
|
||||
"common.actions.show_less": "Show less",
|
||||
|
||||
"common.status.healthy": "Healthy",
|
||||
"common.status.degraded": "Degraded",
|
||||
"common.status.unavailable": "Unavailable",
|
||||
"common.status.unknown": "Unknown",
|
||||
"common.status.active": "Active",
|
||||
"common.status.inactive": "Inactive",
|
||||
"common.status.pending": "Pending",
|
||||
"common.status.running": "Running",
|
||||
"common.status.completed": "Completed",
|
||||
"common.status.failed": "Failed",
|
||||
"common.status.canceled": "Canceled",
|
||||
"common.status.blocked": "Blocked",
|
||||
|
||||
"common.severity.critical": "Critical",
|
||||
"common.severity.high": "High",
|
||||
"common.severity.medium": "Medium",
|
||||
"common.severity.low": "Low",
|
||||
"common.severity.info": "Info",
|
||||
"common.severity.none": "None",
|
||||
|
||||
"common.time.just_now": "Just now",
|
||||
|
||||
"common.ui.loading": "Loading...",
|
||||
"common.ui.saving": "Saving...",
|
||||
"common.ui.deleting": "Deleting...",
|
||||
"common.ui.submitting": "Submitting...",
|
||||
"common.ui.no_results": "No results found.",
|
||||
"common.ui.offline": "You are offline.",
|
||||
"common.ui.reconnecting": "Reconnecting...",
|
||||
"common.ui.back_online": "Back online.",
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Language",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese Traditional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese Simplified (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yuyan",
|
||||
"ui.settings.language.subtitle": "Shezhi nin pianhao de kongzhi tai yuyan.",
|
||||
"ui.settings.language.description": "Biangeng hui liji shengxiao zai UI.",
|
||||
"ui.settings.language.selector_label": "Pianhao yuyan",
|
||||
"ui.settings.language.persisted": "Yijing baocun dao zhanghu bing gong CLI chongyong.",
|
||||
"ui.settings.language.persisted_error": "Yijing benji baocun, dan zhanghu tongbu shibai.",
|
||||
"ui.settings.language.sign_in_hint": "Qing dengru yi jiang ci pianhao tongbu dao CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-cross-domain.e2e.spec.ts
|
||||
// E2E tests for cross-domain natural language queries (410 cases),
|
||||
// domain filtering, keyboard shortcuts, ambient context, edge cases,
|
||||
// and comprehensive accessibility.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
doctorCard,
|
||||
docsCard,
|
||||
emptyResponse,
|
||||
findingCard,
|
||||
mockSearchApi,
|
||||
mockSearchApiDynamic,
|
||||
policyCard,
|
||||
secretCard,
|
||||
vexCard,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-domain fixtures (multi-domain responses)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const troubleshootDbResponse = buildResponse('cannot connect to database', [
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Postgres Connectivity',
|
||||
snippet: 'Validates TCP/TLS connectivity to the primary Postgres cluster.',
|
||||
score: 0.95,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.pool',
|
||||
title: 'Postgres Connection Pool',
|
||||
snippet: 'Connection pool utilization and health thresholds.',
|
||||
score: 0.80,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'db/local-postgres.md',
|
||||
title: 'Local Postgres Setup',
|
||||
snippet: 'Guide for configuring local PostgreSQL for development.',
|
||||
score: 0.60,
|
||||
}),
|
||||
], {
|
||||
summary: 'Database connectivity issue. Run `stella doctor run check.postgres.*` to diagnose. See Postgres setup docs.',
|
||||
template: 'troubleshoot',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const troubleshootAuthResponse = buildResponse('authentication failed', [
|
||||
doctorCard({
|
||||
checkId: 'check.auth.config',
|
||||
title: 'Auth Configuration',
|
||||
snippet: 'Validates OIDC provider configuration and token service health.',
|
||||
score: 0.92,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.auth.oidc',
|
||||
title: 'OIDC Provider Connectivity',
|
||||
snippet: 'Checks connectivity to the configured OIDC identity provider.',
|
||||
score: 0.85,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/authority/architecture.md',
|
||||
title: 'Authority Architecture',
|
||||
snippet: 'OAuth 2.1 / OIDC authority module with DPoP authentication.',
|
||||
score: 0.55,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootSigningResponse = buildResponse('attestation signing failed HSM timeout', [
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.hsm',
|
||||
title: 'HSM PKCS#11 Availability',
|
||||
snippet: 'HSM module connectivity check — timeout detected.',
|
||||
score: 0.94,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.attestation.keymaterial',
|
||||
title: 'Signing Key Expiration',
|
||||
snippet: 'Checks key material health and expiration status.',
|
||||
score: 0.82,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/signer/architecture.md',
|
||||
title: 'Signer Architecture',
|
||||
snippet: 'Key management, HSM integration, and signing ceremony documentation.',
|
||||
score: 0.60,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootTimestampResponse = buildResponse('TSA endpoint not responding', [
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.availability',
|
||||
title: 'TSA Availability',
|
||||
snippet: 'RFC-3161 TSA endpoint health probe — connection timeout.',
|
||||
score: 0.96,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.failover-ready',
|
||||
title: 'TSA Failover Ready',
|
||||
snippet: 'Checks if failover TSA endpoint is configured and responsive.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const troubleshootIntegrationResponse = buildResponse('OCI registry credentials expired', [
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.credentials',
|
||||
title: 'OCI Registry Credentials',
|
||||
snippet: 'Registry credentials validation — token expired.',
|
||||
score: 0.93,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.registry',
|
||||
title: 'OCI Registry',
|
||||
snippet: 'Non-destructive HEAD probe to verify registry availability.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const howToScanResponse = buildResponse('how to scan a container', [
|
||||
docsCard({
|
||||
docPath: 'quickstart.md',
|
||||
title: 'Quick Start Guide',
|
||||
snippet: 'Step-by-step: scanning your first container image with Stella Ops.',
|
||||
score: 0.90,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/scanner/architecture.md',
|
||||
title: 'Scanner Architecture',
|
||||
snippet: 'Container image scanning pipeline, SBOM generation, and vulnerability detection.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const howToDoctorResponse = buildResponse('how to run doctor checks', [
|
||||
docsCard({
|
||||
docPath: 'modules/doctor/architecture.md',
|
||||
title: 'Doctor Architecture',
|
||||
snippet: 'Health check plugin system with 134+ checks across 18 plugins.',
|
||||
score: 0.88,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Example: Postgres Connectivity',
|
||||
snippet: 'Run: stella doctor run check.postgres.connectivity',
|
||||
score: 0.65,
|
||||
}),
|
||||
]);
|
||||
|
||||
const navigationResponse = buildResponse('open settings', [
|
||||
docsCard({
|
||||
docPath: 'setup/overview',
|
||||
title: 'Setup & Configuration',
|
||||
snippet: 'Navigate to /setup to configure topology, agents, and integrations.',
|
||||
score: 0.85,
|
||||
}),
|
||||
]);
|
||||
|
||||
const cliResponse = buildResponse('stella release create', [
|
||||
docsCard({
|
||||
docPath: 'API_CLI_REFERENCE.md',
|
||||
title: 'CLI Reference: stella release create',
|
||||
snippet: 'Creates a new release version. Usage: stella release create --name v1.2.0 --target production',
|
||||
score: 0.92,
|
||||
}),
|
||||
]);
|
||||
|
||||
const conceptResponse = buildResponse('what is a VEX statement', [
|
||||
docsCard({
|
||||
docPath: 'VEX_CONSENSUS_GUIDE.md',
|
||||
title: 'VEX Consensus Guide',
|
||||
snippet: 'A VEX statement declares whether a product is affected by a specific vulnerability.',
|
||||
score: 0.95,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'GLOSSARY.md',
|
||||
title: 'Glossary: VEX',
|
||||
snippet: 'Vulnerability Exploitability eXchange — a machine-readable format for communicating vulnerability status.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const multiDomainCveResponse = buildResponse('CVE-2024-21626 runc VEX', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626: Container Escape via runc',
|
||||
snippet: 'CRITICAL container escape vulnerability in runc 1.1.10.',
|
||||
severity: 'critical',
|
||||
score: 0.97,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2024-21626 — Not Affected',
|
||||
snippet: 'Vendor VEX: component_not_present in production image.',
|
||||
score: 0.85,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/scanner/architecture.md',
|
||||
title: 'Container Scanning Documentation',
|
||||
snippet: 'Guide for scanning container images and handling runc-related findings.',
|
||||
score: 0.60,
|
||||
}),
|
||||
], {
|
||||
summary: 'CVE-2024-21626: CRITICAL finding with VEX "not_affected" from vendor. 3 results across findings, VEX, and docs.',
|
||||
template: 'cve_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings', 'vex', 'knowledge'],
|
||||
});
|
||||
|
||||
const policyFindingCrossResponse = buildResponse('release blocked by reachable CVE', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 (REACHABLE)',
|
||||
snippet: 'Container escape — reachability confirmed by static + runtime analysis.',
|
||||
severity: 'critical',
|
||||
score: 0.95,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'reachable-cve-gate-block',
|
||||
title: 'ReachableCveGate: BLOCKED',
|
||||
snippet: 'Release promotion blocked. Reachable critical CVE without VEX justification.',
|
||||
score: 0.90,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'under_investigation',
|
||||
title: 'VEX: Under Investigation',
|
||||
snippet: 'No VEX justification available yet. Under investigation by vendor.',
|
||||
score: 0.70,
|
||||
}),
|
||||
], {
|
||||
summary: 'Release BLOCKED: CVE-2024-21626 is reachable, VEX under investigation. ReachableCveGate triggered.',
|
||||
template: 'mixed_overview',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings', 'policy', 'vex'],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Cross-Domain Queries', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.1 Troubleshooting Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Troubleshooting Queries', () => {
|
||||
const dbTroubleshoot = [
|
||||
'cannot connect to database',
|
||||
'database connection failing',
|
||||
'connection pool exhausted',
|
||||
'postgres connectivity lost',
|
||||
'migration failed',
|
||||
'slow query performance',
|
||||
];
|
||||
|
||||
for (const query of dbTroubleshoot) {
|
||||
test(`DB: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const authTroubleshoot = [
|
||||
'authentication failed',
|
||||
'token expired',
|
||||
'error 403 forbidden',
|
||||
'OIDC provider connectivity',
|
||||
];
|
||||
|
||||
for (const query of authTroubleshoot) {
|
||||
test(`Auth: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootAuthResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const signingTroubleshoot = [
|
||||
'signing failed',
|
||||
'attestation signing failed HSM timeout',
|
||||
'HSM not available',
|
||||
'cosign key material',
|
||||
];
|
||||
|
||||
for (const query of signingTroubleshoot) {
|
||||
test(`Signing: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootSigningResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const timestampTroubleshoot = [
|
||||
'TSA endpoint not responding',
|
||||
'TSA response time degraded',
|
||||
'TSA certificate about to expire',
|
||||
'OCSP responder unreachable',
|
||||
'CRL distribution endpoint down',
|
||||
'timestamp token approaching expiry',
|
||||
'system clock not synced NTP',
|
||||
'Rekor time correlation drift',
|
||||
];
|
||||
|
||||
for (const query of timestampTroubleshoot) {
|
||||
test(`TSA: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootTimestampResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const integrationTroubleshoot = [
|
||||
'OCI registry credentials expired',
|
||||
'registry push denied insufficient permissions',
|
||||
'S3 bucket access denied',
|
||||
'Slack API rate limited',
|
||||
'Git provider SSH key rejected',
|
||||
'LDAP bind failed wrong credentials',
|
||||
'secrets manager Vault sealed',
|
||||
];
|
||||
|
||||
for (const query of integrationTroubleshoot) {
|
||||
test(`Integration: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootIntegrationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const agentTroubleshoot = [
|
||||
'agent not responding',
|
||||
'agent version mismatch in cluster',
|
||||
'agent certificate expired',
|
||||
'agent resource utilization critical',
|
||||
'stale agent not reporting',
|
||||
'agent task failure rate above threshold',
|
||||
'cluster health degraded',
|
||||
'quorum lost',
|
||||
];
|
||||
|
||||
for (const query of agentTroubleshoot) {
|
||||
test(`Agent: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const complianceTroubleshoot = [
|
||||
'compliance evidence integrity violation',
|
||||
'provenance chain validation failed',
|
||||
'audit readiness check failed',
|
||||
'evidence generation rate dropped',
|
||||
'export readiness not met',
|
||||
'evidence tampering detected',
|
||||
];
|
||||
|
||||
for (const query of complianceTroubleshoot) {
|
||||
test(`Compliance: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const cryptoTroubleshoot = [
|
||||
'eIDAS compliance check failing',
|
||||
'FIPS module not loaded',
|
||||
'HSM PKCS#11 module unavailable',
|
||||
'GOST crypto provider not found',
|
||||
'SM2/SM3/SM4 provider missing',
|
||||
];
|
||||
|
||||
for (const query of cryptoTroubleshoot) {
|
||||
test(`Crypto: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootSigningResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.2 How-To & Workflow Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('How-To Queries', () => {
|
||||
const howToQueries = [
|
||||
'how to scan a container',
|
||||
'how to create a release',
|
||||
'how to promote to production',
|
||||
'how to triage a finding',
|
||||
'how to suppress a vulnerability',
|
||||
'how to generate SBOM',
|
||||
'how to write a VEX statement',
|
||||
'how to configure notifications',
|
||||
'how to set up policy gates',
|
||||
'how to export evidence',
|
||||
'how to verify attestation',
|
||||
'how to configure air gap mode',
|
||||
'how to rotate signing keys',
|
||||
'how to run doctor checks',
|
||||
'how to create policy exception',
|
||||
'how to investigate reachability',
|
||||
'how to configure Prometheus',
|
||||
'how to set up email alerts',
|
||||
'how to configure escalation',
|
||||
'how to deploy offline',
|
||||
];
|
||||
|
||||
for (const query of howToQueries) {
|
||||
test(`"${query}" returns documentation cards`, async ({ page }) => {
|
||||
const fixture = query.includes('doctor') ? howToDoctorResponse : howToScanResponse;
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.3 Navigation & Feature Discovery
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Navigation Queries', () => {
|
||||
const navQueries = [
|
||||
'open settings',
|
||||
'go to findings',
|
||||
'show dashboard',
|
||||
'open security view',
|
||||
'go to policy gates',
|
||||
'open VEX hub',
|
||||
'show release history',
|
||||
'open agent fleet',
|
||||
'go to evidence center',
|
||||
'open timeline',
|
||||
'go to triage inbox',
|
||||
'open approval queue',
|
||||
'show integrations',
|
||||
'open policy studio',
|
||||
'open doctor diagnostics',
|
||||
'where is the audit log',
|
||||
'find the compliance dashboard',
|
||||
'open mission control',
|
||||
];
|
||||
|
||||
for (const query of navQueries) {
|
||||
test(`nav: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, navigationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.4 CLI Command Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CLI Command Searches', () => {
|
||||
const cliQueries = [
|
||||
'stella release create',
|
||||
'stella release promote',
|
||||
'stella scan graph',
|
||||
'stella policy validate-yaml',
|
||||
'stella policy simulate',
|
||||
'stella doctor run',
|
||||
'stella vex generate',
|
||||
'stella evidence export',
|
||||
'stella attest sign',
|
||||
'stella verify',
|
||||
'stella db migrate',
|
||||
'stella airgap prepare',
|
||||
'stella agent status',
|
||||
'stella crypto keygen',
|
||||
'stella sbom generate',
|
||||
'stella notify test',
|
||||
'stella feeds sync',
|
||||
'stella delta compare',
|
||||
'stella reachability check',
|
||||
'stella auth login',
|
||||
];
|
||||
|
||||
for (const query of cliQueries) {
|
||||
test(`CLI: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, cliResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.5 Concept & Explanation Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Concept Queries', () => {
|
||||
const conceptQueries = [
|
||||
'what is a VEX statement',
|
||||
'explain SBOM',
|
||||
'what is reachability analysis',
|
||||
'explain attestation',
|
||||
'what is a policy gate',
|
||||
'explain risk budget',
|
||||
'what is severity fusion',
|
||||
'what is deterministic replay',
|
||||
'explain provenance',
|
||||
'what is sealed mode',
|
||||
'what is content addressable storage',
|
||||
'explain smart diff',
|
||||
'what is a linkset',
|
||||
'what is the findings ledger',
|
||||
'explain reciprocal rank fusion',
|
||||
'what is a verdict',
|
||||
'explain storm breaker',
|
||||
'what is a dead letter queue',
|
||||
'explain circuit breaker pattern',
|
||||
'what is DPoP authentication',
|
||||
];
|
||||
|
||||
for (const query of conceptQueries) {
|
||||
test(`concept: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, conceptResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7.8 Multi-Domain Operational Queries
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Multi-Domain Operational Queries', () => {
|
||||
const multiDomainQueries = [
|
||||
'CVE-2024-21626 runc escape reachability VEX',
|
||||
'log4j affected not_affected VEX',
|
||||
'how to suppress CVE-2023-44487',
|
||||
'VEX not_affected but policy still blocks',
|
||||
'reachability shows vulnerable code not in execute path',
|
||||
];
|
||||
|
||||
for (const query of multiDomainQueries) {
|
||||
test(`multi-domain: "${query}" renders cards from multiple domains`, async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
}
|
||||
|
||||
test('multi-domain synthesis covers all domains', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 runc VEX');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CVE-2024-21626');
|
||||
});
|
||||
|
||||
const policyFindingQueries = [
|
||||
'release blocked by reachable CVE and no VEX',
|
||||
'policy violation critical CVE-2024-3094',
|
||||
'promote release with blocked findings',
|
||||
'VEX consensus disagreement blocking release',
|
||||
];
|
||||
|
||||
for (const query of policyFindingQueries) {
|
||||
test(`policy+finding: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyFindingCrossResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const operationalQueries = [
|
||||
'SBOM incomplete missing Go dependencies',
|
||||
'binary analysis found crypto weakness',
|
||||
'environment drift detected after deployment',
|
||||
'policy determinism check failed in sealed mode',
|
||||
'feed mirror stale advisory data 7 days old',
|
||||
'CI integration broken OIDC token expired',
|
||||
'scheduler missed nightly scan job',
|
||||
'agent fleet partial quorum during upgrade',
|
||||
'Prometheus not collecting scanner metrics',
|
||||
'trust anchor expired blocking attestation',
|
||||
'evidence staleness exceeding policy TTL',
|
||||
'findings backlog prioritization by EPSS',
|
||||
];
|
||||
|
||||
for (const query of operationalQueries) {
|
||||
test(`operational: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain Filtering Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Domain Filtering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('domain filter chips render for multi-domain results', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 VEX');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
const filters = resultsContainer.locator('[data-role="domain-filter"], .search__filters .search__filter, .search__filter, .chip');
|
||||
const count = await filters.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('clicking domain filter narrows results', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626 VEX');
|
||||
await waitForResults(page);
|
||||
|
||||
const filters = page.locator('.search__filter, [data-role="domain-filter"]');
|
||||
if ((await filters.count()) > 0) {
|
||||
await filters.first().click();
|
||||
// Results should still be visible (filtered view)
|
||||
await expect(page.locator('.search__results')).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard Navigation Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Keyboard Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('ArrowDown moves through entity cards', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
const input = await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
// Navigate down through cards
|
||||
await input.press('ArrowDown');
|
||||
await input.press('ArrowDown');
|
||||
await input.press('ArrowDown');
|
||||
|
||||
// Cards should still be visible
|
||||
await expect(page.locator('app-entity-card').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Escape closes results, second Escape blurs input', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
// First Escape closes results
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.search__results')).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('Enter on selected card triggers primary action', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
const input = await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
await input.press('ArrowDown');
|
||||
|
||||
const navigationPromise = page.waitForURL(/\/security\/(triage|advisories-vex)/, { timeout: 10_000 });
|
||||
await page.keyboard.press('Enter');
|
||||
await navigationPromise;
|
||||
expect(page.url()).toMatch(/\/security\/(triage|advisories-vex)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty State & Edge Cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Edge Cases', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('empty query does not trigger search', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse(''));
|
||||
await waitForShell(page);
|
||||
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
await input.fill('');
|
||||
|
||||
// Short wait — results should NOT appear for empty query
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.search__results')).toBeHidden();
|
||||
});
|
||||
|
||||
test('single character does not trigger search (min 2 chars)', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('a'));
|
||||
await waitForShell(page);
|
||||
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
await input.fill('a');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('.search__results .search__cards')).toBeHidden();
|
||||
});
|
||||
|
||||
test('no results shows empty state message', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('xyznonexistent999'));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'xyznonexistent999');
|
||||
await waitForResults(page);
|
||||
|
||||
const entityCards = page.locator('app-entity-card');
|
||||
await expect(entityCards).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
const noResults = page.locator('.search__results').getByText(/no results/i);
|
||||
await expect(noResults).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('very long query string does not crash UI', async ({ page }) => {
|
||||
const longQuery = 'CVE-2024-21626 '.repeat(50).trim();
|
||||
await mockSearchApi(page, emptyResponse(longQuery));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, longQuery);
|
||||
|
||||
// Should not throw; results container should appear
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.search__results')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('special characters in query are handled safely', async ({ page }) => {
|
||||
const specialQuery = '<script>alert(1)</script> CVE-2024-21626 "test" & | ; $()';
|
||||
await mockSearchApi(page, emptyResponse(specialQuery));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, specialQuery);
|
||||
|
||||
// Should not crash or inject scripts
|
||||
await page.waitForTimeout(500);
|
||||
// Verify page is still functional
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rapid typing triggers debounce (only one API call)', async ({ page }) => {
|
||||
let apiCallCount = 0;
|
||||
await page.route('**/search/query**', (route) => {
|
||||
apiCallCount++;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(emptyResponse('test')),
|
||||
});
|
||||
});
|
||||
|
||||
await waitForShell(page);
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.click();
|
||||
|
||||
// Type rapidly, character by character
|
||||
for (const char of 'CVE-2024-21626') {
|
||||
await input.type(char, { delay: 20 });
|
||||
}
|
||||
|
||||
// Wait for debounce to settle
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should have made fewer calls than characters typed (debounce at 200ms)
|
||||
expect(apiCallCount).toBeLessThan(14);
|
||||
});
|
||||
|
||||
test('API error gracefully handled', async ({ page }) => {
|
||||
await page.route('**/search/query**', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal Server Error' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
|
||||
// Wait and verify the page doesn't crash
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('network timeout gracefully handled', async ({ page }) => {
|
||||
await page.route('**/search/query**', (route) => route.abort('timedout'));
|
||||
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('search input has proper ARIA attributes', async ({ page }) => {
|
||||
await waitForShell(page);
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await expect(input).toHaveAttribute('aria-label', /search/i);
|
||||
await expect(input).toHaveAttribute('aria-autocomplete', 'list');
|
||||
});
|
||||
|
||||
test('entity cards are present in the results list', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const cards = page.locator('.search__cards app-entity-card');
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('synthesis panel has role="region" and aria-label', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel [role="region"]');
|
||||
if ((await synthesis.count()) > 0) {
|
||||
await expect(synthesis).toHaveAttribute('aria-label', /synthesis|summary/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('results listbox has proper ARIA', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
|
||||
const listbox = page.locator('#search-results, [role="listbox"]');
|
||||
await expect(listbox).toBeVisible();
|
||||
});
|
||||
|
||||
test('findings results pass axe-core WCAG 2.0 AA', async ({ page }) => {
|
||||
await mockSearchApi(page, multiDomainCveResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
|
||||
if (serious.length > 0) {
|
||||
console.log(
|
||||
'[a11y] violations:',
|
||||
JSON.stringify(serious.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })), null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
expect(serious, `${serious.length} critical/serious a11y violations`).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('doctor results pass axe-core', async ({ page }) => {
|
||||
await mockSearchApi(page, troubleshootDbResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'database connection');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
expect(serious).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('empty state passes axe-core', async ({ page }) => {
|
||||
await mockSearchApi(page, emptyResponse('nonexistent'));
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'nonexistent');
|
||||
await waitForResults(page);
|
||||
|
||||
const a11y = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const serious = a11y.violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
expect(serious).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-doctor.e2e.spec.ts
|
||||
// E2E tests for Doctor Check domain queries (180 cases from test-cases.md).
|
||||
// Verifies: doctor entity cards, check code rendering, run actions,
|
||||
// synthesis templates, severity badges, and domain filtering.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
doctorCard,
|
||||
docsCard,
|
||||
mockSearchApi,
|
||||
mockSearchApiDynamic,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: doctor response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const postgresCheckResponse = buildResponse('database connection failing', [
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.connectivity',
|
||||
title: 'Postgres Connectivity',
|
||||
snippet: 'Validates TCP/TLS connectivity to the primary Postgres cluster.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.pool',
|
||||
title: 'Postgres Connection Pool',
|
||||
snippet: 'Connection pool utilization is within healthy thresholds.',
|
||||
score: 0.72,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.postgres.migrations',
|
||||
title: 'Postgres Migration Status',
|
||||
snippet: 'All EF Core migrations have been applied successfully.',
|
||||
score: 0.65,
|
||||
}),
|
||||
], {
|
||||
summary: 'Found 3 doctor checks related to database connectivity. Run `stella doctor run check.postgres.*` to diagnose.',
|
||||
template: 'doctor_check',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const timestampingCheckResponse = buildResponse('TSA certificate expiry', [
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.certificate-expiry',
|
||||
title: 'TSA Certificate Expiry',
|
||||
snippet: 'Checks if the TSA signing certificate is approaching expiration.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.chain-valid',
|
||||
title: 'TSA Chain Valid',
|
||||
snippet: 'Validates the full certificate chain from leaf to root CA.',
|
||||
score: 0.78,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.timestamp.tsa.root-expiry',
|
||||
title: 'TSA Root Expiry',
|
||||
snippet: 'Checks if the root CA certificate is approaching expiration.',
|
||||
score: 0.70,
|
||||
}),
|
||||
], {
|
||||
summary: 'Found 3 timestamping certificate checks. TSA infrastructure health is critical for eIDAS compliance.',
|
||||
template: 'doctor_check',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['knowledge'],
|
||||
});
|
||||
|
||||
const integrationCheckResponse = buildResponse('OCI registry connectivity', [
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.registry',
|
||||
title: 'OCI Registry',
|
||||
snippet: 'Non-destructive HEAD probe to verify OCI registry availability.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.referrers',
|
||||
title: 'OCI Registry Referrers API',
|
||||
snippet: 'Checks if the registry supports the OCI Referrers API for artifact linking.',
|
||||
score: 0.75,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.integration.oci.credentials',
|
||||
title: 'OCI Registry Credentials',
|
||||
snippet: 'Validates stored registry credentials have not expired.',
|
||||
score: 0.68,
|
||||
}),
|
||||
]);
|
||||
|
||||
const binaryAnalysisCheckResponse = buildResponse('debuginfod available', [
|
||||
doctorCard({
|
||||
checkId: 'check.binaryanalysis.debuginfod.available',
|
||||
title: 'Debuginfod Availability',
|
||||
snippet: 'Verifies debuginfod server is reachable for symbol resolution.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.binaryanalysis.buildinfo.cache',
|
||||
title: 'Buildinfo Cache',
|
||||
snippet: 'Checks buildinfo cache hit rate and staleness.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const observabilityCheckResponse = buildResponse('OTLP endpoint check', [
|
||||
doctorCard({
|
||||
checkId: 'check.telemetry.otlp.endpoint',
|
||||
title: 'OTLP Endpoint Check',
|
||||
snippet: 'Validates the OpenTelemetry collector endpoint is accepting spans.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.metrics.prometheus.scrape',
|
||||
title: 'Prometheus Scrape Check',
|
||||
snippet: 'Verifies Prometheus scrape target is healthy and returning metrics.',
|
||||
score: 0.70,
|
||||
}),
|
||||
]);
|
||||
|
||||
const complianceCheckResponse = buildResponse('audit readiness check', [
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.audit-readiness',
|
||||
title: 'Audit Readiness',
|
||||
snippet: 'All evidence chains, attestations, and provenance records are complete for audit.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.evidence-integrity',
|
||||
title: 'Evidence Tamper Check',
|
||||
snippet: 'Verifies evidence locker integrity via Merkle tree verification.',
|
||||
score: 0.82,
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.compliance.provenance-completeness',
|
||||
title: 'Provenance Completeness',
|
||||
snippet: 'Checks that all artifacts have complete provenance chains.',
|
||||
score: 0.75,
|
||||
}),
|
||||
]);
|
||||
|
||||
const agentCheckResponse = buildResponse('agent heartbeat freshness', [
|
||||
doctorCard({
|
||||
checkId: 'check.agent.heartbeat.freshness',
|
||||
title: 'Agent Heartbeat Freshness',
|
||||
snippet: 'Checks that all registered agents have reported within the configured interval.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.agent.cluster.quorum',
|
||||
title: 'Agent Cluster Quorum',
|
||||
snippet: 'Validates that the agent cluster has sufficient members for quorum.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const cryptoCheckResponse = buildResponse('FIPS compliance check', [
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.fips',
|
||||
title: 'FIPS 140-2 Compliance',
|
||||
snippet: 'Verifies FIPS-validated cryptographic modules are loaded and active.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.crypto.hsm',
|
||||
title: 'HSM PKCS#11 Availability',
|
||||
snippet: 'Checks HSM module connectivity and signing key availability.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const scannerCheckResponse = buildResponse('scanner queue check', [
|
||||
doctorCard({
|
||||
checkId: 'check.scanner.queue',
|
||||
title: 'Scanner Queue Health',
|
||||
snippet: 'Scanner job queue depth and processing rate are within normal thresholds.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.scanner.resources',
|
||||
title: 'Scanner Resource Utilization',
|
||||
snippet: 'CPU and memory usage for scanner workers are within configured limits.',
|
||||
score: 0.74,
|
||||
}),
|
||||
]);
|
||||
|
||||
const releaseCheckResponse = buildResponse('promotion gates check', [
|
||||
doctorCard({
|
||||
checkId: 'check.release.promotion.gates',
|
||||
title: 'Promotion Gate Health',
|
||||
snippet: 'All promotion gates are correctly configured and responding.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.release.rollback.readiness',
|
||||
title: 'Rollback Readiness',
|
||||
snippet: 'Rollback procedures are verified and ready for execution.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexCheckResponse = buildResponse('VEX schema compliance check', [
|
||||
doctorCard({
|
||||
checkId: 'check.vex.schema',
|
||||
title: 'VEX Schema Compliance',
|
||||
snippet: 'Validates all stored VEX documents conform to their declared schema.',
|
||||
}),
|
||||
doctorCard({
|
||||
checkId: 'check.vex.issuer-trust',
|
||||
title: 'VEX Issuer Trust',
|
||||
snippet: 'Checks that VEX issuer trust tiers are configured and up to date.',
|
||||
score: 0.73,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test suites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Doctor Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.1 Database & Infrastructure Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Database & Infrastructure Checks', () => {
|
||||
const queries = [
|
||||
'check.postgres.connectivity',
|
||||
'database connection failing',
|
||||
'postgres migrations pending',
|
||||
'connection pool exhausted',
|
||||
'disk space running low',
|
||||
'evidence locker write check',
|
||||
'backup directory writable',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns doctor entity cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// At least one card should have doctor-related content
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.2 Security & Auth Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Security & Auth Checks', () => {
|
||||
const queries = [
|
||||
'authentication config check',
|
||||
'OIDC provider connectivity',
|
||||
'signing key health',
|
||||
'token service health',
|
||||
'certificate chain validation',
|
||||
'FIPS compliance check',
|
||||
'HSM availability check',
|
||||
'eIDAS compliance check',
|
||||
'GOST availability check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns doctor cards`, async ({ page }) => {
|
||||
const fixture = query.includes('FIPS') || query.includes('HSM') || query.includes('eIDAS') || query.includes('GOST')
|
||||
? cryptoCheckResponse
|
||||
: postgresCheckResponse;
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.3 Compliance, Agent & Notification Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Compliance, Agent & Notification Checks', () => {
|
||||
const complianceQueries = [
|
||||
'audit readiness check',
|
||||
'evidence integrity check',
|
||||
'provenance completeness',
|
||||
'attestation signing health',
|
||||
'evidence generation rate',
|
||||
'export readiness check',
|
||||
'compliance framework check',
|
||||
];
|
||||
|
||||
for (const query of complianceQueries) {
|
||||
test(`compliance: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, complianceCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const agentQueries = [
|
||||
'agent heartbeat freshness',
|
||||
'agent capacity check',
|
||||
'stale agent detection',
|
||||
'agent cluster health',
|
||||
'agent cluster quorum',
|
||||
'agent version consistency',
|
||||
'agent certificate expiry',
|
||||
'agent task backlog',
|
||||
];
|
||||
|
||||
for (const query of agentQueries) {
|
||||
test(`agent: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, agentCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const notifyQueries = [
|
||||
'email notification check',
|
||||
'Slack connectivity check',
|
||||
'Teams notification check',
|
||||
'notification queue health',
|
||||
'webhook connectivity',
|
||||
];
|
||||
|
||||
for (const query of notifyQueries) {
|
||||
test(`notify: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, integrationCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.4 Environment & Release Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Environment & Release Checks', () => {
|
||||
const queries = [
|
||||
'environment connectivity',
|
||||
'environment drift',
|
||||
'network policy enforcement',
|
||||
'deployment health check',
|
||||
'active release health',
|
||||
'promotion gates check',
|
||||
'rollback readiness',
|
||||
'release schedule check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, releaseCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.5 Timestamping & Certificate Lifecycle Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Timestamping & Certificate Lifecycle Checks', () => {
|
||||
const queries = [
|
||||
'TSA availability check',
|
||||
'TSA response time',
|
||||
'TSA valid response check',
|
||||
'TSA failover ready',
|
||||
'TSA certificate expiry',
|
||||
'TSA root expiry check',
|
||||
'TSA chain validation',
|
||||
'OCSP responder check',
|
||||
'CRL distribution check',
|
||||
'revocation cache freshness',
|
||||
'OCSP stapling enabled',
|
||||
'evidence staleness check',
|
||||
'timestamp approaching expiry',
|
||||
'TST algorithm deprecated',
|
||||
'retimestamp pending',
|
||||
'EU trust list freshness',
|
||||
'QTS providers qualified',
|
||||
'system time synced',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns timestamping doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, timestampingCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.6 Integration & External Connectivity Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Integration & External Connectivity Checks', () => {
|
||||
const queries = [
|
||||
'OCI registry connectivity',
|
||||
'OCI referrers API check',
|
||||
'OCI push authorization',
|
||||
'OCI pull authorization',
|
||||
'OCI registry credentials',
|
||||
'S3 object storage check',
|
||||
'SMTP connectivity check',
|
||||
'Slack webhook check',
|
||||
'Teams webhook check',
|
||||
'Git provider connectivity',
|
||||
'LDAP connectivity check',
|
||||
'CI system connectivity',
|
||||
'secrets manager connectivity',
|
||||
'integration webhook health',
|
||||
'cannot push policy to OCI',
|
||||
'Git provider auth failing',
|
||||
'object storage write failing',
|
||||
'secrets vault unreachable',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns integration doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, integrationCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.7 Binary Analysis & Corpus Health Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Binary Analysis & Corpus Health Checks', () => {
|
||||
const queries = [
|
||||
'debuginfod available',
|
||||
'ddeb repo enabled',
|
||||
'buildinfo cache health',
|
||||
'symbol recovery fallback',
|
||||
'corpus mirror freshness',
|
||||
'corpus KPI baseline exists',
|
||||
'binary analysis not working',
|
||||
'symbol table missing',
|
||||
'debug symbols not found',
|
||||
'buildinfo cache expired',
|
||||
'Go binary stripped no debug',
|
||||
'PE authenticode verification failed',
|
||||
'corpus mirror out of date',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns binary analysis doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.8 Observability, Logging & Operations Deep Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Observability & Operations Checks', () => {
|
||||
const queries = [
|
||||
'OTLP exporter not sending',
|
||||
'log directory not writable',
|
||||
'log rotation not configured',
|
||||
'Prometheus not scraping metrics',
|
||||
'dead letter queue growing',
|
||||
'job queue backlog increasing',
|
||||
'scheduler not processing',
|
||||
'traces not appearing in Jaeger',
|
||||
'metrics endpoint 404',
|
||||
'OpenTelemetry collector down',
|
||||
'dead letter messages accumulating',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns observability doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, observabilityCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3.9 Scanner, Reachability & Storage Deep Checks
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Scanner & Storage Deep Checks', () => {
|
||||
const queries = [
|
||||
'scanner queue backed up',
|
||||
'SBOM generation failing',
|
||||
'vulnerability scan timing out',
|
||||
'witness graph corruption',
|
||||
'slice cache miss rate high',
|
||||
'reachability computation stalled',
|
||||
'scanner resource utilization high',
|
||||
'disk space critical on evidence locker',
|
||||
'evidence locker write failure',
|
||||
'postgres connection pool exhausted',
|
||||
'database migrations not applied',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns scanner/storage doctor cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, scannerCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Doctor-specific rendering verification
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor cards show "Run Check" action button', async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'database connection');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 3);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('Run Check');
|
||||
});
|
||||
|
||||
test('doctor synthesis uses doctor_check template', async ({ page }) => {
|
||||
await mockSearchApi(page, postgresCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'postgres connectivity');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('stella doctor run');
|
||||
});
|
||||
|
||||
test('VEX doctor checks return VEX-specific cards', async ({ page }) => {
|
||||
await mockSearchApi(page, vexCheckResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'VEX schema compliance');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('VEX Schema');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-findings.e2e.spec.ts
|
||||
// E2E tests for Findings domain queries (200 cases from test-cases.md).
|
||||
// Verifies: CVE entity cards, severity badges, PURL extraction, GHSA,
|
||||
// secret detection, reachability, binary analysis, triage workflow.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
docsCard,
|
||||
findingCard,
|
||||
secretCard,
|
||||
mockSearchApi,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures: findings response shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cveSearchResponse = buildResponse('CVE-2024-21626', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626: Container Escape via runc',
|
||||
snippet: 'A container escape vulnerability in runc allows attackers to escape container isolation.',
|
||||
severity: 'critical',
|
||||
score: 0.97,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 in pkg:golang/runc@1.1.10',
|
||||
snippet: 'Affected package: runc 1.1.10. Fixed in 1.1.12.',
|
||||
severity: 'critical',
|
||||
score: 0.90,
|
||||
}),
|
||||
], {
|
||||
summary: 'CVE-2024-21626: CRITICAL container escape in runc. 2 affected packages found. Exploit available.',
|
||||
template: 'cve_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const highSeverityResponse = buildResponse('high severity findings', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-0056',
|
||||
title: 'CVE-2024-0056: .NET SQL Injection',
|
||||
snippet: '.NET data provider allows SQL injection via crafted connection strings.',
|
||||
severity: 'high',
|
||||
score: 0.88,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-38545',
|
||||
title: 'CVE-2023-38545: curl SOCKS5 Overflow',
|
||||
snippet: 'Heap-based buffer overflow in curl SOCKS5 proxy handshake.',
|
||||
severity: 'high',
|
||||
score: 0.85,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-32233',
|
||||
title: 'CVE-2023-32233: Linux kernel nf_tables',
|
||||
snippet: 'Use-after-free in Linux kernel nf_tables allows local privilege escalation.',
|
||||
severity: 'high',
|
||||
score: 0.82,
|
||||
}),
|
||||
]);
|
||||
|
||||
const purlSearchResponse = buildResponse('pkg:npm/lodash@4.17.21', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-23337',
|
||||
title: 'CVE-2021-23337: lodash Command Injection',
|
||||
snippet: 'lodash template function allows command injection via template strings.',
|
||||
severity: 'high',
|
||||
score: 0.91,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2020-28500',
|
||||
title: 'CVE-2020-28500: lodash ReDoS',
|
||||
snippet: 'Regular expression denial of service in lodash.trimEnd.',
|
||||
severity: 'medium',
|
||||
score: 0.78,
|
||||
}),
|
||||
], {
|
||||
summary: 'pkg:npm/lodash@4.17.21: 2 known vulnerabilities. 1 HIGH, 1 MEDIUM.',
|
||||
template: 'package_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const ghsaSearchResponse = buildResponse('GHSA-xxxx-yyyy-zzzz', [
|
||||
findingCard({
|
||||
cveId: 'GHSA-xxxx-yyyy-zzzz',
|
||||
title: 'GHSA-xxxx-yyyy-zzzz: Remote Code Execution',
|
||||
snippet: 'GitHub Security Advisory for remote code execution vulnerability.',
|
||||
severity: 'critical',
|
||||
score: 0.93,
|
||||
}),
|
||||
]);
|
||||
|
||||
const secretDetectionResponse = buildResponse('AWS access key exposed', [
|
||||
secretCard({
|
||||
title: 'AWS Access Key Exposed',
|
||||
snippet: 'AWS Access Key ID AKIA*** detected in src/config/settings.ts:42',
|
||||
severity: 'critical',
|
||||
score: 0.96,
|
||||
}),
|
||||
secretCard({
|
||||
title: 'AWS Secret Access Key',
|
||||
snippet: 'AWS Secret Access Key detected adjacent to Access Key ID.',
|
||||
severity: 'critical',
|
||||
score: 0.94,
|
||||
}),
|
||||
], {
|
||||
summary: 'CRITICAL: 2 AWS credential detections. Immediate key rotation required.',
|
||||
template: 'secret_detection',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const reachabilityResponse = buildResponse('reachable CVE findings', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 (REACHABLE)',
|
||||
snippet: 'Static + runtime analysis confirms vulnerable code path is reachable from container entrypoint.',
|
||||
severity: 'critical',
|
||||
score: 0.98,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'CVE-2023-44487 (REACHABLE)',
|
||||
snippet: 'HTTP/2 Rapid Reset — runtime OTel trace confirms active HTTP/2 usage.',
|
||||
severity: 'high',
|
||||
score: 0.90,
|
||||
}),
|
||||
], {
|
||||
summary: '2 REACHABLE findings. Both confirmed by runtime observation. Prioritize remediation.',
|
||||
template: 'reachability_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const binaryAnalysisResponse = buildResponse('stripped Go binary vulnerability', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-24790',
|
||||
title: 'CVE-2024-24790 in Go binary (stripped)',
|
||||
snippet: 'net/netip ParseAddr vulnerability detected in stripped Go binary via symbol reconstruction.',
|
||||
severity: 'high',
|
||||
score: 0.84,
|
||||
}),
|
||||
]);
|
||||
|
||||
const triageWorkflowResponse = buildResponse('findings in active triage', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
title: 'CVE-2024-21626 — Triage: ACTIVE',
|
||||
snippet: 'Container escape vulnerability under active investigation. Assigned to security team.',
|
||||
severity: 'critical',
|
||||
score: 0.95,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
title: 'CVE-2023-44487 — Triage: BLOCKED',
|
||||
snippet: 'HTTP/2 Rapid Reset — blocking shipment until VEX or patch applied.',
|
||||
severity: 'high',
|
||||
score: 0.88,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'CVE-2021-44228 — Triage: MUTED (VEX)',
|
||||
snippet: 'Log4Shell muted by authoritative vendor VEX: component_not_present.',
|
||||
severity: 'critical',
|
||||
score: 0.80,
|
||||
}),
|
||||
], {
|
||||
summary: '3 findings in triage: 1 ACTIVE, 1 BLOCKED, 1 MUTED. Review blocked findings for release gate.',
|
||||
template: 'triage_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings'],
|
||||
});
|
||||
|
||||
const cweSearchResponse = buildResponse('SQL injection vulnerability', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2024-0056',
|
||||
title: 'CVE-2024-0056: SQL Injection (CWE-89)',
|
||||
snippet: 'SQL injection via .NET data provider connection string manipulation.',
|
||||
severity: 'high',
|
||||
}),
|
||||
]);
|
||||
|
||||
const namedVulnResponse = buildResponse('Log4Shell', [
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
title: 'Log4Shell (CVE-2021-44228)',
|
||||
snippet: 'Remote code execution in Apache Log4j 2.x via JNDI lookup injection.',
|
||||
severity: 'critical',
|
||||
score: 0.99,
|
||||
}),
|
||||
findingCard({
|
||||
cveId: 'CVE-2021-45046',
|
||||
title: 'Log4j followup (CVE-2021-45046)',
|
||||
snippet: 'Incomplete fix for CVE-2021-44228 in certain non-default configurations.',
|
||||
severity: 'critical',
|
||||
score: 0.85,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Findings Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.1 CVE Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CVE Searches', () => {
|
||||
const cveQueries = [
|
||||
'CVE-2024-21626',
|
||||
'CVE-2024-3094',
|
||||
'CVE-2023-44487',
|
||||
'CVE-2021-44228',
|
||||
'CVE-2024-6387',
|
||||
'CVE-2023-4863',
|
||||
'CVE-2024-0056',
|
||||
'CVE-2023-38545',
|
||||
];
|
||||
|
||||
for (const query of cveQueries) {
|
||||
test(`"${query}" returns finding entity cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, cveSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
|
||||
const cards = await waitForEntityCards(page);
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Cards should show in findings domain
|
||||
const firstCard = cards.first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('CVE card shows severity badge', async ({ page }) => {
|
||||
await mockSearchApi(page, cveSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'CVE-2024-21626');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 2);
|
||||
|
||||
// Look for severity indicator
|
||||
const resultsText = await page.locator('.search__results').textContent();
|
||||
expect(resultsText?.toLowerCase()).toContain('critical');
|
||||
});
|
||||
|
||||
const namedVulnQueries = [
|
||||
'Log4Shell',
|
||||
'Heartbleed',
|
||||
'Spring4Shell',
|
||||
'Shellshock',
|
||||
'POODLE',
|
||||
];
|
||||
|
||||
for (const query of namedVulnQueries) {
|
||||
test(`named vulnerability: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, namedVulnResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CVE by severity / CVSS / EPSS
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Severity & Score Searches', () => {
|
||||
const queries = [
|
||||
'critical vulnerabilities',
|
||||
'high severity findings',
|
||||
'CVSS score 9.8',
|
||||
'CVSS greater than 7',
|
||||
'exploit available',
|
||||
'zero day vulnerability',
|
||||
'EPSS score high',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns severity-filtered findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, highSeverityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CWE-based searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('CWE & Weakness Type Searches', () => {
|
||||
const cweQueries = [
|
||||
'remote code execution',
|
||||
'SQL injection vulnerability',
|
||||
'buffer overflow',
|
||||
'cross site scripting',
|
||||
'privilege escalation',
|
||||
'denial of service',
|
||||
'path traversal',
|
||||
'deserialization vulnerability',
|
||||
'SSRF vulnerability',
|
||||
'use after free',
|
||||
'null pointer dereference',
|
||||
'race condition',
|
||||
];
|
||||
|
||||
for (const query of cweQueries) {
|
||||
test(`"${query}" returns CWE-typed findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, cweSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.2 PURL & Package Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('PURL & Package Searches', () => {
|
||||
const purlQueries = [
|
||||
'pkg:npm/lodash@4.17.21',
|
||||
'pkg:maven/org.apache.logging.log4j/log4j-core@2.17.0',
|
||||
'pkg:pypi/django@4.2',
|
||||
'pkg:cargo/tokio@1.28',
|
||||
'pkg:golang/github.com/opencontainers/runc@1.1.10',
|
||||
'pkg:nuget/Newtonsoft.Json@13.0.3',
|
||||
'pkg:gem/actionpack@7.0',
|
||||
'pkg:npm/express@4.18',
|
||||
];
|
||||
|
||||
for (const query of purlQueries) {
|
||||
test(`PURL: "${query}" returns package findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('PURL search shows package_summary synthesis', async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'pkg:npm/lodash@4.17.21');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('lodash');
|
||||
});
|
||||
|
||||
const packageNameQueries = [
|
||||
'npm lodash vulnerability',
|
||||
'jackson-databind CVE',
|
||||
'spring framework vulnerability',
|
||||
'golang net/http vulnerability',
|
||||
'python requests vulnerability',
|
||||
'docker runc vulnerability',
|
||||
'kubernetes vulnerability',
|
||||
'nginx CVE',
|
||||
'postgresql vulnerability',
|
||||
'redis vulnerability',
|
||||
];
|
||||
|
||||
for (const query of packageNameQueries) {
|
||||
test(`package name: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, purlSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.3 GHSA & Source Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('GHSA & Advisory Source Searches', () => {
|
||||
const queries = [
|
||||
'GHSA-xxxx-yyyy-zzzz',
|
||||
'GitHub advisory',
|
||||
'NVD advisory',
|
||||
'CISA advisory',
|
||||
'Red Hat security advisory',
|
||||
'Debian security advisory',
|
||||
'Ubuntu security notice',
|
||||
'advisories published today',
|
||||
'recently discovered vulnerabilities',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns advisory source findings`, async ({ page }) => {
|
||||
await mockSearchApi(page, ghsaSearchResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.4 Secret Detection & Credential Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Secret Detection Searches', () => {
|
||||
const secretQueries = [
|
||||
'AWS access key exposed',
|
||||
'GitHub personal access token',
|
||||
'private SSH key in repository',
|
||||
'database password hardcoded',
|
||||
'Slack webhook URL leaked',
|
||||
'Azure connection string exposed',
|
||||
'Docker registry credentials',
|
||||
'JWT secret key in code',
|
||||
'Stripe API key leaked',
|
||||
'Google Cloud service account key',
|
||||
'npm auth token',
|
||||
'PKCS#12 certificate with private key',
|
||||
'environment file with secrets',
|
||||
'Terraform state with credentials',
|
||||
'Kubernetes secret in YAML',
|
||||
'PGP private key committed',
|
||||
'OAuth client secret exposed',
|
||||
'encryption key in code',
|
||||
'all secret detections this week',
|
||||
];
|
||||
|
||||
for (const query of secretQueries) {
|
||||
test(`secret: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, secretDetectionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('secret detection synthesis shows rotation warning', async ({ page }) => {
|
||||
await mockSearchApi(page, secretDetectionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'AWS access key');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CRITICAL');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.5 Reachability & Runtime Analysis Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Reachability Searches', () => {
|
||||
const reachQueries = [
|
||||
'reachable CVE findings',
|
||||
'unreachable vulnerabilities',
|
||||
'conditional reachability',
|
||||
'unknown reachability status',
|
||||
'static path analysis',
|
||||
'runtime hit confirmed',
|
||||
'runtime sink hit',
|
||||
'static analysis confirmed by runtime',
|
||||
'OTel trace confirms vulnerable path',
|
||||
'hot symbol detected at runtime',
|
||||
'vulnerable function in execute path',
|
||||
'no callstack to vulnerable code',
|
||||
'entry point to sink path',
|
||||
'reachability proof document',
|
||||
];
|
||||
|
||||
for (const query of reachQueries) {
|
||||
test(`reachability: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, reachabilityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('reachability synthesis confirms runtime observation', async ({ page }) => {
|
||||
await mockSearchApi(page, reachabilityResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'reachable CVE');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('REACHABLE');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.6 Binary & Crypto Analysis Findings
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Binary & Crypto Analysis Searches', () => {
|
||||
const binaryQueries = [
|
||||
'stripped Go binary vulnerability',
|
||||
'Mach-O binary CVE',
|
||||
'Windows PE vulnerability',
|
||||
'Authenticode signature invalid',
|
||||
'native library vulnerability',
|
||||
'statically linked vulnerable code',
|
||||
'shared library CVE',
|
||||
'musl libc vulnerability',
|
||||
'glibc vulnerability',
|
||||
];
|
||||
|
||||
for (const query of binaryQueries) {
|
||||
test(`binary: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const cryptoQueries = [
|
||||
'weak cipher algorithm detected',
|
||||
'deprecated TLS version',
|
||||
'insecure hash function MD5',
|
||||
'SHA1 deprecation warning',
|
||||
'RSA key too short',
|
||||
'self-signed certificate in production',
|
||||
'certificate about to expire',
|
||||
];
|
||||
|
||||
for (const query of cryptoQueries) {
|
||||
test(`crypto: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, binaryAnalysisResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4.7 Triage Workflow & Status Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Triage Workflow Searches', () => {
|
||||
const triageQueries = [
|
||||
'findings in active triage',
|
||||
'blocked shipment findings',
|
||||
'findings needing exception',
|
||||
'muted by reachability',
|
||||
'muted by VEX status',
|
||||
'compensated findings',
|
||||
'ship verdict findings',
|
||||
'block verdict findings',
|
||||
'exception granted findings',
|
||||
'pending scan results',
|
||||
'running scans',
|
||||
'failed scan results',
|
||||
'findings without evidence',
|
||||
'unresolved findings older than 30 days',
|
||||
'findings blocking production release',
|
||||
];
|
||||
|
||||
for (const query of triageQueries) {
|
||||
test(`triage: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, triageWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('triage synthesis shows lane summary', async ({ page }) => {
|
||||
await mockSearchApi(page, triageWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'findings in active triage');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('ACTIVE');
|
||||
});
|
||||
});
|
||||
});
|
||||
485
src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts
Normal file
485
src/Web/StellaOps.Web/tests/e2e/unified-search-fixtures.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-fixtures.ts
|
||||
// Shared mock data & helpers for all unified search e2e test suites.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth / config fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read advisory:search advisory:read search:read findings:read vex:read policy:read health:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://policy.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
gateway: 'https://gateway.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
export const oidcConfig = {
|
||||
issuer: mockConfig.authority.issuer,
|
||||
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
|
||||
token_endpoint: mockConfig.authority.tokenEndpoint,
|
||||
jwks_uri: 'https://authority.local/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
};
|
||||
|
||||
export const shellSession = {
|
||||
...policyAuthorSession,
|
||||
scopes: [
|
||||
...new Set([
|
||||
...policyAuthorSession.scopes,
|
||||
'ui.read',
|
||||
'admin',
|
||||
'advisory:search',
|
||||
'advisory:read',
|
||||
'search:read',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
'policy:read',
|
||||
'health:read',
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page setup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setupBasicMocks(page: Page) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = message.text();
|
||||
if (text.includes('status of 404') || text.includes('HttpErrorResponse')) return;
|
||||
console.log('[browser:error]', text);
|
||||
});
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
await page.route('**/platform/envsettings.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/.well-known/openid-configuration')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(oidcConfig),
|
||||
});
|
||||
}
|
||||
if (url.includes('/.well-known/jwks.json')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ keys: [] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('authorize')) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.fulfill({ status: 400, body: 'blocked' });
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupAuthenticatedSession(page: Page) {
|
||||
await page.addInitScript((stubSession) => {
|
||||
(window as any).__stellaopsTestSession = stubSession;
|
||||
}, shellSession);
|
||||
}
|
||||
|
||||
export async function waitForShell(page: Page) {
|
||||
await page.goto('/');
|
||||
await page.locator('aside.sidebar').waitFor({ state: 'visible', timeout: 15_000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search interaction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Intercepts unified search and replies with the given fixture. */
|
||||
export async function mockSearchApi(page: Page, responseBody: unknown) {
|
||||
await page.route('**/search/query**', (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responseBody),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts unified search and replies with a fixture chosen at
|
||||
* runtime based on `q`/`query` from JSON body or URL params. This allows a single test
|
||||
* to issue multiple different queries and receive different mock responses.
|
||||
*/
|
||||
export async function mockSearchApiDynamic(
|
||||
page: Page,
|
||||
responseMap: Record<string, unknown>,
|
||||
fallback?: unknown,
|
||||
) {
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
try {
|
||||
const request = route.request();
|
||||
let q = '';
|
||||
|
||||
const rawPostData = request.postData();
|
||||
if (rawPostData) {
|
||||
try {
|
||||
const body = JSON.parse(rawPostData);
|
||||
q = String(body.q ?? body.query ?? '');
|
||||
} catch {
|
||||
q = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
const url = new URL(request.url());
|
||||
q = String(url.searchParams.get('q') ?? url.searchParams.get('query') ?? '');
|
||||
}
|
||||
|
||||
const key = Object.keys(responseMap).find((k) => q.toLowerCase().includes(k.toLowerCase()));
|
||||
const payload = key ? responseMap[key] : (fallback ?? emptyResponse(q));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(fallback ?? emptyResponse('')),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function typeInSearch(page: Page, query: string) {
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
await input.focus();
|
||||
await input.fill(query);
|
||||
return input;
|
||||
}
|
||||
|
||||
export async function waitForResults(page: Page) {
|
||||
await page.locator('.search__results').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
const loading = page.locator('.search__loading');
|
||||
if ((await loading.count()) > 0) {
|
||||
await loading.first().waitFor({ state: 'hidden', timeout: 8_000 }).catch(() => {
|
||||
// Ignore timeout here; some result states intentionally do not render loading.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForEntityCards(page: Page, count?: number) {
|
||||
const cards = page.locator(
|
||||
'.search__cards app-entity-card:visible, [role="list"] [role="listitem"]:visible, [role="listbox"] [role="option"]:visible',
|
||||
);
|
||||
const waitTimeoutMs = 8_000;
|
||||
const waitForCards = async () => {
|
||||
if (count !== undefined) {
|
||||
await cards.nth(count - 1).waitFor({ state: 'visible', timeout: waitTimeoutMs });
|
||||
} else {
|
||||
await cards.first().waitFor({ state: 'visible', timeout: waitTimeoutMs });
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await waitForCards();
|
||||
} catch (firstError) {
|
||||
// Retry once by re-triggering the current query input in case the
|
||||
// debounced search call was dropped under long stress runs.
|
||||
try {
|
||||
const input = page.locator('app-global-search input[type="text"]');
|
||||
if ((await input.count()) > 0) {
|
||||
const searchInput = input.first();
|
||||
const current = await searchInput.inputValue();
|
||||
if (current.trim().length >= 2) {
|
||||
await searchInput.focus();
|
||||
await searchInput.fill(current);
|
||||
await searchInput.press(' ');
|
||||
await searchInput.press('Backspace');
|
||||
await searchInput.press('Enter').catch(() => {
|
||||
// Some environments do not bind Enter on the input.
|
||||
});
|
||||
await waitForResults(page);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore retry interaction errors and still attempt one more card wait.
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForCards();
|
||||
} catch {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock response builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CardFixture {
|
||||
entityKey: string;
|
||||
entityType: string;
|
||||
domain: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
severity?: string;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
actionType: string;
|
||||
route?: string;
|
||||
command?: string;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
sources: string[];
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildResponse(
|
||||
query: string,
|
||||
cards: CardFixture[],
|
||||
synthesis?: {
|
||||
summary: string;
|
||||
template: string;
|
||||
confidence: string;
|
||||
sourceCount: number;
|
||||
domainsCovered: string[];
|
||||
},
|
||||
) {
|
||||
return {
|
||||
query,
|
||||
topK: 10,
|
||||
cards,
|
||||
synthesis: synthesis ?? {
|
||||
summary: `Found ${cards.length} result(s) for "${query}".`,
|
||||
template: cards.length > 0 ? 'mixed_overview' : 'empty',
|
||||
confidence: cards.length >= 3 ? 'high' : cards.length >= 1 ? 'medium' : 'low',
|
||||
sourceCount: cards.length,
|
||||
domainsCovered: [...new Set(cards.map((c) => c.domain))],
|
||||
},
|
||||
diagnostics: {
|
||||
ftsMatches: cards.length + 2,
|
||||
vectorMatches: cards.length,
|
||||
entityCardCount: cards.length,
|
||||
durationMs: 38,
|
||||
usedVector: true,
|
||||
mode: 'hybrid',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyResponse(query: string) {
|
||||
return buildResponse(query, [], {
|
||||
summary: 'No results found for the given query.',
|
||||
template: 'empty',
|
||||
confidence: 'high',
|
||||
sourceCount: 0,
|
||||
domainsCovered: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain-specific card factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function doctorCard(opts: {
|
||||
checkId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `doctor:${opts.checkId}`,
|
||||
entityType: 'doctor',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.85,
|
||||
actions: [
|
||||
{ label: 'Run Check', actionType: 'run', command: `stella doctor run ${opts.checkId}`, isPrimary: true },
|
||||
{ label: 'View Docs', actionType: 'navigate', route: '/ops/operations/data-integrity', isPrimary: false },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
|
||||
export function findingCard(opts: {
|
||||
cveId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `cve:${opts.cveId}`,
|
||||
entityType: 'finding',
|
||||
domain: 'findings',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.92,
|
||||
severity: opts.severity,
|
||||
actions: [
|
||||
{ label: 'View Finding', actionType: 'navigate', route: `/security/triage?q=${opts.cveId}`, isPrimary: true },
|
||||
{ label: 'Copy CVE', actionType: 'copy', command: opts.cveId, isPrimary: false },
|
||||
],
|
||||
sources: ['findings'],
|
||||
};
|
||||
}
|
||||
|
||||
export function secretCard(opts: {
|
||||
title: string;
|
||||
snippet: string;
|
||||
severity: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `secret:${opts.title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
entityType: 'finding',
|
||||
domain: 'findings',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.88,
|
||||
severity: opts.severity,
|
||||
actions: [
|
||||
{ label: 'View Secret', actionType: 'navigate', route: '/security/triage?type=secret', isPrimary: true },
|
||||
],
|
||||
sources: ['findings'],
|
||||
};
|
||||
}
|
||||
|
||||
export function vexCard(opts: {
|
||||
cveId: string;
|
||||
status: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `vex:${opts.cveId}`,
|
||||
entityType: 'vex_statement',
|
||||
domain: 'vex',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.80,
|
||||
actions: [
|
||||
{
|
||||
label: 'View VEX',
|
||||
actionType: 'navigate',
|
||||
route: `/security/advisories-vex?q=${opts.cveId}`,
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
sources: ['vex'],
|
||||
metadata: { status: opts.status },
|
||||
};
|
||||
}
|
||||
|
||||
export function policyCard(opts: {
|
||||
ruleId: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `policy:${opts.ruleId}`,
|
||||
entityType: 'policy_rule',
|
||||
domain: 'policy',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.78,
|
||||
actions: [
|
||||
{
|
||||
label: 'View Rule',
|
||||
actionType: 'navigate',
|
||||
route: `/ops/policy?rule=${opts.ruleId}`,
|
||||
isPrimary: true,
|
||||
},
|
||||
{ label: 'Simulate', actionType: 'navigate', route: `/ops/policy/simulate`, isPrimary: false },
|
||||
],
|
||||
sources: ['policy'],
|
||||
};
|
||||
}
|
||||
|
||||
export function docsCard(opts: {
|
||||
docPath: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `docs:${opts.docPath}`,
|
||||
entityType: 'docs',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.70,
|
||||
actions: [
|
||||
{ label: 'Open', actionType: 'navigate', route: `/docs/${opts.docPath}`, isPrimary: true },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
|
||||
export function apiCard(opts: {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score?: number;
|
||||
}): CardFixture {
|
||||
return {
|
||||
entityKey: `api:${opts.method}:${opts.endpoint}`,
|
||||
entityType: 'api',
|
||||
domain: 'knowledge',
|
||||
title: opts.title,
|
||||
snippet: opts.snippet,
|
||||
score: opts.score ?? 0.75,
|
||||
actions: [
|
||||
{ label: 'Try API', actionType: 'curl', command: `curl -X ${opts.method} ${opts.endpoint}`, isPrimary: true },
|
||||
{ label: 'Open Docs', actionType: 'navigate', route: `/docs/api`, isPrimary: false },
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search-vex-policy.e2e.spec.ts
|
||||
// E2E tests for VEX (100 cases) and Policy (100 cases) domain queries.
|
||||
// Verifies: VEX status/justification/trust/consensus cards, policy gate/verdict
|
||||
// cards, risk budget, sealed mode, unknowns, observation states.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildResponse,
|
||||
docsCard,
|
||||
findingCard,
|
||||
policyCard,
|
||||
vexCard,
|
||||
mockSearchApi,
|
||||
setupAuthenticatedSession,
|
||||
setupBasicMocks,
|
||||
typeInSearch,
|
||||
waitForEntityCards,
|
||||
waitForResults,
|
||||
waitForShell,
|
||||
} from './unified-search-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VEX Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const vexStatusResponse = buildResponse('VEX not affected', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2024-21626 — Not Affected',
|
||||
snippet: 'Vendor confirms: vulnerable code not present in this build. Justification: component_not_present.',
|
||||
score: 0.92,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2023-44487',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: CVE-2023-44487 — Not Affected',
|
||||
snippet: 'HTTP/2 Rapid Reset not exploitable: vulnerable_code_not_in_execute_path.',
|
||||
score: 0.85,
|
||||
}),
|
||||
], {
|
||||
summary: '2 VEX statements with status "not_affected". Both with verified justifications.',
|
||||
template: 'vex_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexAffectedResponse = buildResponse('VEX affected', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'affected',
|
||||
title: 'VEX: CVE-2024-3094 — Affected',
|
||||
snippet: 'XZ Utils backdoor confirmed affected. Upgrade to xz 5.6.1-2 required.',
|
||||
score: 0.95,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexFixedResponse = buildResponse('VEX fixed', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2021-44228',
|
||||
status: 'fixed',
|
||||
title: 'VEX: CVE-2021-44228 — Fixed',
|
||||
snippet: 'Log4Shell fixed in log4j-core 2.17.1. All deployments updated.',
|
||||
score: 0.88,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexUnderInvestigationResponse = buildResponse('VEX under investigation', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-6387',
|
||||
status: 'under_investigation',
|
||||
title: 'VEX: CVE-2024-6387 — Under Investigation',
|
||||
snippet: 'OpenSSH regreSSHion applicability being assessed across fleet.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexJustificationResponse = buildResponse('vulnerable code not present', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: vulnerable_code_not_present',
|
||||
snippet: 'Justification: The vulnerable code path in runc was removed in our fork.',
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2023-4863',
|
||||
status: 'not_affected',
|
||||
title: 'VEX: vulnerable_code_not_in_execute_path',
|
||||
snippet: 'Justification: libwebp is linked but image decoding disabled at build time.',
|
||||
score: 0.78,
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexTrustResponse = buildResponse('authoritative VEX source', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX from Docker Inc. (Authoritative)',
|
||||
snippet: 'Trust tier: AUTHORITATIVE. Vendor PSIRT issued. DSSE signed. Verified.',
|
||||
score: 0.96,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'VEX from Community (Trusted)',
|
||||
snippet: 'Trust tier: TRUSTED. Community analysis. PGP signed. Verified.',
|
||||
score: 0.72,
|
||||
}),
|
||||
], {
|
||||
summary: '2 VEX sources for CVE-2024-21626. Authoritative vendor source agrees with community. High confidence.',
|
||||
template: 'vex_trust',
|
||||
confidence: 'high',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexConflictResponse = buildResponse('VEX consensus conflict', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'affected',
|
||||
title: 'VEX Conflict: Vendor says AFFECTED',
|
||||
snippet: 'Vendor PSIRT: affected. Trust tier: Authoritative.',
|
||||
score: 0.90,
|
||||
}),
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-3094',
|
||||
status: 'not_affected',
|
||||
title: 'VEX Conflict: Community says NOT_AFFECTED',
|
||||
snippet: 'Community analysis: not_affected. Trust tier: Trusted. Justification: component_not_present.',
|
||||
score: 0.75,
|
||||
}),
|
||||
], {
|
||||
summary: 'HARD CONFLICT: Vendor (affected) vs Community (not_affected) for CVE-2024-3094. Manual review required.',
|
||||
template: 'vex_conflict',
|
||||
confidence: 'low',
|
||||
sourceCount: 2,
|
||||
domainsCovered: ['vex'],
|
||||
});
|
||||
|
||||
const vexFormatResponse = buildResponse('OpenVEX document', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'OpenVEX: CVE-2024-21626',
|
||||
snippet: 'Format: OpenVEX v0.2. DSSE-signed. Schema validated.',
|
||||
}),
|
||||
]);
|
||||
|
||||
const vexWorkflowResponse = buildResponse('generate VEX document', [
|
||||
vexCard({
|
||||
cveId: 'CVE-2024-21626',
|
||||
status: 'not_affected',
|
||||
title: 'Generated VEX for CVE-2024-21626',
|
||||
snippet: 'Run: stella vex generate --cve CVE-2024-21626 --status not_affected',
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'VEX_CONSENSUS_GUIDE.md',
|
||||
title: 'VEX Consensus Guide',
|
||||
snippet: 'Guide for creating and managing VEX documents in Stella Ops.',
|
||||
score: 0.65,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const policyManagementResponse = buildResponse('create policy rule', [
|
||||
policyCard({
|
||||
ruleId: 'severity-gate-critical',
|
||||
title: 'Block Critical Vulnerabilities',
|
||||
snippet: 'Blocks release promotion when CRITICAL severity findings are present without VEX.',
|
||||
score: 0.90,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'sbom-attestation-required',
|
||||
title: 'Require SBOM Attestation',
|
||||
snippet: 'Requires signed SBOM attestation predicate before promotion to production.',
|
||||
score: 0.82,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'modules/policy/architecture.md',
|
||||
title: 'Policy Engine Architecture',
|
||||
snippet: 'Policy rules, packs, gates, and evaluation pipeline documentation.',
|
||||
score: 0.65,
|
||||
}),
|
||||
], {
|
||||
summary: '2 policy rules and 1 documentation result. Use Policy Studio to create or edit rules.',
|
||||
template: 'policy_rule',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['policy', 'knowledge'],
|
||||
});
|
||||
|
||||
const policyGateResponse = buildResponse('VEX trust gate evaluation', [
|
||||
policyCard({
|
||||
ruleId: 'vex-trust-gate',
|
||||
title: 'VEX Trust Gate',
|
||||
snippet: 'Validates VEX source trust tier meets minimum threshold before accepting as evidence.',
|
||||
score: 0.92,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'reachable-cve-gate',
|
||||
title: 'Reachable CVE Gate',
|
||||
snippet: 'Blocks if CVE is reachable and VEX status is "affected" or missing.',
|
||||
score: 0.85,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'execution-evidence-gate',
|
||||
title: 'Execution Evidence Gate',
|
||||
snippet: 'Requires signed execution evidence (SEE-03) for all promoted artifacts.',
|
||||
score: 0.80,
|
||||
}),
|
||||
], {
|
||||
summary: '3 policy gates found. VexTrustGate, ReachableCveGate, and ExecutionEvidenceGate.',
|
||||
template: 'policy_gate',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['policy'],
|
||||
});
|
||||
|
||||
const policyVerdictResponse = buildResponse('policy verdict blocked', [
|
||||
policyCard({
|
||||
ruleId: 'verdict-block-cve-2024',
|
||||
title: 'Verdict: BLOCKED — CVE-2024-21626',
|
||||
snippet: 'ReachableCveGate blocked promotion. Finding is reachable with no VEX justification.',
|
||||
score: 0.95,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'verdict-guarded-cve-2023',
|
||||
title: 'Verdict: GUARDED PASS — CVE-2023-44487',
|
||||
snippet: 'GuardedPass with runtime monitoring. Beacon verification rate: 94%.',
|
||||
score: 0.82,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyRiskBudgetResponse = buildResponse('risk budget remaining', [
|
||||
policyCard({
|
||||
ruleId: 'risk-budget-project-alpha',
|
||||
title: 'Risk Budget: Project Alpha',
|
||||
snippet: 'Budget: 45/100 points consumed. 55 remaining. Burn rate: 3.2 points/week.',
|
||||
score: 0.88,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'unknowns-budget',
|
||||
title: 'Unknowns Budget',
|
||||
snippet: 'Unknowns: 12/20 allowed. U-RCH: 5, U-VEX: 3, U-FEED: 2, U-PROV: 2.',
|
||||
score: 0.80,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyObservationResponse = buildResponse('observation pending determinization', [
|
||||
policyCard({
|
||||
ruleId: 'obs-pending-det',
|
||||
title: 'Observation: Pending Determinization',
|
||||
snippet: 'CVE discovered but evidence incomplete. Guardrail-based evaluation active.',
|
||||
score: 0.85,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'obs-disputed',
|
||||
title: 'Observation: Disputed',
|
||||
snippet: 'Multiple signals conflict. Runtime says reachable, static says unreachable. Human review required.',
|
||||
score: 0.78,
|
||||
}),
|
||||
policyCard({
|
||||
ruleId: 'obs-stale',
|
||||
title: 'Observation: Stale Requires Refresh',
|
||||
snippet: 'Evidence decayed past TTL threshold. Auto-triggered refresh in progress.',
|
||||
score: 0.72,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policySealedModeResponse = buildResponse('sealed mode locked dependencies', [
|
||||
policyCard({
|
||||
ruleId: 'sealed-mode-config',
|
||||
title: 'Sealed Mode Configuration',
|
||||
snippet: 'Dependencies locked, evidence frozen, no external network calls during evaluation.',
|
||||
score: 0.90,
|
||||
}),
|
||||
docsCard({
|
||||
docPath: 'contracts/sealed-mode.md',
|
||||
title: 'Sealed Mode Contract',
|
||||
snippet: 'Deterministic, offline-first evaluation mode for air-gap environments.',
|
||||
score: 0.75,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyGateLevelResponse = buildResponse('G3 high risk gate level', [
|
||||
policyCard({
|
||||
ruleId: 'g3-high-risk',
|
||||
title: 'G3 — High Risk Gate',
|
||||
snippet: 'Requires: security scan, migration plan, load checks, observability updates, release captain sign-off.',
|
||||
score: 0.88,
|
||||
}),
|
||||
]);
|
||||
|
||||
const policyDecisionResponse = buildResponse('why was release blocked', [
|
||||
policyCard({
|
||||
ruleId: 'decision-audit-release-42',
|
||||
title: 'Release #42 Decision Audit',
|
||||
snippet: 'BLOCKED by ReachableCveGate: CVE-2024-21626 is reachable. No VEX. No exception. Risk budget exceeded.',
|
||||
score: 0.95,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VEX Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — VEX Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.1 VEX Status & Justification Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Status Searches', () => {
|
||||
const statusTests: Array<{ query: string; fixture: unknown }> = [
|
||||
{ query: 'VEX not affected', fixture: vexStatusResponse },
|
||||
{ query: 'VEX affected', fixture: vexAffectedResponse },
|
||||
{ query: 'VEX fixed', fixture: vexFixedResponse },
|
||||
{ query: 'VEX under investigation', fixture: vexUnderInvestigationResponse },
|
||||
];
|
||||
|
||||
for (const { query, fixture } of statusTests) {
|
||||
test(`"${query}" returns VEX cards with correct status`, async ({ page }) => {
|
||||
await mockSearchApi(page, fixture);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
const cards = await waitForEntityCards(page);
|
||||
expect(await cards.count()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
}
|
||||
|
||||
const justificationQueries = [
|
||||
'component not present justification',
|
||||
'vulnerable code not present',
|
||||
'code not in execute path',
|
||||
'code not executable',
|
||||
'adversary cannot control code',
|
||||
'inline mitigations exist',
|
||||
];
|
||||
|
||||
for (const query of justificationQueries) {
|
||||
test(`justification: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexJustificationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const generalVexQueries = [
|
||||
'VEX for CVE-2024-21626',
|
||||
'VEX for log4j',
|
||||
'VEX from vendor',
|
||||
'VEX from community',
|
||||
'trusted VEX statements',
|
||||
'authoritative VEX',
|
||||
'VEX impact statement',
|
||||
'VEX action required',
|
||||
'VEX expiring soon',
|
||||
'VEX override',
|
||||
];
|
||||
|
||||
for (const query of generalVexQueries) {
|
||||
test(`general: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexStatusResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.2 VEX Format & Workflow
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Format & Workflow', () => {
|
||||
const formatQueries = [
|
||||
'OpenVEX document',
|
||||
'CSAF VEX document',
|
||||
'CycloneDX VEX',
|
||||
'StellaOps canonical VEX',
|
||||
'VEX from SPDX format',
|
||||
];
|
||||
|
||||
for (const query of formatQueries) {
|
||||
test(`format: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexFormatResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const workflowQueries = [
|
||||
'generate VEX document',
|
||||
'ingest VEX statement',
|
||||
'VEX hub search',
|
||||
'VEX studio create',
|
||||
'export VEX bundle',
|
||||
'VEX consensus handling',
|
||||
'VEX linked to finding',
|
||||
'VEX suppresses finding',
|
||||
'VEX as evidence',
|
||||
'VEX attestation',
|
||||
'VEX policy evaluation',
|
||||
'VEX mirror',
|
||||
'VEX feed subscription',
|
||||
'VEX document lifecycle',
|
||||
];
|
||||
|
||||
for (const query of workflowQueries) {
|
||||
test(`workflow: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexWorkflowResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.3 VEX Trust, Signature & Freshness
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Trust & Signature', () => {
|
||||
const trustQueries = [
|
||||
'authoritative VEX source',
|
||||
'trusted community VEX',
|
||||
'untrusted VEX statement',
|
||||
'vendor PSIRT VEX',
|
||||
'distributor VEX statement',
|
||||
'community VEX source',
|
||||
'internal organization VEX',
|
||||
'aggregator VEX source',
|
||||
'VEX with high trust score',
|
||||
'VEX issuer reputation',
|
||||
];
|
||||
|
||||
for (const query of trustQueries) {
|
||||
test(`trust: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexTrustResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const signatureQueries = [
|
||||
'DSSE signed VEX document',
|
||||
'cosign verified VEX',
|
||||
'PGP signed VEX statement',
|
||||
'X.509 signed VEX document',
|
||||
'unverified VEX signature',
|
||||
'failed VEX signature verification',
|
||||
];
|
||||
|
||||
for (const query of signatureQueries) {
|
||||
test(`signature: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexTrustResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const freshnessQueries = [
|
||||
'VEX freshness stale',
|
||||
'VEX freshness expired',
|
||||
'VEX superseded by newer',
|
||||
'fresh VEX statements only',
|
||||
'VEX document age over 90 days',
|
||||
];
|
||||
|
||||
for (const query of freshnessQueries) {
|
||||
test(`freshness: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexStatusResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5.4 VEX Consensus & Conflict
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('VEX Consensus & Conflict', () => {
|
||||
const conflictQueries = [
|
||||
'VEX consensus conflict',
|
||||
'hard conflict between VEX sources',
|
||||
'soft conflict VEX disagreement',
|
||||
'vendor says not_affected community says affected',
|
||||
'VEX consensus engine result',
|
||||
'trust-weighted VEX merge',
|
||||
'VEX confidence score low',
|
||||
'VEX confidence high agreement',
|
||||
'multiple issuers same CVE',
|
||||
];
|
||||
|
||||
for (const query of conflictQueries) {
|
||||
test(`conflict: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('VEX conflict synthesis shows low confidence', async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'VEX consensus conflict');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
const text = await synthesis.textContent();
|
||||
expect(text).toContain('CONFLICT');
|
||||
});
|
||||
|
||||
const transitionQueries = [
|
||||
'VEX status transition history',
|
||||
'affected changed to not_affected',
|
||||
'under_investigation resolved to fixed',
|
||||
'VEX linked to SBOM component',
|
||||
'VEX for CPE product match',
|
||||
'VEX suppressing active finding',
|
||||
'VEX impact on policy gate',
|
||||
'VEX used as evidence in release',
|
||||
'VEX document schema validation failure',
|
||||
];
|
||||
|
||||
for (const query of transitionQueries) {
|
||||
test(`transition: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, vexConflictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search — Policy Domain', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.1 Policy Management Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Policy Management', () => {
|
||||
const queries = [
|
||||
'create policy rule',
|
||||
'policy pack install',
|
||||
'validate policy YAML',
|
||||
'policy simulation',
|
||||
'push policy to OCI',
|
||||
'pull policy from registry',
|
||||
'policy pack bundle',
|
||||
'block critical vulnerabilities',
|
||||
'require SBOM attestation',
|
||||
'require VEX for all CVEs',
|
||||
'maximum CVSS score allowed',
|
||||
'block exploit available',
|
||||
'require reachability proof',
|
||||
'policy for production environment',
|
||||
'policy exception request',
|
||||
'policy waiver',
|
||||
'risk budget remaining',
|
||||
'policy violation list',
|
||||
'policy determinism verification',
|
||||
'policy lint check',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`"${query}" returns policy cards`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyManagementResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.2 Policy Evaluation & Decisioning
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Policy Evaluation', () => {
|
||||
const queries = [
|
||||
'evaluate policy for container',
|
||||
'policy APPROVE decision',
|
||||
'policy REJECT decision',
|
||||
'conditional approval',
|
||||
'blocked by policy',
|
||||
'override policy violation',
|
||||
'severity fusion scoring',
|
||||
'attestation report for release',
|
||||
'promotion gate evaluation',
|
||||
'policy snapshot comparison',
|
||||
];
|
||||
|
||||
for (const query of queries) {
|
||||
test(`evaluation: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyVerdictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('policy decision shows audit detail', async ({ page }) => {
|
||||
await mockSearchApi(page, policyDecisionResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'why was release blocked');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.3 Gate-Level Evaluation & Verdict Searches
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Gate-Level Evaluation', () => {
|
||||
const gateQueries = [
|
||||
'VEX trust gate evaluation',
|
||||
'reachable CVE gate blocked',
|
||||
'execution evidence gate result',
|
||||
'beacon rate gate threshold',
|
||||
'drift gate unreviewed changes',
|
||||
'unknowns gate budget exceeded',
|
||||
];
|
||||
|
||||
for (const query of gateQueries) {
|
||||
test(`gate: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyGateResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const verdictQueries = [
|
||||
'policy verdict pass',
|
||||
'policy verdict guarded pass',
|
||||
'policy verdict blocked',
|
||||
'policy verdict ignored',
|
||||
'policy verdict warned',
|
||||
'policy verdict deferred',
|
||||
'policy verdict escalated',
|
||||
'policy verdict requires VEX',
|
||||
];
|
||||
|
||||
for (const query of verdictQueries) {
|
||||
test(`verdict: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyVerdictResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const gateLevelQueries = [
|
||||
'G0 no-risk gate level',
|
||||
'G1 low risk gate level',
|
||||
'G2 moderate risk gate level',
|
||||
'G3 high risk gate level',
|
||||
'G4 safety critical gate level',
|
||||
];
|
||||
|
||||
for (const query of gateLevelQueries) {
|
||||
test(`gate level: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyGateLevelResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6.4 Risk Budget, Unknowns & Sealed Mode
|
||||
// -------------------------------------------------------------------------
|
||||
test.describe('Risk Budget & Unknowns', () => {
|
||||
const budgetQueries = [
|
||||
'risk budget remaining for project',
|
||||
'risk budget burn rate',
|
||||
'unknowns budget exceeded',
|
||||
];
|
||||
|
||||
for (const query of budgetQueries) {
|
||||
test(`budget: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const unknownCodeQueries = [
|
||||
'unknown reachability reason',
|
||||
'unknown identity ambiguous package',
|
||||
'unknown provenance cannot map binary',
|
||||
'VEX conflict unknown',
|
||||
'feed gap unknown source missing',
|
||||
'config unknown feature not observable',
|
||||
'analyzer limit language not supported',
|
||||
];
|
||||
|
||||
for (const query of unknownCodeQueries) {
|
||||
test(`unknown code: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const observationQueries = [
|
||||
'observation pending determinization',
|
||||
'observation determined',
|
||||
'observation disputed',
|
||||
'observation stale requires refresh',
|
||||
'observation manual review required',
|
||||
'observation suppressed',
|
||||
];
|
||||
|
||||
for (const query of observationQueries) {
|
||||
test(`observation: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyObservationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const sealedModeQueries = [
|
||||
'sealed mode locked dependencies',
|
||||
'sealed mode frozen evidence',
|
||||
'deterministic replay manifest',
|
||||
'no external network during evaluation',
|
||||
];
|
||||
|
||||
for (const query of sealedModeQueries) {
|
||||
test(`sealed mode: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policySealedModeResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
|
||||
const tierQueries = [
|
||||
'uncertainty tier T1',
|
||||
'uncertainty tier T2',
|
||||
'uncertainty tier T3',
|
||||
'uncertainty tier T4',
|
||||
];
|
||||
|
||||
for (const query of tierQueries) {
|
||||
test(`uncertainty: "${query}"`, async ({ page }) => {
|
||||
await mockSearchApi(page, policyObservationResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, query);
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Policy-specific rendering
|
||||
// -------------------------------------------------------------------------
|
||||
test('policy cards show "Simulate" action', async ({ page }) => {
|
||||
await mockSearchApi(page, policyManagementResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'block critical vulnerabilities');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page);
|
||||
|
||||
const allText = await page.locator('.search__results').textContent();
|
||||
expect(allText).toContain('Simulate');
|
||||
});
|
||||
|
||||
test('risk budget synthesis shows budget numbers', async ({ page }) => {
|
||||
await mockSearchApi(page, policyRiskBudgetResponse);
|
||||
await waitForShell(page);
|
||||
await typeInSearch(page, 'risk budget remaining');
|
||||
await waitForResults(page);
|
||||
|
||||
const synthesis = page.locator('app-synthesis-panel');
|
||||
await expect(synthesis).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
493
src/Web/StellaOps.Web/tests/e2e/unified-search.e2e.spec.ts
Normal file
493
src/Web/StellaOps.Web/tests/e2e/unified-search.e2e.spec.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// unified-search.e2e.spec.ts
|
||||
// E2E tests for the Unified Search feature.
|
||||
// Tests search input, entity cards, domain filters, keyboard navigation,
|
||||
// synthesis panel, empty state, and accessibility.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
import { policyAuthorSession } from '../../src/app/testing';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'https://authority.local',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
||||
tokenEndpoint: 'https://authority.local/connect/token',
|
||||
logoutEndpoint: 'https://authority.local/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read advisory:search advisory:read search:read findings:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: 'https://authority.local',
|
||||
scanner: 'https://scanner.local',
|
||||
policy: 'https://policy.local',
|
||||
concelier: 'https://concelier.local',
|
||||
attestor: 'https://attestor.local',
|
||||
gateway: 'https://gateway.local',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
const oidcConfig = {
|
||||
issuer: mockConfig.authority.issuer,
|
||||
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
|
||||
token_endpoint: mockConfig.authority.tokenEndpoint,
|
||||
jwks_uri: 'https://authority.local/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
};
|
||||
|
||||
const shellSession = {
|
||||
...policyAuthorSession,
|
||||
scopes: [
|
||||
...new Set([
|
||||
...policyAuthorSession.scopes,
|
||||
'ui.read',
|
||||
'admin',
|
||||
'advisory:search',
|
||||
'advisory:read',
|
||||
'search:read',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock API response fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const searchResultsResponse = {
|
||||
query: 'CVE-2024-21626',
|
||||
topK: 10,
|
||||
cards: [
|
||||
{
|
||||
entityKey: 'cve:CVE-2024-21626',
|
||||
entityType: 'finding',
|
||||
domain: 'findings',
|
||||
title: 'CVE-2024-21626: Container Escape via runc',
|
||||
snippet: 'A container escape vulnerability in runc allows...',
|
||||
score: 0.95,
|
||||
severity: 'critical',
|
||||
actions: [
|
||||
{
|
||||
label: 'View Finding',
|
||||
actionType: 'navigate',
|
||||
route: '/security/triage?q=CVE-2024-21626',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
label: 'Copy CVE',
|
||||
actionType: 'copy',
|
||||
command: 'CVE-2024-21626',
|
||||
isPrimary: false,
|
||||
},
|
||||
],
|
||||
sources: ['findings'],
|
||||
},
|
||||
{
|
||||
entityKey: 'vex:CVE-2024-21626',
|
||||
entityType: 'vex_statement',
|
||||
domain: 'vex',
|
||||
title: 'VEX: CVE-2024-21626 - Not Affected',
|
||||
snippet: 'Product not affected by CVE-2024-21626...',
|
||||
score: 0.82,
|
||||
actions: [
|
||||
{
|
||||
label: 'View VEX',
|
||||
actionType: 'navigate',
|
||||
route: '/security/advisories-vex?q=CVE-2024-21626',
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
sources: ['vex'],
|
||||
},
|
||||
{
|
||||
entityKey: 'docs:container-deployment',
|
||||
entityType: 'docs',
|
||||
domain: 'knowledge',
|
||||
title: 'Container Deployment Guide',
|
||||
snippet: 'Guide for deploying containers securely...',
|
||||
score: 0.65,
|
||||
actions: [
|
||||
{
|
||||
label: 'Open',
|
||||
actionType: 'navigate',
|
||||
route: '/docs/deploy.md#overview',
|
||||
isPrimary: true,
|
||||
},
|
||||
],
|
||||
sources: ['knowledge'],
|
||||
},
|
||||
],
|
||||
synthesis: {
|
||||
summary:
|
||||
'Results for CVE-2024-21626: 1 finding, 1 VEX statement, 1 knowledge result. CRITICAL severity finding detected.',
|
||||
template: 'cve_summary',
|
||||
confidence: 'high',
|
||||
sourceCount: 3,
|
||||
domainsCovered: ['findings', 'vex', 'knowledge'],
|
||||
},
|
||||
diagnostics: {
|
||||
ftsMatches: 5,
|
||||
vectorMatches: 3,
|
||||
entityCardCount: 3,
|
||||
durationMs: 42,
|
||||
usedVector: true,
|
||||
mode: 'hybrid',
|
||||
},
|
||||
};
|
||||
|
||||
const emptySearchResponse = {
|
||||
query: 'xyznonexistent999',
|
||||
topK: 10,
|
||||
cards: [],
|
||||
synthesis: {
|
||||
summary: 'No results found for the given query.',
|
||||
template: 'empty',
|
||||
confidence: 'high',
|
||||
sourceCount: 0,
|
||||
domainsCovered: [],
|
||||
},
|
||||
diagnostics: {
|
||||
ftsMatches: 0,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 0,
|
||||
durationMs: 5,
|
||||
usedVector: true,
|
||||
mode: 'hybrid',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setupBasicMocks(page: Page) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
console.log('[browser:error]', message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
await page.route('**/platform/envsettings.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('https://authority.local/**', (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes('/.well-known/openid-configuration')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(oidcConfig),
|
||||
});
|
||||
}
|
||||
if (url.includes('/.well-known/jwks.json')) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ keys: [] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('authorize')) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.fulfill({ status: 400, body: 'blocked' });
|
||||
});
|
||||
}
|
||||
|
||||
async function setupAuthenticatedSession(page: Page) {
|
||||
await page.addInitScript((stubSession) => {
|
||||
(window as any).__stellaopsTestSession = stubSession;
|
||||
}, shellSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts POST requests to the unified search endpoint and replies with
|
||||
* the provided fixture payload.
|
||||
*/
|
||||
async function mockSearchApi(page: Page, responseBody: unknown) {
|
||||
await page.route('**/search/query**', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(responseBody),
|
||||
});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the global search input and types a query.
|
||||
* Returns the search input locator for further assertions.
|
||||
*/
|
||||
async function typeInSearchInput(page: Page, query: string) {
|
||||
const searchInput = page.locator('app-global-search input[type="text"]');
|
||||
await searchInput.focus();
|
||||
await searchInput.fill(query);
|
||||
return searchInput;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Unified Search', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupBasicMocks(page);
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Search input is visible and focusable
|
||||
// -------------------------------------------------------------------------
|
||||
test('search input is visible and focusable', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const searchInput = page.locator('app-global-search input[type="text"]');
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await expect(searchInput).toHaveAttribute('placeholder', /search everything/i);
|
||||
|
||||
await searchInput.focus();
|
||||
await expect(searchInput).toBeFocused();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Typing a query shows entity cards
|
||||
// -------------------------------------------------------------------------
|
||||
test('typing a query shows entity cards with correct titles', async ({ page }) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify entity cards render
|
||||
const entityCards = page.locator('app-entity-card');
|
||||
await expect(entityCards).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Verify card titles match the mock data
|
||||
const cardTitles = await entityCards.allTextContents();
|
||||
const combinedText = cardTitles.join(' ');
|
||||
expect(combinedText).toContain('CVE-2024-21626');
|
||||
expect(combinedText).toContain('Container Escape via runc');
|
||||
expect(combinedText).toContain('VEX');
|
||||
expect(combinedText).toContain('Container Deployment Guide');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Domain filter chips work
|
||||
// -------------------------------------------------------------------------
|
||||
test('domain filter chips are present and clickable', async ({ page }) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Domain filter chips should be rendered (findings, vex, knowledge)
|
||||
const filterChips = resultsContainer.locator('[data-role="domain-filter"], .search__filters .search__filter, .search__filter, .chip');
|
||||
const chipCount = await filterChips.count();
|
||||
expect(chipCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Click the first chip and verify it toggles (gets an active/selected class)
|
||||
const firstChip = filterChips.first();
|
||||
await firstChip.click();
|
||||
|
||||
// After clicking a filter chip, results should still be visible
|
||||
// (the component filters client-side or re-fetches)
|
||||
await expect(resultsContainer).toBeVisible();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Keyboard navigation
|
||||
// -------------------------------------------------------------------------
|
||||
test('keyboard navigation: ArrowDown moves selection, Escape closes results', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const searchInput = await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Press ArrowDown to move selection to the first entity card
|
||||
await searchInput.press('ArrowDown');
|
||||
|
||||
// First card should receive a visual active/selected indicator
|
||||
const firstCard = page.locator('app-entity-card').first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
|
||||
// Press ArrowDown again to move to the second card
|
||||
await searchInput.press('ArrowDown');
|
||||
|
||||
// Press Escape to close the results panel
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(resultsContainer).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('keyboard navigation: Enter on selected card triggers primary action', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const searchInput = await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Navigate to the first card
|
||||
await searchInput.press('ArrowDown');
|
||||
|
||||
const initialUrl = page.url();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Enter should trigger the selected card primary route.
|
||||
await expect.poll(() => page.url(), { timeout: 10_000 }).not.toBe(initialUrl);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Synthesis panel renders
|
||||
// -------------------------------------------------------------------------
|
||||
test('synthesis panel renders summary text after search', async ({ page }) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Synthesis panel should render
|
||||
const synthesisPanel = page.locator('app-synthesis-panel');
|
||||
await expect(synthesisPanel).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify the synthesis summary text is present
|
||||
const synthesisText = await synthesisPanel.textContent();
|
||||
expect(synthesisText).toContain('CVE-2024-21626');
|
||||
expect(synthesisText).toContain('CRITICAL');
|
||||
|
||||
// Verify domain coverage indicators
|
||||
expect(synthesisText).toContain('finding');
|
||||
expect(synthesisText).toContain('VEX');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Empty state
|
||||
// -------------------------------------------------------------------------
|
||||
test('empty state shows "No results" message when API returns zero cards', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockSearchApi(page, emptySearchResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearchInput(page, 'xyznonexistent999');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// No entity cards should be rendered
|
||||
const entityCards = page.locator('app-entity-card');
|
||||
await expect(entityCards).toHaveCount(0, { timeout: 5_000 });
|
||||
|
||||
// A "No results" or empty state message should be visible
|
||||
const noResultsText = resultsContainer.getByText(/no results/i);
|
||||
await expect(noResultsText).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Accessibility (axe-core)
|
||||
// -------------------------------------------------------------------------
|
||||
test('search results pass axe-core accessibility checks', async ({ page }) => {
|
||||
await mockSearchApi(page, searchResultsResponse);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearchInput(page, 'CVE-2024-21626');
|
||||
|
||||
const resultsContainer = page.locator('.search__results');
|
||||
await expect(resultsContainer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for entity cards to be fully rendered before scanning
|
||||
await expect(page.locator('app-entity-card')).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Run axe-core against the search results region
|
||||
const a11yResults = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.search')
|
||||
.analyze();
|
||||
|
||||
const violations = a11yResults.violations;
|
||||
|
||||
// Log violations for debugging but allow the test to surface issues
|
||||
if (violations.length > 0) {
|
||||
console.log(
|
||||
'[a11y] Unified search violations:',
|
||||
JSON.stringify(
|
||||
violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
description: v.description,
|
||||
nodes: v.nodes.length,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fail on serious or critical a11y violations
|
||||
const serious = violations.filter(
|
||||
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||
);
|
||||
expect(
|
||||
serious,
|
||||
`Expected no critical/serious a11y violations but found ${serious.length}: ${serious.map((v) => v.id).join(', ')}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user