texts fixes, search bar fixes, global menu fixes.
This commit is contained in:
BIN
src/Web/StellaOps.Web/output/playwright/header-search-repro.png
Normal file
BIN
src/Web/StellaOps.Web/output/playwright/header-search-repro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -0,0 +1,101 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'ui.read',
|
||||
'policy:read',
|
||||
'policy:author',
|
||||
'policy:simulate',
|
||||
'advisory-ai:view',
|
||||
'advisory-ai:operate',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
'admin',
|
||||
],
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--no-proxy-server'],
|
||||
});
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
const navHistory = [];
|
||||
const httpErrors = [];
|
||||
const failures = [];
|
||||
let currentUrl = '';
|
||||
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame !== page.mainFrame()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = `${new Date().toISOString()} ${frame.url()}`;
|
||||
navHistory.push(entry);
|
||||
console.log('[nav]', entry);
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = response.request();
|
||||
const entry = `${response.status()} ${request.method()} ${response.url()}`;
|
||||
httpErrors.push(entry);
|
||||
console.log('[http-error]', entry);
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const entry = `${request.method()} ${request.url()} :: ${request.failure()?.errorText ?? 'failed'}`;
|
||||
failures.push(entry);
|
||||
console.log('[requestfailed]', entry);
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
console.log('[console-error]', msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const target = process.argv[2] ?? 'https://stella-ops.local/';
|
||||
console.log('[goto]', target);
|
||||
|
||||
try {
|
||||
await page.goto(target, { waitUntil: 'commit', timeout: 20000 });
|
||||
} catch (error) {
|
||||
console.log('[goto-error]', error.message);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const url = page.url();
|
||||
if (url !== currentUrl) {
|
||||
currentUrl = url;
|
||||
console.log('[url-change]', url);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const searchInputCount = await page
|
||||
.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length)
|
||||
.catch(() => -1);
|
||||
|
||||
console.log('[final-url]', page.url());
|
||||
console.log('[title]', await page.title().catch(() => '<title unavailable>'));
|
||||
console.log('[search-input-count]', searchInputCount);
|
||||
console.log('[nav-count]', navHistory.length);
|
||||
console.log('[http-error-count]', httpErrors.length);
|
||||
console.log('[failed-request-count]', failures.length);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/stella-ops-local-load-check-viewport.png' });
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: ['ui.read', 'policy:read', 'policy:author', 'policy:simulate', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', 'admin'],
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('/search')) {
|
||||
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.includes('/api/v1/search/query') ||
|
||||
url.includes('/api/v1/advisory-ai/search') ||
|
||||
url.includes('/api/v1/advisory-ai/search/analytics')
|
||||
) {
|
||||
const req = response.request();
|
||||
console.log('[response]', req.method(), response.status(), url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const url = process.argv[2] || 'https://127.1.0.1/';
|
||||
console.log('[goto]', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
|
||||
console.log('[search-input-count]', count);
|
||||
|
||||
if (count === 0) {
|
||||
console.log('[page-url]', page.url());
|
||||
console.log('[title]', await page.title());
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
|
||||
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
|
||||
const emptyText = await page.locator('.search__empty').allTextContents();
|
||||
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
|
||||
console.log('[entity-cards]', results);
|
||||
console.log('[empty-text]', emptyText.join(' | '));
|
||||
console.log('[degraded-banner]', degradedVisible);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-live.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: ['ui.read','policy:read','policy:author','policy:simulate','advisory:search','advisory:read','search:read','findings:read','vex:read','admin']
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('/search')) {
|
||||
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.includes('/api/v1/search/query') ||
|
||||
url.includes('/api/v1/advisory-ai/search') ||
|
||||
url.includes('/api/v1/advisory-ai/search/analytics')
|
||||
) {
|
||||
const req = response.request();
|
||||
console.log('[response]', req.method(), response.status(), url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const url = process.argv[2] || 'https://127.1.0.1:10000/';
|
||||
console.log('[goto]', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
|
||||
console.log('[search-input-count]', count);
|
||||
|
||||
if (count === 0) {
|
||||
console.log('[page-url]', page.url());
|
||||
console.log('[title]', await page.title());
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
|
||||
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
|
||||
const emptyText = await page.locator('.search__empty').allTextContents();
|
||||
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
|
||||
console.log('[entity-cards]', results);
|
||||
console.log('[empty-text]', emptyText.join(' | '));
|
||||
console.log('[degraded-banner]', degradedVisible);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -101,7 +101,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly readBaseUrl = '/api/v2/releases';
|
||||
private readonly legacyBaseUrl = '/api/release-orchestrator/releases';
|
||||
private readonly legacyBaseUrl = '/api/v1/releases';
|
||||
|
||||
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> {
|
||||
const page = Math.max(1, filter?.page ?? 1);
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('requireConfigGuard', () => {
|
||||
expect((result as UrlTree).toString()).toBe('/setup');
|
||||
});
|
||||
|
||||
it('should redirect to /setup/wizard when config loaded but setup is absent', () => {
|
||||
it('should redirect to /setup-wizard/wizard when config loaded but setup is absent', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
|
||||
Object.defineProperty(configService, 'config', {
|
||||
get: () => ({ ...minimalConfig, setup: undefined }),
|
||||
@@ -70,10 +70,10 @@ describe('requireConfigGuard', () => {
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toBe('/setup/wizard');
|
||||
expect((result as UrlTree).toString()).toBe('/setup-wizard/wizard');
|
||||
});
|
||||
|
||||
it('should redirect to /setup/wizard?resume=migrations when setup is a step ID', () => {
|
||||
it('should redirect to /setup-wizard/wizard?resume=migrations when setup is a step ID', () => {
|
||||
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
|
||||
Object.defineProperty(configService, 'config', {
|
||||
get: () => ({ ...minimalConfig, setup: 'migrations' }),
|
||||
@@ -82,7 +82,7 @@ describe('requireConfigGuard', () => {
|
||||
const result = runGuard();
|
||||
|
||||
expect(result).toBeInstanceOf(UrlTree);
|
||||
expect((result as UrlTree).toString()).toContain('/setup/wizard');
|
||||
expect((result as UrlTree).toString()).toContain('/setup-wizard/wizard');
|
||||
expect((result as UrlTree).queryParams['resume']).toBe('migrations');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import { AppConfigService } from './app-config.service';
|
||||
* Route guard that checks both configuration loading and setup state.
|
||||
*
|
||||
* - If config is not loaded → redirect to /setup
|
||||
* - If config is loaded but `setup` is absent/undefined → redirect to /setup/wizard (fresh install)
|
||||
* - If config is loaded and `setup` is a step ID → redirect to /setup/wizard?resume=<stepId>
|
||||
* - If config is loaded but `setup` is absent/undefined → redirect to /setup-wizard/wizard (fresh install)
|
||||
* - If config is loaded and `setup` is a step ID → redirect to /setup-wizard/wizard?resume=<stepId>
|
||||
* - If config is loaded and `setup === "complete"` → allow navigation
|
||||
*
|
||||
* Place this guard **before** auth guards so unconfigured deployments
|
||||
@@ -26,12 +26,12 @@ export const requireConfigGuard: CanMatchFn = () => {
|
||||
|
||||
if (!setup) {
|
||||
// setup absent → fresh install, go to wizard
|
||||
return router.createUrlTree(['/setup/wizard']);
|
||||
return router.createUrlTree(['/setup-wizard/wizard']);
|
||||
}
|
||||
|
||||
if (setup !== 'complete') {
|
||||
// setup = stepId → resume wizard at that step
|
||||
return router.createUrlTree(['/setup/wizard'], {
|
||||
return router.createUrlTree(['/setup-wizard/wizard'], {
|
||||
queryParams: { resume: setup },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,79 +21,142 @@ interface SetupCard {
|
||||
template: `
|
||||
<div class="admin-overview">
|
||||
<header class="admin-overview__header">
|
||||
<h1 class="admin-overview__title">Setup</h1>
|
||||
<p class="admin-overview__subtitle">
|
||||
Manage topology, identity, tenants, notifications, and system controls.
|
||||
</p>
|
||||
<div>
|
||||
<h1 class="admin-overview__title">Setup</h1>
|
||||
<p class="admin-overview__subtitle">
|
||||
Manage topology, identity, tenants, notifications, and system controls.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-overview__meta">
|
||||
<span class="admin-overview__meta-chip">7 setup domains</span>
|
||||
<span class="admin-overview__meta-chip">3 operational drilldowns</span>
|
||||
<span class="admin-overview__meta-chip">Offline-first safe</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-overview__grid">
|
||||
@for (card of cards; track card.id) {
|
||||
<a class="admin-card" [routerLink]="card.route">
|
||||
<div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div>
|
||||
<div class="admin-card__body">
|
||||
<h2 class="admin-card__title">{{ card.title }}</h2>
|
||||
<p class="admin-card__description">{{ card.description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="admin-overview__layout">
|
||||
<div>
|
||||
<div class="admin-overview__grid">
|
||||
@for (card of cards; track card.id) {
|
||||
<a class="admin-card" [routerLink]="card.route">
|
||||
<div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div>
|
||||
<div class="admin-card__body">
|
||||
<h2 class="admin-card__title">{{ card.title }}</h2>
|
||||
<p class="admin-card__description">{{ card.description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="admin-overview__drilldowns">
|
||||
<h2 class="admin-overview__section-heading">Operational Drilldowns</h2>
|
||||
<ul class="admin-overview__links">
|
||||
<li><a routerLink="/ops/operations/quotas">Quotas & Limits</a> - Ops</li>
|
||||
<li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li>
|
||||
<li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="admin-overview__drilldowns">
|
||||
<h2 class="admin-overview__section-heading">Operational Drilldowns</h2>
|
||||
<ul class="admin-overview__links">
|
||||
<li><a routerLink="/ops/operations/quotas">Quotas & Limits</a> - Ops</li>
|
||||
<li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li>
|
||||
<li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="admin-overview__aside">
|
||||
<section class="admin-overview__aside-section">
|
||||
<h2 class="admin-overview__section-heading">Quick Actions</h2>
|
||||
<div class="admin-overview__quick-actions">
|
||||
<a routerLink="/setup/topology/targets">Add Target</a>
|
||||
<a routerLink="/setup/integrations">Configure Integrations</a>
|
||||
<a routerLink="/setup/identity-access">Review Access</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-overview__aside-section">
|
||||
<h2 class="admin-overview__section-heading">Suggested Next Steps</h2>
|
||||
<ul class="admin-overview__links admin-overview__links--compact">
|
||||
<li>Validate environment mapping and target ownership.</li>
|
||||
<li>Verify notification channels before first production promotion.</li>
|
||||
<li>Confirm audit trail visibility for approvers.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.admin-overview {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
.admin-overview__header {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-overview__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.05;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.admin-overview__subtitle {
|
||||
color: var(--color-text-secondary, #666);
|
||||
color: var(--color-text-secondary, #4f4b3e);
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.admin-overview__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-overview__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border-secondary, #d4c9a8);
|
||||
border-radius: var(--radius-full, 999px);
|
||||
padding: 0.24rem 0.55rem;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary, #4f4b3e);
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.admin-overview__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-overview__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.admin-card:hover {
|
||||
border-color: var(--color-brand-primary, #4f46e5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--color-brand-primary, #f5a623);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.admin-card__icon {
|
||||
@@ -104,24 +167,59 @@ interface SetupCard {
|
||||
}
|
||||
|
||||
.admin-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.admin-card__description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary, #4f4b3e);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-overview__aside {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.admin-overview__aside-section,
|
||||
.admin-overview__drilldowns {
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
background: var(--color-surface-primary, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-overview__section-heading {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary, #666);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-secondary, #4f4b3e);
|
||||
}
|
||||
|
||||
.admin-overview__quick-actions {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-overview__quick-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
padding: 0.45rem 0.55rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary, #1c1200);
|
||||
background: var(--color-surface-secondary, #faf8f0);
|
||||
}
|
||||
|
||||
.admin-overview__quick-actions a:hover {
|
||||
border-color: var(--color-brand-primary, #f5a623);
|
||||
color: var(--color-brand-primary, #f5a623);
|
||||
}
|
||||
|
||||
.admin-overview__links {
|
||||
@@ -134,18 +232,42 @@ interface SetupCard {
|
||||
}
|
||||
|
||||
.admin-overview__links li {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-text-secondary, #4f4b3e);
|
||||
}
|
||||
|
||||
.admin-overview__links--compact li {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-overview__links a {
|
||||
color: var(--color-brand-primary, #4f46e5);
|
||||
color: var(--color-brand-primary, #f5a623);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-overview__links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.admin-overview__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-overview__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.admin-overview {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-overview__title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-surface-secondary);
|
||||
overflow: visible;
|
||||
|
||||
.target-selector {
|
||||
display: flex;
|
||||
@@ -86,6 +91,7 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane {
|
||||
@@ -187,6 +193,120 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.compare-toolbar {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
|
||||
.target-selector {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.delta-summary {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.compare-view {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.compare-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
|
||||
.target-selector {
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
|
||||
mat-select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
> button[mat-icon-button] {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
> button[mat-stroked-button] {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.role-toggle {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.role-toggle-button {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding-inline: 0.2rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
}
|
||||
|
||||
.delta-summary {
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
|
||||
.summary-chip {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panes-container {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.pane {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.pane:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.categories-pane,
|
||||
.items-pane,
|
||||
.evidence-pane {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.evidence-pane .side-by-side {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -31,7 +31,7 @@ interface Deployment {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-container table-container--desktop">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -76,6 +76,53 @@ interface Deployment {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="deployment-cards" aria-label="Deployment cards">
|
||||
@for (deployment of deployments(); track deployment.id) {
|
||||
<article class="deployment-card">
|
||||
<header class="deployment-card__header">
|
||||
<a [routerLink]="['./', deployment.id]" class="deployment-link deployment-card__id">
|
||||
{{ deployment.id }}
|
||||
</a>
|
||||
<span class="status-badge" [class]="'status-badge--' + deployment.status">
|
||||
@if (deployment.status === 'running') {
|
||||
<span class="spinner"></span>
|
||||
}
|
||||
{{ deployment.status | uppercase }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl class="deployment-card__meta">
|
||||
<div>
|
||||
<dt>Release</dt>
|
||||
<dd>
|
||||
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Environment</dt>
|
||||
<dd>{{ deployment.environment }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Started</dt>
|
||||
<dd>{{ deployment.startedAt }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>{{ deployment.duration }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Initiated By</dt>
|
||||
<dd>{{ deployment.initiatedBy }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="deployment-card__actions">
|
||||
<a [routerLink]="['./', deployment.id]" class="btn btn--sm">View Deployment</a>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -88,9 +135,10 @@ interface Deployment {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table { width: 100%; min-width: 840px; border-collapse: collapse; }
|
||||
.data-table th, .data-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
|
||||
.data-table th { background: var(--color-surface-secondary); font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; }
|
||||
.data-table tbody tr:hover { background: var(--color-nav-hover); }
|
||||
@@ -115,6 +163,74 @@ interface Deployment {
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.btn { padding: 0.25rem 0.5rem; border-radius: var(--radius-md); font-size: 0.75rem; font-weight: var(--font-weight-medium); text-decoration: none; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); }
|
||||
|
||||
.deployment-cards {
|
||||
display: none;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.deployment-card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.deployment-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.deployment-card__id {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.deployment-card__meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.deployment-card__meta div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.deployment-card__meta dt {
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.deployment-card__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.deployment-card__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.deployments-page { max-width: none; }
|
||||
.page-title { font-size: 2rem; line-height: 1.05; }
|
||||
.page-subtitle { font-size: 0.95rem; line-height: 1.35; }
|
||||
.data-table th, .data-table td { padding: 0.625rem 0.75rem; }
|
||||
.table-container--desktop { display: none; }
|
||||
.deployment-cards { display: grid; }
|
||||
.deployment-card__meta { grid-template-columns: 1fr; }
|
||||
.deployment-card__actions .btn { width: 100%; text-align: center; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class DeploymentsListPageComponent {
|
||||
|
||||
@@ -92,5 +92,5 @@ export function getCheckIdsForStep(stepId: SetupStepId): string[] {
|
||||
|
||||
/** Build a deep-link URL to the setup wizard for a specific step in reconfigure mode. */
|
||||
export function buildWizardDeepLink(stepId: SetupStepId): string {
|
||||
return `/setup/wizard?step=${stepId}&mode=reconfigure`;
|
||||
return `/setup-wizard/wizard?step=${stepId}&mode=reconfigure`;
|
||||
}
|
||||
|
||||
@@ -13,22 +13,136 @@ import { RouterLink } from '@angular/router';
|
||||
<p>Unified operations workspace for platform runtime, policy governance, and integrations.</p>
|
||||
</header>
|
||||
|
||||
<div class="doors">
|
||||
<a routerLink="/ops/operations">Operations</a>
|
||||
<a routerLink="/ops/integrations">Integrations</a>
|
||||
<a routerLink="/ops/policy">Policy</a>
|
||||
<a routerLink="/ops/platform-setup">Platform Setup</a>
|
||||
<div class="ops-overview__layout">
|
||||
<div class="doors">
|
||||
<a routerLink="/ops/operations">
|
||||
<strong>Operations</strong>
|
||||
<span>Scheduler, feeds, health, quotas, and runtime controls.</span>
|
||||
</a>
|
||||
<a routerLink="/ops/integrations">
|
||||
<strong>Integrations</strong>
|
||||
<span>Connector onboarding, source registration, and delivery channels.</span>
|
||||
</a>
|
||||
<a routerLink="/ops/policy">
|
||||
<strong>Policy</strong>
|
||||
<span>Governance, simulations, waivers, and gate catalog management.</span>
|
||||
</a>
|
||||
<a routerLink="/ops/platform-setup">
|
||||
<strong>Platform Setup</strong>
|
||||
<span>Environment bootstrap, topology alignment, and baseline controls.</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<aside class="ops-overview__aside">
|
||||
<h2>Operational Focus</h2>
|
||||
<ul>
|
||||
<li>Validate feed freshness and advisory import health.</li>
|
||||
<li>Review scheduler backlog and failed task retries.</li>
|
||||
<li>Confirm policy package rollout and waiver expiration queue.</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.ops-overview { display: grid; gap: 1rem; }
|
||||
h1 { margin: 0; font-size: 1.35rem; }
|
||||
p { margin: 0; color: var(--color-text-secondary); }
|
||||
.doors { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||
.doors a { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.65rem; text-decoration: none; color: var(--color-text-primary); background: var(--color-surface-primary); }
|
||||
.doors a:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); }
|
||||
.ops-overview {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.ops-overview__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doors {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.doors a {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.doors a strong {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.doors a span {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.doors a:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ops-overview__aside {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.ops-overview__aside h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ops-overview__aside ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ops-overview__aside li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.ops-overview__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @sprint Sprint 4: UI Wizard Core
|
||||
* @description Routes for the setup wizard feature.
|
||||
* The default path shows the config-missing screen (for unconfigured deployments).
|
||||
* The /setup/wizard path loads the full setup wizard (when config is present).
|
||||
* The /setup-wizard/wizard path loads the full setup wizard (when config is present).
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
||||
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppTopbarComponent } from '../app-topbar/app-topbar.component';
|
||||
import { AppSidebarComponent } from '../app-sidebar';
|
||||
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
|
||||
import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service';
|
||||
|
||||
/**
|
||||
* AppShellComponent - Main application shell with permanent left rail navigation.
|
||||
@@ -29,16 +30,16 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
OverlayHostComponent
|
||||
],
|
||||
template: `
|
||||
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarCollapsed()">
|
||||
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()">
|
||||
<!-- Skip link for accessibility -->
|
||||
<a class="shell__skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<app-sidebar
|
||||
class="shell__sidebar"
|
||||
[collapsed]="sidebarCollapsed()"
|
||||
[collapsed]="sidebarPrefs.sidebarCollapsed()"
|
||||
(mobileClose)="onMobileSidebarClose()"
|
||||
(collapseToggle)="onSidebarCollapseToggle()"
|
||||
(collapseToggle)="sidebarPrefs.toggleSidebar()"
|
||||
></app-sidebar>
|
||||
|
||||
<!-- Main area (topbar + content) -->
|
||||
@@ -212,12 +213,11 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppShellComponent {
|
||||
readonly sidebarPrefs = inject(SidebarPreferenceService);
|
||||
|
||||
/** Whether mobile menu is open */
|
||||
readonly mobileMenuOpen = signal(false);
|
||||
|
||||
/** Whether sidebar is collapsed (icons only) */
|
||||
readonly sidebarCollapsed = signal(false);
|
||||
|
||||
onMobileMenuToggle(): void {
|
||||
this.mobileMenuOpen.update((v) => !v);
|
||||
}
|
||||
@@ -225,8 +225,4 @@ export class AppShellComponent {
|
||||
onMobileSidebarClose(): void {
|
||||
this.mobileMenuOpen.set(false);
|
||||
}
|
||||
|
||||
onSidebarCollapseToggle(): void {
|
||||
this.sidebarCollapsed.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,25 @@ describe('AppSidebarComponent', () => {
|
||||
expect(text).not.toContain('Analytics');
|
||||
});
|
||||
|
||||
it('starts edge auto-scroll animation only when pointer enters edge zone', () => {
|
||||
setScopes([StellaOpsScopes.UI_READ]);
|
||||
const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(1);
|
||||
const cancelSpy = spyOn(window, 'cancelAnimationFrame');
|
||||
const fixture = createComponent();
|
||||
const nav = fixture.nativeElement.querySelector('.sidebar__nav') as HTMLElement;
|
||||
|
||||
expect(nav).toBeTruthy();
|
||||
expect(rafSpy).not.toHaveBeenCalled();
|
||||
|
||||
spyOn(nav, 'getBoundingClientRect').and.returnValue(new DOMRect(0, 0, 320, 480));
|
||||
nav.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 5 }));
|
||||
|
||||
expect(rafSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
nav.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
expect(cancelSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
function setScopes(scopes: readonly StellaOpsScope[]): void {
|
||||
const baseUser = authService.user();
|
||||
if (!baseUser) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ApprovalApi } from '../../core/api/approval.client';
|
||||
|
||||
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
|
||||
import { SidebarPreferenceService } from './sidebar-preference.service';
|
||||
|
||||
/**
|
||||
* Navigation structure for the shell.
|
||||
@@ -33,6 +34,8 @@ export interface NavSection {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
menuGroupId?: string;
|
||||
menuGroupLabel?: string;
|
||||
badge$?: () => number | null;
|
||||
sparklineData$?: () => number[];
|
||||
children?: NavItem[];
|
||||
@@ -40,19 +43,31 @@ export interface NavSection {
|
||||
requireAnyScope?: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
interface DisplayNavSection extends NavSection {
|
||||
sectionBadge: number | null;
|
||||
displayChildren: NavItem[];
|
||||
}
|
||||
|
||||
interface NavSectionGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
sections: DisplayNavSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AppSidebarComponent - Permanent dark left navigation rail.
|
||||
*
|
||||
* Design: Always-visible 240px dark sidebar. Never collapses.
|
||||
* Design: Always-visible 240px dark sidebar, collapsible to 56px icon rail.
|
||||
* Dark charcoal background with amber/gold accents.
|
||||
* All nav groups are always expanded. Mouse-proximity auto-scroll near edges.
|
||||
* Collapsible nav groups and foldable sections with smooth CSS grid animation.
|
||||
* Mouse-proximity auto-scroll near edges.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
SidebarNavItemComponent
|
||||
],
|
||||
SidebarNavItemComponent,
|
||||
],
|
||||
template: `
|
||||
<aside
|
||||
class="sidebar"
|
||||
@@ -73,65 +88,116 @@ export interface NavSection {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Navigation - sections with vertical rail labels -->
|
||||
<!-- Navigation - collapsible groups with foldable sections -->
|
||||
<nav class="sidebar__nav" #sidebarNav>
|
||||
@for (section of displaySections(); track section.id; let first = $first) {
|
||||
@if (!section.displayChildren.length) {
|
||||
<!-- Root item (no children, e.g. Dashboard) -->
|
||||
@if (!first) {
|
||||
<div class="nav-section__divider"></div>
|
||||
@for (group of displaySectionGroups(); track group.id) {
|
||||
<div
|
||||
class="sb-group"
|
||||
[class.sb-group--collapsed]="!collapsed && sidebarPrefs.collapsedGroups().has(group.id)"
|
||||
role="group"
|
||||
[attr.aria-label]="group.label"
|
||||
>
|
||||
@if (!collapsed) {
|
||||
<button
|
||||
type="button"
|
||||
class="sb-group__header"
|
||||
(click)="sidebarPrefs.toggleGroup(group.id)"
|
||||
[attr.aria-expanded]="!sidebarPrefs.collapsedGroups().has(group.id)"
|
||||
[attr.aria-controls]="'nav-grp-' + group.id"
|
||||
>
|
||||
<span class="sb-group__title">{{ group.label }}</span>
|
||||
<svg class="sb-group__chevron" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.sectionBadge"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
} @else {
|
||||
<!-- Section group with children -->
|
||||
<div class="nav-section">
|
||||
@if (!first) {
|
||||
<div class="nav-section__divider"></div>
|
||||
}
|
||||
<div class="nav-section__body">
|
||||
@if (!collapsed) {
|
||||
<div class="nav-section__rail" aria-hidden="true">
|
||||
<div class="nav-section__rail-line"></div>
|
||||
<span class="nav-section__rail-label">{{ section.label }}</span>
|
||||
</div>
|
||||
}
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.sectionBadge"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
@for (child of section.displayChildren; track child.id) {
|
||||
<app-sidebar-nav-item
|
||||
[label]="child.label"
|
||||
[icon]="child.icon"
|
||||
[route]="child.route"
|
||||
[badge]="child.badge ?? null"
|
||||
[isChild]="true"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
<div class="sb-group__body" [id]="'nav-grp-' + group.id">
|
||||
<div class="sb-group__body-inner">
|
||||
@for (section of group.sections; track section.id; let sectionFirst = $first) {
|
||||
@if (!collapsed && !sectionFirst) {
|
||||
<div class="sb-divider"></div>
|
||||
}
|
||||
@if (!collapsed && section.displayChildren.length > 0) {
|
||||
<!-- Section with foldable children -->
|
||||
<div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)">
|
||||
<div class="sb-section__head">
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.sectionBadge"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
<button
|
||||
type="button"
|
||||
class="sb-section__fold"
|
||||
(click)="sidebarPrefs.toggleSection(section.id)"
|
||||
[attr.aria-expanded]="!sidebarPrefs.collapsedSections().has(section.id)"
|
||||
[attr.aria-label]="(sidebarPrefs.collapsedSections().has(section.id) ? 'Expand ' : 'Collapse ') + section.label"
|
||||
>
|
||||
<svg class="sb-section__fold-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sb-section__body">
|
||||
<div class="sb-section__body-inner">
|
||||
@for (child of section.displayChildren; track child.id) {
|
||||
<app-sidebar-nav-item
|
||||
[label]="child.label"
|
||||
[icon]="child.icon"
|
||||
[route]="child.route"
|
||||
[badge]="child.badge ?? null"
|
||||
[isChild]="true"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Section without children (or collapsed sidebar) -->
|
||||
<app-sidebar-nav-item
|
||||
[label]="section.label"
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.sectionBadge"
|
||||
[collapsed]="collapsed"
|
||||
></app-sidebar-nav-item>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar__collapse-btn"
|
||||
(click)="collapseToggle.emit()"
|
||||
[attr.title]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
[attr.aria-label]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
@if (collapsed) {
|
||||
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
} @else {
|
||||
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
<div class="sidebar__footer-divider"></div>
|
||||
<span class="sidebar__version">Stella Ops v1.0.0-alpha</span>
|
||||
</div>
|
||||
</aside>
|
||||
`,
|
||||
styles: [`
|
||||
/* ================================================================
|
||||
Sidebar shell
|
||||
================================================================ */
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -149,7 +215,6 @@ export interface NavSection {
|
||||
width: 56px;
|
||||
}
|
||||
|
||||
/* Subtle inner glow along right edge */
|
||||
.sidebar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -160,7 +225,17 @@ export interface NavSection {
|
||||
background: var(--color-sidebar-border);
|
||||
}
|
||||
|
||||
/* ---- Mobile close ---- */
|
||||
/* Mobile: always full width regardless of collapsed state */
|
||||
@media (max-width: 991px) {
|
||||
.sidebar,
|
||||
.sidebar.sidebar--collapsed {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Mobile close
|
||||
================================================================ */
|
||||
.sidebar__close {
|
||||
display: none;
|
||||
position: absolute;
|
||||
@@ -189,12 +264,14 @@ export interface NavSection {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Nav ---- */
|
||||
/* ================================================================
|
||||
Scrollable nav area
|
||||
================================================================ */
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem 0.5rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
|
||||
@@ -210,71 +287,212 @@ export interface NavSection {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Collapsed nav ---- */
|
||||
.sidebar--collapsed .sidebar__nav {
|
||||
padding: 0.5rem 0.25rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
}
|
||||
|
||||
/* ---- Section groups ---- */
|
||||
.nav-section__divider {
|
||||
/* ================================================================
|
||||
Nav group (collapsible section cluster)
|
||||
================================================================ */
|
||||
.sb-group {
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.125rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--color-sidebar-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Group header (toggle button) ---- */
|
||||
.sb-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% - 0.25rem);
|
||||
margin: 0 0.125rem 0.125rem;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-sidebar-text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-sidebar-text-heading);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--color-sidebar-active-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.sb-group__title {
|
||||
flex: 1;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- Group chevron (rotates on collapse) ---- */
|
||||
.sb-group__chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.35;
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
.sb-group--collapsed .sb-group__chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.sb-group__header:hover .sb-group__chevron {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- Animated group body (CSS grid trick) ---- */
|
||||
.sb-group__body {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sb-group--collapsed .sb-group__body {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.sb-group__body-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Sections within a group (foldable children)
|
||||
================================================================ */
|
||||
.sb-divider {
|
||||
height: 1px;
|
||||
background: var(--color-sidebar-divider);
|
||||
margin: 0.5rem 0.75rem;
|
||||
margin: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-section__body {
|
||||
/* Section head: nav-item + fold toggle */
|
||||
.sb-section__head {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-section__rail {
|
||||
.sb-section__fold {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
right: 0.375rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-sidebar-text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
z-index: 2;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--color-sidebar-text-heading);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--color-sidebar-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section__rail-line {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: rgba(245, 200, 120, 0.12);
|
||||
transform: translateX(-50%);
|
||||
/* Show fold button on hover or when section is folded */
|
||||
.sb-section__head:hover .sb-section__fold,
|
||||
.sb-section--folded .sb-section__fold {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-section__rail-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-sidebar-group-text);
|
||||
white-space: nowrap;
|
||||
background: var(--color-sidebar-bg);
|
||||
padding: 4px 0;
|
||||
line-height: 1;
|
||||
.sb-section__fold-icon {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
.sb-section--folded .sb-section__fold-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Animated section body */
|
||||
.sb-section__body {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sb-section--folded .sb-section__body {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.sb-section__body-inner {
|
||||
overflow: hidden;
|
||||
margin-left: 1.25rem;
|
||||
border-left: 1px solid rgba(245, 166, 35, 0.12);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Footer with collapse toggle
|
||||
================================================================ */
|
||||
.sidebar__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.375rem 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar__collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-sidebar-divider);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-sidebar-text-muted);
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.375rem;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-sidebar-text-heading);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--color-sidebar-active-border);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide collapse button on mobile (mobile uses the close X) */
|
||||
@media (max-width: 991px) {
|
||||
.sidebar__collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__footer-divider {
|
||||
height: 1px;
|
||||
background: var(--color-sidebar-divider);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.sidebar__version {
|
||||
@@ -289,7 +507,7 @@ export interface NavSection {
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar__footer {
|
||||
padding: 0.5rem;
|
||||
padding: 0.375rem 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar__version {
|
||||
@@ -307,6 +525,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
private readonly doctorTrendService = inject(DoctorTrendService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
readonly sidebarPrefs = inject(SidebarPreferenceService);
|
||||
|
||||
@Input() collapsed = false;
|
||||
@Output() mobileClose = new EventEmitter<void>();
|
||||
@Output() collapseToggle = new EventEmitter<void>();
|
||||
@@ -324,6 +544,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Dashboard',
|
||||
icon: 'dashboard',
|
||||
route: '/mission-control/board',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_READ,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
@@ -335,6 +557,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Releases',
|
||||
icon: 'package',
|
||||
route: '/releases/deployments',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
@@ -384,6 +608,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Vulnerabilities',
|
||||
icon: 'shield',
|
||||
route: '/security',
|
||||
menuGroupId: 'security-evidence',
|
||||
menuGroupLabel: 'Security & Evidence',
|
||||
sparklineData$: () => this.doctorTrendService.securityTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
@@ -406,6 +632,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Evidence',
|
||||
icon: 'file-text',
|
||||
route: '/evidence/overview',
|
||||
menuGroupId: 'security-evidence',
|
||||
menuGroupLabel: 'Security & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
@@ -426,6 +654,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Operations',
|
||||
icon: 'settings',
|
||||
route: '/ops/operations',
|
||||
menuGroupId: 'platform-setup',
|
||||
menuGroupLabel: 'Platform & Setup',
|
||||
sparklineData$: () => this.doctorTrendService.platformTrend(),
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
@@ -445,6 +675,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
label: 'Setup',
|
||||
icon: 'server',
|
||||
route: '/setup/system',
|
||||
menuGroupId: 'platform-setup',
|
||||
menuGroupLabel: 'Platform & Setup',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_ADMIN,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
@@ -469,14 +701,42 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
});
|
||||
|
||||
/** Sections with duplicate children removed and badges resolved */
|
||||
readonly displaySections = computed(() => {
|
||||
return this.visibleSections().map(section => ({
|
||||
readonly displaySections = computed<DisplayNavSection[]>(() => {
|
||||
return this.visibleSections().map((section) => ({
|
||||
...section,
|
||||
sectionBadge: section.badge$?.() ?? null,
|
||||
displayChildren: (section.children ?? []).filter(child => child.route !== section.route),
|
||||
displayChildren: (section.children ?? []).filter((child) => child.route !== section.route),
|
||||
}));
|
||||
});
|
||||
|
||||
/** Menu groups rendered in deterministic order for scanability */
|
||||
readonly displaySectionGroups = computed<NavSectionGroup[]>(() => {
|
||||
const orderedGroups = new Map<string, NavSectionGroup>();
|
||||
const groupOrder = ['release-control', 'security-evidence', 'platform-setup', 'misc'];
|
||||
|
||||
for (const groupId of groupOrder) {
|
||||
orderedGroups.set(groupId, {
|
||||
id: groupId,
|
||||
label: this.resolveMenuGroupLabel(groupId),
|
||||
sections: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const section of this.displaySections()) {
|
||||
const groupId = section.menuGroupId ?? 'misc';
|
||||
const group = orderedGroups.get(groupId) ?? {
|
||||
id: groupId,
|
||||
label: section.menuGroupLabel ?? this.resolveMenuGroupLabel(groupId),
|
||||
sections: [],
|
||||
};
|
||||
|
||||
group.sections.push(section);
|
||||
orderedGroups.set(groupId, group);
|
||||
}
|
||||
|
||||
return Array.from(orderedGroups.values()).filter((group) => group.sections.length > 0);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.loadPendingApprovalsBadge();
|
||||
this.router.events
|
||||
@@ -510,10 +770,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
.map((child) => this.filterItem(child))
|
||||
.filter((child): child is NavItem => child !== null);
|
||||
|
||||
if (visibleChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = visibleChildren.map((child) => this.withDynamicChildState(child));
|
||||
|
||||
return {
|
||||
@@ -522,6 +778,23 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
};
|
||||
}
|
||||
|
||||
private resolveMenuGroupLabel(groupId: string): string {
|
||||
switch (groupId) {
|
||||
case 'release-control':
|
||||
return 'Release Control';
|
||||
case 'security-evidence':
|
||||
return 'Security & Evidence';
|
||||
case 'platform-setup':
|
||||
return 'Platform & Setup';
|
||||
default:
|
||||
return 'Global Menu';
|
||||
}
|
||||
}
|
||||
|
||||
groupRoute(group: NavSectionGroup): string {
|
||||
return group.sections[0]?.route ?? '/mission-control/board';
|
||||
}
|
||||
|
||||
private withDynamicChildState(item: NavItem): NavItem {
|
||||
if (item.id !== 'rel-approvals') {
|
||||
return item;
|
||||
@@ -579,14 +852,34 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
const EDGE_ZONE = 60;
|
||||
const MAX_SPEED = 8;
|
||||
let scrollDirection = 0; // -1 = up, 0 = none, 1 = down
|
||||
let animFrameId = 0;
|
||||
let animFrameId: number | null = null;
|
||||
let paused = false;
|
||||
|
||||
const scrollLoop = () => {
|
||||
if (scrollDirection !== 0 && !paused) {
|
||||
navEl.scrollTop += scrollDirection;
|
||||
const stopScrollLoop = () => {
|
||||
if (animFrameId === null) {
|
||||
return;
|
||||
}
|
||||
animFrameId = requestAnimationFrame(scrollLoop);
|
||||
|
||||
cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
};
|
||||
|
||||
const runScrollLoop = () => {
|
||||
if (animFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (scrollDirection === 0 || paused) {
|
||||
animFrameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
navEl.scrollTop += scrollDirection;
|
||||
animFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
@@ -595,7 +888,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
|
||||
// Check if hovering a nav item (pause scrolling)
|
||||
const target = e.target as HTMLElement;
|
||||
paused = !!(target.closest('.nav-item') || target.closest('.nav-group__header'));
|
||||
paused = !!(target.closest('.nav-item') || target.closest('.sb-group__header'));
|
||||
|
||||
const distFromTop = mouseY - rect.top;
|
||||
const distFromBottom = rect.bottom - mouseY;
|
||||
@@ -609,22 +902,28 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
} else {
|
||||
scrollDirection = 0;
|
||||
}
|
||||
|
||||
if (scrollDirection !== 0 && !paused) {
|
||||
runScrollLoop();
|
||||
} else {
|
||||
stopScrollLoop();
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
scrollDirection = 0;
|
||||
paused = false;
|
||||
stopScrollLoop();
|
||||
};
|
||||
|
||||
// Run outside Angular zone to avoid unnecessary change detection
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
navEl.addEventListener('mousemove', onMouseMove);
|
||||
navEl.addEventListener('mouseleave', onMouseLeave);
|
||||
animFrameId = requestAnimationFrame(scrollLoop);
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
stopScrollLoop();
|
||||
navEl.removeEventListener('mousemove', onMouseMove);
|
||||
navEl.removeEventListener('mouseleave', onMouseLeave);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
|
||||
export interface SidebarPreferences {
|
||||
sidebarCollapsed: boolean;
|
||||
collapsedGroups: string[];
|
||||
collapsedSections: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'stellaops.sidebar.preferences';
|
||||
|
||||
const DEFAULTS: SidebarPreferences = {
|
||||
sidebarCollapsed: false,
|
||||
collapsedGroups: [],
|
||||
collapsedSections: ['ops', 'setup'],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SidebarPreferenceService {
|
||||
private readonly _state = signal<SidebarPreferences>(this.load());
|
||||
|
||||
readonly sidebarCollapsed = computed(() => this._state().sidebarCollapsed);
|
||||
readonly collapsedGroups = computed(() => new Set(this._state().collapsedGroups));
|
||||
readonly collapsedSections = computed(() => new Set(this._state().collapsedSections));
|
||||
|
||||
constructor() {
|
||||
effect(() => this.save(this._state()));
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this._state.update((s) => ({ ...s, sidebarCollapsed: !s.sidebarCollapsed }));
|
||||
}
|
||||
|
||||
toggleGroup(groupId: string): void {
|
||||
this._state.update((s) => {
|
||||
const set = new Set(s.collapsedGroups);
|
||||
if (set.has(groupId)) set.delete(groupId);
|
||||
else set.add(groupId);
|
||||
return { ...s, collapsedGroups: [...set] };
|
||||
});
|
||||
}
|
||||
|
||||
toggleSection(sectionId: string): void {
|
||||
this._state.update((s) => {
|
||||
const set = new Set(s.collapsedSections);
|
||||
if (set.has(sectionId)) set.delete(sectionId);
|
||||
else set.add(sectionId);
|
||||
return { ...s, collapsedSections: [...set] };
|
||||
});
|
||||
}
|
||||
|
||||
private load(): SidebarPreferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
sidebarCollapsed:
|
||||
typeof parsed.sidebarCollapsed === 'boolean'
|
||||
? parsed.sidebarCollapsed
|
||||
: DEFAULTS.sidebarCollapsed,
|
||||
collapsedGroups: Array.isArray(parsed.collapsedGroups)
|
||||
? parsed.collapsedGroups
|
||||
: DEFAULTS.collapsedGroups,
|
||||
collapsedSections: Array.isArray(parsed.collapsedSections)
|
||||
? parsed.collapsedSections
|
||||
: DEFAULTS.collapsedSections,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
private save(state: SidebarPreferences): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore quota / private-mode errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,13 +78,28 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__context-toggle"
|
||||
[class.topbar__context-toggle--active]="mobileContextOpen()"
|
||||
[attr.aria-expanded]="mobileContextOpen()"
|
||||
aria-controls="topbar-context-row"
|
||||
(click)="toggleMobileContext()"
|
||||
>
|
||||
Context
|
||||
</button>
|
||||
|
||||
<!-- 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">
|
||||
<div
|
||||
id="topbar-context-row"
|
||||
class="topbar__row topbar__row--secondary"
|
||||
[class.topbar__row--secondary-open]="mobileContextOpen()"
|
||||
>
|
||||
<!-- Tenant selector (only shown when multiple tenants) -->
|
||||
@if (showTenantSelector()) {
|
||||
<div class="topbar__tenant">
|
||||
@@ -198,6 +213,14 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
|
||||
.topbar__row--secondary {
|
||||
padding: 0 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__row--secondary-open {
|
||||
display: flex;
|
||||
height: auto;
|
||||
padding: 0.35rem 0.5rem;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +335,34 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.topbar__context-toggle {
|
||||
display: none;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
height: 28px;
|
||||
padding: 0 0.45rem;
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.topbar__context-toggle:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.topbar__context-toggle--active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary));
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__right {
|
||||
gap: 0.25rem;
|
||||
@@ -320,6 +371,12 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
|
||||
.topbar__primary-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar__context-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Row 2 separator ---- */
|
||||
@@ -495,6 +552,7 @@ export class AppTopbarComponent {
|
||||
);
|
||||
readonly localePreferenceSyncAttempted = signal(false);
|
||||
readonly scopePanelOpen = signal(false);
|
||||
readonly mobileContextOpen = signal(false);
|
||||
readonly tenantPanelOpen = signal(false);
|
||||
readonly tenantSwitchInFlight = signal(false);
|
||||
readonly tenantBootstrapAttempted = signal(false);
|
||||
@@ -551,6 +609,10 @@ export class AppTopbarComponent {
|
||||
this.scopePanelOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
toggleMobileContext(): void {
|
||||
this.mobileContextOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
closeScopePanel(): void {
|
||||
this.scopePanelOpen.set(false);
|
||||
}
|
||||
@@ -654,6 +716,7 @@ export class AppTopbarComponent {
|
||||
onEscape(): void {
|
||||
this.closeScopePanel();
|
||||
this.closeTenantPanel();
|
||||
this.mobileContextOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
@@ -666,12 +729,17 @@ export class AppTopbarComponent {
|
||||
const host = this.elementRef.nativeElement;
|
||||
const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false;
|
||||
const insideTenant = host.querySelector('.topbar__tenant')?.contains(target) ?? false;
|
||||
const insideContextToggle = host.querySelector('.topbar__context-toggle')?.contains(target) ?? false;
|
||||
const insideContextRow = host.querySelector('.topbar__row--secondary')?.contains(target) ?? false;
|
||||
if (!insideScope) {
|
||||
this.closeScopePanel();
|
||||
}
|
||||
if (!insideTenant) {
|
||||
this.closeTenantPanel();
|
||||
}
|
||||
if (!insideContextToggle && !insideContextRow) {
|
||||
this.mobileContextOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadTenantContextIfNeeded(): Promise<void> {
|
||||
|
||||
@@ -381,6 +381,40 @@ interface DropdownOption {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.ctx {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ctx__controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.ctx__dropdown-btn {
|
||||
height: 22px;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.ctx__dropdown-key {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctx__dropdown-val {
|
||||
max-width: 82px;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.ctx__sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctx__chips {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "bg-BG",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "пакет \"Стелла\"",
|
||||
"common.error.generic": "Нещо се обърка.",
|
||||
"common.error.not_found": "Заявеният ресурс не беше намерен.",
|
||||
"common.error.unauthorized": "Нямате права да извършите това действие.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "de-DE",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"common.error.generic": "Etwas ist schiefgelaufen.",
|
||||
"common.error.not_found": "Die angeforderte Ressource wurde nicht gefunden.",
|
||||
"common.error.unauthorized": "Sie haben keine Berechtigung, diese Aktion auszuführen.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "en-US",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"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.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "es-ES",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"common.error.generic": "Algo salió mal.",
|
||||
"common.error.not_found": "No se encontró el recurso solicitado.",
|
||||
"common.error.unauthorized": "No tienes permiso para realizar esta acción.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "fr-FR",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"common.error.generic": "Quelque chose s'est mal passé.",
|
||||
"common.error.not_found": "La ressource demandée n'a pas été trouvée.",
|
||||
"common.error.unauthorized": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"_meta": {
|
||||
"version": "1.0",
|
||||
"locale": "en-US",
|
||||
"description": "Micro-interaction copy for StellaOps Console (MI9)"
|
||||
"description": "Micro-interaction copy for Stella Ops Console (MI9)"
|
||||
},
|
||||
"loading": {
|
||||
"skeleton": "Loading...",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "ru-RU",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "пакет \"Стелла\"",
|
||||
"common.error.generic": "Что-то пошло не так.",
|
||||
"common.error.not_found": "Запрошенный ресурс не найден.",
|
||||
"common.error.unauthorized": "У вас нет разрешения на выполнение этого действия.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "uk-UA",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "пакет \"Стелла\"",
|
||||
"common.error.generic": "Щось пішло не так.",
|
||||
"common.error.not_found": "Потрібний ресурс не знайдено.",
|
||||
"common.error.unauthorized": "Ви не маєте дозволу на виконання цієї дії.",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "zh-CN",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"common.error.generic": "出了点问题。",
|
||||
"common.error.not_found": "未找到请求的资源。",
|
||||
"common.error.unauthorized": "您没有执行此操作的权限。",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"_meta": {
|
||||
"locale": "zh-TW",
|
||||
"description": "Offline fallback bundle for StellaOps Console"
|
||||
"description": "Offline fallback bundle for Stella Ops Console"
|
||||
},
|
||||
"ui.branding.app_name": "Stella Ops",
|
||||
"common.error.generic": "出了點問題。",
|
||||
"common.error.not_found": "未找到請求的資源。",
|
||||
"common.error.unauthorized": "您沒有執行此操作的權限。",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Stella Ops",
|
||||
"short_name": "StellaOps",
|
||||
"short_name": "Stella Ops",
|
||||
"description": "Release control plane for container estates",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-text-primary: #3D2E0A;
|
||||
--color-text-heading: #1C1200;
|
||||
--color-text-secondary: #6B5A2E;
|
||||
--color-text-muted: #9A8F78;
|
||||
--color-text-secondary: #5A4A24;
|
||||
--color-text-muted: #756949;
|
||||
--color-text-inverse: #F5F0E6;
|
||||
--color-text-link: #D4920A;
|
||||
--color-text-link-hover: #F5A623;
|
||||
@@ -39,8 +39,8 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Border Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-border-primary: rgba(212, 201, 168, 0.3);
|
||||
--color-border-secondary: rgba(212, 201, 168, 0.5);
|
||||
--color-border-primary: rgba(176, 160, 118, 0.52);
|
||||
--color-border-secondary: rgba(176, 160, 118, 0.72);
|
||||
--color-border-focus: #F5A623;
|
||||
--color-border-error: #ef4444;
|
||||
|
||||
|
||||
@@ -92,15 +92,15 @@ describe('DoctorWizardMapping', () => {
|
||||
|
||||
describe('buildWizardDeepLink', () => {
|
||||
it('should build correct deep link for database', () => {
|
||||
expect(buildWizardDeepLink('database')).toBe('/setup/wizard?step=database&mode=reconfigure');
|
||||
expect(buildWizardDeepLink('database')).toBe('/setup-wizard/wizard?step=database&mode=reconfigure');
|
||||
});
|
||||
|
||||
it('should build correct deep link for authority', () => {
|
||||
expect(buildWizardDeepLink('authority')).toBe('/setup/wizard?step=authority&mode=reconfigure');
|
||||
expect(buildWizardDeepLink('authority')).toBe('/setup-wizard/wizard?step=authority&mode=reconfigure');
|
||||
});
|
||||
|
||||
it('should build correct deep link for telemetry', () => {
|
||||
expect(buildWizardDeepLink('telemetry')).toBe('/setup/wizard?step=telemetry&mode=reconfigure');
|
||||
expect(buildWizardDeepLink('telemetry')).toBe('/setup-wizard/wizard?step=telemetry&mode=reconfigure');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,13 +172,14 @@ test.describe('Nav shell canonical domains', () => {
|
||||
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
|
||||
|
||||
const labels = [
|
||||
'Mission Board',
|
||||
'Mission Alerts',
|
||||
'Mission Activity',
|
||||
'Release Control',
|
||||
'Security & Evidence',
|
||||
'Platform & Setup',
|
||||
'Dashboard',
|
||||
'Releases',
|
||||
'Security',
|
||||
'Vulnerabilities',
|
||||
'Evidence',
|
||||
'Ops',
|
||||
'Operations',
|
||||
'Setup',
|
||||
];
|
||||
|
||||
@@ -198,6 +199,45 @@ test.describe('Nav shell canonical domains', () => {
|
||||
expect(navText).not.toContain('Administration');
|
||||
expect(navText).not.toContain('Policy Studio');
|
||||
});
|
||||
|
||||
test('group headers are unique and navigate to group landing routes', async ({ page }) => {
|
||||
await go(page, '/mission-control/board');
|
||||
await ensureShell(page);
|
||||
|
||||
const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' });
|
||||
const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' });
|
||||
const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' });
|
||||
|
||||
await expect(releaseGroup).toHaveCount(1);
|
||||
await expect(securityGroup).toHaveCount(1);
|
||||
await expect(platformGroup).toHaveCount(1);
|
||||
|
||||
await releaseGroup.click();
|
||||
await expect(page).toHaveURL(/\/mission-control\/board$/);
|
||||
|
||||
await securityGroup.click();
|
||||
await expect(page).toHaveURL(/\/security(\/|$)/);
|
||||
|
||||
await platformGroup.click();
|
||||
await expect(page).toHaveURL(/\/ops(\/|$)/);
|
||||
});
|
||||
|
||||
test('grouped root entries navigate when clicked', async ({ page }) => {
|
||||
await go(page, '/mission-control/board');
|
||||
await ensureShell(page);
|
||||
|
||||
await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click();
|
||||
await expect(page).toHaveURL(/\/releases\/deployments$/);
|
||||
|
||||
await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click();
|
||||
await expect(page).toHaveURL(/\/security(\/|$)/);
|
||||
|
||||
await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click();
|
||||
await expect(page).toHaveURL(/\/evidence\/overview$/);
|
||||
|
||||
await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click();
|
||||
await expect(page).toHaveURL(/\/ops\/operations$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Nav shell breadcrumbs and stability', () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const mockConfig = {
|
||||
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',
|
||||
'openid profile email ui.read advisory:read advisory-ai:view advisory-ai:operate findings:read vex:read policy:read health:read',
|
||||
audience: 'https://scanner.local',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
@@ -55,9 +55,9 @@ export const shellSession = {
|
||||
...policyAuthorSession.scopes,
|
||||
'ui.read',
|
||||
'admin',
|
||||
'advisory:search',
|
||||
'advisory:read',
|
||||
'search:read',
|
||||
'advisory-ai:view',
|
||||
'advisory-ai:operate',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
'policy:read',
|
||||
|
||||
Reference in New Issue
Block a user