search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

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

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&times;</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();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.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/>');
}
}

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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