This commit is contained in:
master
2026-03-18 22:44:59 +02:00
parent d4530b2ae8
commit 8f2e15d992
629 changed files with 22826 additions and 17058 deletions

View File

@@ -28,7 +28,15 @@
"Bash(if not exist \"E:\\dev\\git.stella-ops.org\\src\\Scanner\\__Tests\\StellaOps.Scanner.Analyzers.Lang.Node.Tests\\Internal\" mkdir \"E:\\dev\\git.stella-ops.org\\src\\Scanner\\__Tests\\StellaOps.Scanner.Analyzers.Lang.Node.Tests\\Internal\")",
"Bash(rm:*)",
"Bash(if not exist \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\archived\" mkdir \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\archived\")",
"Bash(del \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\SPRINT_0510_0001_0001_airgap.md\")"
"Bash(del \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\SPRINT_0510_0001_0001_airgap.md\")",
"Bash(docker build:*)",
"Bash(docker compose:*)",
"Bash(docker wait:*)",
"Bash(npx ng:*)",
"mcp__plugin_playwright_playwright__browser_snapshot",
"mcp__plugin_playwright_playwright__browser_click",
"mcp__plugin_playwright_playwright__browser_navigate",
"mcp__plugin_playwright_playwright__browser_take_screenshot"
],
"deny": [],
"ask": []

View File

@@ -87,3 +87,95 @@ Design and build the StellaOps web user experience that surfaces backend capabil
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
## Metric / KPI Cards Convention (MANDATORY)
All metric badges, stat cards, KPI tiles, and summary indicators **must** use `<stella-metric-card>`.
Do NOT create custom `.stat-card`, `.summary-card`, `.kpi-card`, or `.posture-card` elements.
**Components:**
- `shared/components/stella-metric-card/stella-metric-card.component.ts` — individual card
- `shared/components/stella-metric-card/stella-metric-grid.component.ts` — responsive grid wrapper
**Usage:**
```html
<stella-metric-grid [columns]="3">
<stella-metric-card label="Risk Posture" [value]="riskLevel()" subtitle="6 findings in scope"
icon="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" route="/triage/artifacts" />
<stella-metric-card label="VEX Coverage" value="0%" subtitle="0/0 findings covered" />
</stella-metric-grid>
```
**Design rules:**
- Cards are **uncolored** — no severity/status color backgrounds
- Icon is mandatory (SVG path d, multi-path via `|||`)
- Subtitle is 5-10 words explaining the metric
- If `route` is set: card is clickable with hover lift + arrow
- If no `route`: static display, no hover effect
## Tab Navigation Convention (MANDATORY)
All page-level tab navigation **must** use `<stella-page-tabs>`.
Do NOT create custom `.tabs`, `.tab-navigation`, `.tab-button`, or `nav[role="tablist"]` elements.
Do NOT use the `app-tabs` / `TabsComponent` for page tabs (that component is for inline content tabs only).
**Component:** `shared/components/stella-page-tabs/stella-page-tabs.component.ts`
**Usage (signal-based, no router):**
```html
<stella-page-tabs
[tabs]="tabs"
[activeTab]="activeTab()"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Section tabs"
>
@switch (activeTab()) {
@case ('first') { <my-first-panel /> }
@case ('second') { <my-second-panel /> }
}
</stella-page-tabs>
```
**Usage (router-based):**
```html
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Page tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet></router-outlet>
</stella-page-tabs>
```
**Tab definition:**
```typescript
const MY_TABS: readonly StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' },
{ id: 'details', label: 'Details', icon: 'M14 2H6a2...', status: 'warn', statusHint: '3 issues' },
];
```
**Design rules:**
- Every tab MUST have an SVG icon (`icon` field — SVG path `d` attribute, multi-path via `|||`)
- Labels should be short (1-2 words)
- Use `status` for warn/error indicators, `badge` for counts
- Do NOT duplicate the tab bar styling — the component owns all tab CSS
- The component provides: keyboard navigation, ARIA roles, active background, bottom border, icon opacity transitions, panel border-radius, enter animation
## Table Styling Convention
All HTML tables must use the `stella-table` CSS class for consistent styling.
Never define custom table padding, borders, or header styles inline.
Use the shared data-table component when possible, or the stella-table class for simple static tables.
## Filter Convention (MANDATORY)
Three filter component types:
1. `stella-filter-chip` — Single-select dropdown (Region, Env, Stage, Type, Gate, Risk)
2. `stella-filter-multi` — Multi-select with checkboxes + All/None (Severity, Status)
3. `stella-view-mode-switcher` — Binary toggle (Operator/Auditor, view modes)
Global filters (Region, Env, Window, Stage, Operator/Auditor) live in the header bar only.
Pages must NOT duplicate global filters. Read from PlatformContextStore.
Design: Compact inline chips, 28px height, no border default, dropdown on click.

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,13 @@ export const routes: Routes = [
data: { breadcrumb: 'Mission Control' },
loadChildren: () => import('./routes/mission-control.routes').then((m) => m.MISSION_CONTROL_ROUTES),
},
{
path: 'environments',
title: 'Environments',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireOpsGuard],
data: { breadcrumb: 'Environments' },
loadChildren: () => import('./routes/topology.routes').then((m) => m.TOPOLOGY_ROUTES),
},
{
path: 'releases',
title: 'Releases',
@@ -329,7 +336,7 @@ export const routes: Routes = [
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{
path: 'setup/regions-environments',
redirectTo: preserveAppRedirect('/setup/topology/regions'),
redirectTo: preserveAppRedirect('/environments/regions'),
pathMatch: 'full',
},
{
@@ -382,11 +389,11 @@ export const routes: Routes = [
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
pathMatch: 'full',
},
{ path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
{ path: 'regions', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
{ path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' },
{ path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' },
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
{ path: 'governance', redirectTo: '/ops/policy', pathMatch: 'full' },

View File

@@ -102,7 +102,7 @@ describe('BrandingService', () => {
service.fetchBranding().subscribe((response) => {
expect(response.branding.tenantId).toBe('default');
expect(response.branding.title).toBe('Stella Ops Dashboard');
expect(response.branding.title).toBe('Stella Ops');
expect(response.branding.themeTokens).toEqual({});
});

View File

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

View File

@@ -0,0 +1,111 @@
/**
* Locale-aware date formatting service.
*
* Centralises all date/time formatting behind the user's chosen locale
* (stored in I18nService). Components should inject this service instead of
* hardcoding 'en-US' in Intl.DateTimeFormat / toLocaleString calls.
*/
import { Injectable, computed, inject } from '@angular/core';
import { I18nService } from './i18n.service';
@Injectable({ providedIn: 'root' })
export class DateFormatService {
private readonly i18n = inject(I18nService);
/** The current locale string (e.g. 'de-DE', 'en-US'). */
readonly locale = computed(() => this.i18n.locale());
// -----------------------------------------------------------------------
// Pre-built formatters (re-created when locale changes via computed())
// -----------------------------------------------------------------------
/** Short date+time: "Jan 15, 2026, 3:45 PM" */
readonly mediumFormatter = computed(
() => new Intl.DateTimeFormat(this.locale(), {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
}),
);
/** Short date only: "Jan 15, 2026" */
readonly shortDateFormatter = computed(
() => new Intl.DateTimeFormat(this.locale(), {
year: 'numeric', month: 'short', day: 'numeric',
}),
);
/** Month+day only: "Jan 15" */
readonly monthDayFormatter = computed(
() => new Intl.DateTimeFormat(this.locale(), {
month: 'short', day: 'numeric',
}),
);
/** Time only: "3:45 PM" */
readonly timeFormatter = computed(
() => new Intl.DateTimeFormat(this.locale(), {
hour: '2-digit', minute: '2-digit',
}),
);
// -----------------------------------------------------------------------
// Convenience methods
// -----------------------------------------------------------------------
/**
* Format a date/ISO-string with arbitrary Intl.DateTimeFormat options,
* automatically using the user's locale.
*/
format(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat(this.locale(), options).format(date);
} catch {
return String(value);
}
}
/** Locale-aware toLocaleString(). */
toLocaleString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString(this.locale(), options);
} catch {
return String(value);
}
}
/** Locale-aware toLocaleDateString(). */
toLocaleDateString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString(this.locale(), options);
} catch {
return String(value);
}
}
/** Locale-aware toLocaleTimeString(). */
toLocaleTimeString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string {
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleTimeString(this.locale(), options);
} catch {
return String(value);
}
}
/** Locale-aware number formatting. */
formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
try {
return new Intl.NumberFormat(this.locale(), options).format(value);
} catch {
return String(value);
}
}
}

View File

@@ -14,3 +14,4 @@ export {
export { TranslatePipe } from './translate.pipe';
export { LocaleCatalogService } from './locale-catalog.service';
export { UserLocalePreferenceService } from './user-locale-preference.service';
export { DateFormatService } from './date-format.service';

View File

@@ -9,6 +9,7 @@ import {
BundleFreshnessInfo,
OfflineManifest
} from '../api/offline-kit.models';
import { DateFormatService } from '../i18n/date-format.service';
const HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds
const HEALTH_CHECK_TIMEOUT_MS = 3000; // 3 seconds
@@ -19,6 +20,7 @@ const MANIFEST_CACHE_KEY = 'stellaops_offline_manifest';
@Injectable({ providedIn: 'root' })
export class OfflineModeService implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly dateFmt = inject(DateFormatService);
private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
// Signals for reactive state management
@@ -43,7 +45,7 @@ export class OfflineModeService implements OnDestroy {
const freshness = this.bundleFreshness();
const dateStr = state.bundleCreatedAt
? new Date(state.bundleCreatedAt).toLocaleDateString('en-US', {
? this.dateFmt.toLocaleDateString(state.bundleCreatedAt, {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
})
: 'unknown';

View File

@@ -30,13 +30,23 @@ import {
LocalizationConfig,
NotifyIncident,
} from '../../core/api/notify.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incidents' | 'config';
const NOTIFY_ADMIN_TABS: StellaPageTab[] = [
{ id: 'channels', label: 'Channels', icon: 'M22 2L11 13|||M22 2l-7 20-4-9-9-4 20-7z' },
{ id: 'rules', label: 'Rules', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z' },
{ id: 'templates', label: 'Templates', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'deliveries', label: 'Deliveries', icon: 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9|||M13.73 21a2 2 0 0 1-3.46 0' },
{ id: 'incidents', label: 'Incidents', icon: '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|||M12 9v4|||M12 17h.01' },
{ id: 'config', label: 'Config', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
];
@Component({
selector: 'app-admin-notifications',
standalone: true,
imports: [CommonModule, RouterModule],
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
template: `
<div class="admin-notifications-container">
<header class="page-header">
@@ -107,26 +117,12 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
}
<!-- Tabs -->
<div class="tabs">
<button class="tab" [class.active]="activeTab() === 'channels'" (click)="activeTab.set('channels')">
Channels
</button>
<button class="tab" [class.active]="activeTab() === 'rules'" (click)="activeTab.set('rules')">
Rules
</button>
<button class="tab" [class.active]="activeTab() === 'templates'" (click)="activeTab.set('templates')">
Templates
</button>
<button class="tab" [class.active]="activeTab() === 'deliveries'" (click)="activeTab.set('deliveries')">
Delivery Audit
</button>
<button class="tab" [class.active]="activeTab() === 'incidents'" (click)="activeTab.set('incidents')">
Incidents
</button>
<button class="tab" [class.active]="activeTab() === 'config'" (click)="activeTab.set('config')">
Configuration
</button>
</div>
<stella-page-tabs
[tabs]="NOTIFY_ADMIN_TABS"
[activeTab]="activeTab()"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Notification admin tabs"
/>
<!-- Channels Tab -->
@if (activeTab() === 'channels') {
@@ -139,7 +135,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
@if (loading()) {
<div class="loading">Loading channels...</div>
} @else {
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Name</th>
@@ -197,7 +193,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
@if (loading()) {
<div class="loading">Loading rules...</div>
} @else {
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Name</th>
@@ -284,7 +280,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
} @else if (deliveries().length === 0) {
<div class="empty-state">No delivery records found.</div>
} @else {
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Timestamp</th>
@@ -336,7 +332,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
<p class="text-muted">Incidents are created when escalation policies are triggered.</p>
</div>
} @else {
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>ID</th>
@@ -477,24 +473,25 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; border-bottom: 1px solid var(--color-border-primary); flex-wrap: wrap; }
.tab {
padding: 0.75rem 1rem; border: none; background: none; cursor: pointer;
font-size: 0.875rem; color: var(--color-text-secondary); border-bottom: 2px solid transparent;
height: var(--color-tab-height, 48px); padding: 0 1rem; display: inline-flex; align-items: center;
border: none; background: var(--color-tab-bg, transparent); cursor: pointer;
font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-tab-inactive-text);
border-bottom: 2px solid transparent; transition: color 0.15s, border-color 0.15s, background-color 0.15s;
}
.tab.active { color: var(--color-status-info-text); border-bottom-color: var(--color-status-info-text); font-weight: var(--font-weight-semibold); }
.tab:hover { color: var(--color-text-primary); background: var(--color-tab-hover-bg); }
.tab.active { color: var(--color-tab-active-text); border-bottom-color: var(--color-tab-active-border); font-weight: var(--font-weight-semibold, 600); }
.tab-content { background: var(--color-surface-primary); border-radius: var(--radius-lg); }
.tab-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.tab-header h2 { margin: 0; font-size: 1.25rem; }
.btn-primary {
background: var(--color-status-info-text); color: white; border: none; padding: 0.5rem 1rem;
background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: none; padding: 0.5rem 1rem;
border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-semibold);
}
.btn-secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-secondary { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-icon { background: none; border: none; color: var(--color-status-info-text); cursor: pointer; padding: 0.25rem 0.5rem; }
.btn-icon.danger { color: var(--color-severity-critical); }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-surface-secondary); }
.data-table th { background: var(--color-surface-primary); font-weight: var(--font-weight-semibold); font-size: 0.875rem; color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-secondary); font-size: 0.875rem; }
@@ -508,11 +505,11 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
.target-cell { font-family: monospace; font-size: 0.875rem; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
.status-badge { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); }
.status-badge.enabled { background: var(--color-status-success-border); color: var(--color-status-success-text); }
.status-badge.disabled { background: var(--color-status-error-border); color: var(--color-status-error-text); }
.status-badge.enabled { background: var(--color-status-success-border); color: #fff; }
.status-badge.disabled { background: var(--color-status-error-border); color: #fff; }
.health-indicator { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; }
.health-indicator.healthy { background: var(--color-status-success-border); color: var(--color-status-success-text); }
.health-indicator.healthy { background: var(--color-status-success-border); color: #fff; }
.event-types { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.tag { background: var(--color-status-info-bg); color: var(--color-status-info-text); padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; }
@@ -527,18 +524,18 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
.filters-row select { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
.delivery-status { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); }
.status-sent { background: var(--color-status-success-border); color: var(--color-status-success-text); }
.status-sent { background: var(--color-status-success-border); color: #fff; }
.status-pending { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
.status-failed { background: var(--color-status-error-border); color: var(--color-status-error-text); }
.status-failed { background: var(--color-status-error-border); color: #fff; }
.status-throttled { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.status-digested { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.error-text { color: var(--color-status-error-text); font-size: 0.875rem; }
.incident-id { font-family: monospace; font-size: 0.875rem; }
.incident-status { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); }
.status-open { background: var(--color-status-error-border); color: var(--color-status-error-text); }
.status-open { background: var(--color-status-error-border); color: #fff; }
.status-acknowledged { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
.status-resolved { background: var(--color-status-success-border); color: var(--color-status-success-text); }
.status-resolved { background: var(--color-status-success-border); color: #fff; }
.setup-guidance {
margin-bottom: 1.5rem;
@@ -574,6 +571,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
export class AdminNotificationsComponent implements OnInit {
private readonly api = inject<NotifyApi>(NOTIFY_API);
readonly NOTIFY_ADMIN_TABS = NOTIFY_ADMIN_TABS;
readonly activeTab = signal<NotifyAdminTab>('channels');
readonly loading = signal(false);
readonly error = signal<string | null>(null);

View File

@@ -4,6 +4,7 @@
*/
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
import {
ChangeDetectionStrategy,
Component,
@@ -32,7 +33,7 @@ interface ChannelTypeOption {
@Component({
selector: 'app-channel-management',
imports: [FormsModule, ReactiveFormsModule],
imports: [FormsModule, ReactiveFormsModule, LoadingStateComponent],
template: `
<div class="channel-management">
<!-- Channel List View -->
@@ -60,10 +61,7 @@ interface ChannelTypeOption {
<!-- Loading -->
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<span>Loading channels...</span>
</div>
<app-loading-state size="md" message="Loading channels..." />
}
<!-- Channel Cards -->
@@ -659,13 +657,13 @@ interface ChannelTypeOption {
}
.btn-primary {
background: var(--color-status-info-text);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: none;
}
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.loading-state, .empty-state {
display: flex;

View File

@@ -247,7 +247,7 @@ import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.
}
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.loading-state { padding: 3rem; text-align: center; color: var(--color-text-secondary); }

View File

@@ -4,6 +4,7 @@
*/
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
import {
ChangeDetectionStrategy,
Component,
@@ -24,7 +25,7 @@ import {
@Component({
selector: 'app-delivery-history',
imports: [FormsModule],
imports: [FormsModule, LoadingStateComponent],
template: `
<div class="delivery-history">
<!-- Statistics Summary -->
@@ -100,16 +101,13 @@ import {
<!-- Loading -->
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<span>Loading delivery history...</span>
</div>
<app-loading-state size="md" message="Loading delivery history..." />
}
<!-- Delivery Table -->
@if (!loading() && deliveries().length > 0) {
<div class="table-container">
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Timestamp</th>
@@ -349,7 +347,7 @@ import {
.stat-value.failed { color: var(--color-status-error); }
.stat-value.pending { color: var(--color-status-warning-text); }
.stat-value.throttled { color: var(--color-status-info-text); }
.stat-value.rate { color: var(--color-brand-secondary); }
.stat-value.rate { color: var(--color-text-link); }
.stat-label {
font-size: 0.75rem;
@@ -391,18 +389,12 @@ import {
cursor: pointer;
}
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;

View File

@@ -250,9 +250,9 @@ import {
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; }
.btn-icon.btn-danger { color: var(--color-status-error); }

View File

@@ -8,11 +8,14 @@ import {
ChangeDetectionStrategy,
Component,
OnInit,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { Router, RouterModule, ActivatedRoute } from '@angular/router';
import { Router, RouterModule, ActivatedRoute, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
@@ -22,16 +25,51 @@ import {
NotifierDelivery,
NotifierDeliveryStats,
} from '../../../core/api/notifier.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component';
export type NotificationTab = 'rules' | 'channels' | 'templates' | 'delivery' | 'simulator' | 'config';
interface TabDefinition {
id: NotificationTab;
label: string;
description: string;
icon: string;
route: string;
}
const TAB_ROUTE_MAP: Record<NotificationTab, string> = {
rules: 'rules',
channels: 'channels',
templates: 'templates',
delivery: 'delivery',
simulator: 'simulator',
config: 'config',
};
const NOTIFICATION_TABS: readonly StellaPageTab[] = [
{
id: 'rules',
label: 'Rules',
icon: 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9|||M13.73 21a2 2 0 0 1-3.46 0',
},
{
id: 'channels',
label: 'Channels',
icon: 'M22 12h-4l-3 9L9 3l-3 9H2',
},
{
id: 'templates',
label: 'Templates',
icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8',
},
{
id: 'delivery',
label: 'Delivery',
icon: 'M22 2L11 13|||M22 2l-7 20-4-9-9-4 20-7z',
},
{
id: 'simulator',
label: 'Simulator',
icon: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z',
},
{
id: 'config',
label: 'Config',
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
},
];
interface ConfigSubTab {
id: string;
@@ -41,503 +79,439 @@ interface ConfigSubTab {
@Component({
selector: 'app-notification-dashboard',
imports: [RouterModule],
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
template: `
<div class="notification-dashboard">
<header class="dashboard-header">
<div class="header-content">
<div class="nd">
<header class="nd__header">
<div class="nd__title-block">
<p class="nd__eyebrow">Setup</p>
<h1>Notification Administration</h1>
<p class="subtitle">Configure notification rules, channels, templates, and view delivery history</p>
<p class="nd__lede">Configure notification rules, channels, templates, and monitor delivery health.</p>
</div>
<div class="header-actions">
<button class="btn btn-secondary" (click)="refreshStats()">
Refresh Stats
<div class="nd__actions">
<a routerLink="/ops/operations/notifications" class="nd__btn nd__btn--secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
Operator Console
</a>
<button
type="button"
class="nd__btn nd__btn--secondary"
(click)="refreshStats()"
[disabled]="loadingStats()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
{{ loadingStats() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
<section class="owner-banner">
<div>
<strong>Setup-owned notification studio</strong>
<p>
Use this setup surface for channel lifecycle, routing policy, templates, throttles, and escalation design.
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
</p>
</div>
<div class="owner-banner__actions">
<a routerLink="/ops/operations/notifications" class="btn btn-secondary">Open operator console</a>
<a
routerLink="/setup-wizard/wizard"
[queryParams]="{ step: 'notifications', mode: 'reconfigure' }"
class="btn btn-secondary"
>
Re-run notifications setup
</a>
</div>
</section>
<!-- Statistics Overview -->
<section class="stats-overview" [class.loading]="loadingStats()">
<div class="stat-card">
<div class="stat-icon rules-icon">R</div>
<div class="stat-content">
<span class="stat-value">{{ stats()?.totalRules ?? '-' }}</span>
<span class="stat-label">Active Rules</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon channels-icon">C</div>
<div class="stat-content">
<span class="stat-value">{{ stats()?.totalChannels ?? '-' }}</span>
<span class="stat-label">Channels</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon sent-icon">S</div>
<div class="stat-content">
<span class="stat-value">{{ deliveryStats()?.totalSent ?? '-' }}</span>
<span class="stat-label">Sent (24h)</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon failed-icon">F</div>
<div class="stat-content">
<span class="stat-value">{{ deliveryStats()?.totalFailed ?? '-' }}</span>
<span class="stat-label">Failed (24h)</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending-icon">P</div>
<div class="stat-content">
<span class="stat-value">{{ deliveryStats()?.totalPending ?? '-' }}</span>
<span class="stat-label">Pending</span>
</div>
</div>
<div class="stat-card success-rate">
<div class="stat-icon rate-icon">%</div>
<div class="stat-content">
<span class="stat-value">{{ successRateDisplay() }}</span>
<span class="stat-label">Success Rate</span>
</div>
</div>
</section>
<!-- Tab Navigation -->
<nav class="tab-navigation" role="tablist">
@for (tab of tabs; track tab.id) {
<a
class="tab-button"
[class.active]="activeTab() === tab.id"
[attr.aria-selected]="activeTab() === tab.id"
[attr.aria-controls]="'panel-' + tab.id"
[attr.aria-label]="tab.label"
role="tab"
[routerLink]="tab.route"
queryParamsHandling="merge"
routerLinkActive="active"
(click)="setActiveTab(tab.id)">
<span class="tab-icon" aria-hidden="true">{{ tab.icon }}</span>
<span class="tab-label">{{ tab.label }}</span>
</a>
}
</nav>
<!-- Config Sub-Navigation -->
@if (activeTab() === 'config') {
<nav class="sub-navigation" role="tablist">
@for (subTab of configSubTabs; track subTab.id) {
<a
class="sub-tab-button"
[routerLink]="subTab.route"
[attr.aria-label]="subTab.label"
queryParamsHandling="merge"
routerLinkActive="active">
{{ subTab.label }}
</a>
<!-- Stats Strip -->
@if (loadingStats() && !stats()) {
<div class="nd__stats">
@for (i of [1,2,3,4,5,6]; track i) {
<div class="nd__stat nd__stat--skeleton">
<div class="nd__stat-icon-skel"></div>
<div class="nd__stat-text-skel">
<div class="skel-line skel-line--value"></div>
<div class="skel-line skel-line--label"></div>
</div>
</div>
}
</nav>
</div>
} @else {
<div class="nd__stats" [class.nd__stats--loading]="loadingStats()">
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--rules">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value">{{ stats()?.totalRules ?? 0 }}</span>
<span class="nd__stat-label">Active Rules</span>
</div>
</div>
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--channels">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value">{{ stats()?.totalChannels ?? 0 }}</span>
<span class="nd__stat-label">Channels</span>
</div>
</div>
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--sent">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value">{{ deliveryStats()?.totalSent ?? 0 }}</span>
<span class="nd__stat-label">Sent (24h)</span>
</div>
</div>
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--failed">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value" [class.nd__stat-value--alert]="(deliveryStats()?.totalFailed ?? 0) > 0">{{ deliveryStats()?.totalFailed ?? 0 }}</span>
<span class="nd__stat-label">Failed (24h)</span>
</div>
</div>
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--pending">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value">{{ deliveryStats()?.totalPending ?? 0 }}</span>
<span class="nd__stat-label">Pending</span>
</div>
</div>
<div class="nd__stat">
<div class="nd__stat-icon nd__stat-icon--rate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
</div>
<div class="nd__stat-content">
<span class="nd__stat-value">{{ successRateDisplay() }}</span>
<span class="nd__stat-label">Success Rate</span>
</div>
</div>
</div>
}
<!-- Delivery Sub-Navigation -->
@if (activeTab() === 'delivery') {
<nav class="sub-navigation" role="tablist">
<a class="sub-tab-button" routerLink="delivery" queryParamsHandling="merge" aria-label="History" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
<a class="sub-tab-button" routerLink="delivery/analytics" queryParamsHandling="merge" aria-label="Analytics" routerLinkActive="active">Analytics</a>
</nav>
}
<!-- Tabs -->
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Notification administration tabs"
(tabChange)="onTabChange($event)"
>
<!-- Config Sub-Navigation -->
@if (activeTab() === 'config') {
<nav class="nd__sub-nav" role="tablist">
@for (subTab of configSubTabs; track subTab.id) {
<a
class="nd__sub-tab"
[routerLink]="subTab.route"
queryParamsHandling="merge"
routerLinkActive="nd__sub-tab--active"
[attr.aria-label]="subTab.label"
>
{{ subTab.label }}
</a>
}
</nav>
}
<!-- Tab Content -->
<main class="tab-content" role="tabpanel" [attr.id]="'panel-' + activeTab()">
<div class="content-section">
<!-- Delivery Sub-Navigation -->
@if (activeTab() === 'delivery') {
<nav class="nd__sub-nav" role="tablist">
<a class="nd__sub-tab" routerLink="delivery" queryParamsHandling="merge" routerLinkActive="nd__sub-tab--active" [routerLinkActiveOptions]="{exact: true}">History</a>
<a class="nd__sub-tab" routerLink="delivery/analytics" queryParamsHandling="merge" routerLinkActive="nd__sub-tab--active">Analytics</a>
</nav>
}
<div class="nd__content">
<router-outlet></router-outlet>
</div>
</main>
</stella-page-tabs>
@if (error()) {
<div class="error-banner" role="alert">
<span class="error-icon">!</span>
<span class="error-message">{{ error() }}</span>
<button class="error-dismiss" (click)="dismissError()">Dismiss</button>
<div class="nd__toast" role="alert">
<svg class="nd__toast-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span class="nd__toast-msg">{{ error() }}</span>
<button class="nd__toast-dismiss" (click)="dismissError()">Dismiss</button>
</div>
}
</div>
`,
styles: [`
.notification-dashboard {
padding: 1.5rem;
:host { display: block; }
.nd {
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
padding: 1.5rem;
}
.dashboard-header {
/* ---- Header ---- */
.nd__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.header-content h1 {
.nd__eyebrow {
margin: 0;
color: var(--color-status-info);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.nd__title-block h1 {
margin: 0.2rem 0 0.4rem;
font-size: 1.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-surface-inverse);
color: var(--color-text-heading);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.9375rem;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.owner-banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.owner-banner strong {
display: block;
margin-bottom: 0.35rem;
}
.owner-banner p {
.nd__lede {
margin: 0;
color: var(--color-text-secondary);
max-width: 64ch;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.5;
}
.owner-banner__actions {
.nd__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
}
/* Statistics Overview */
.stats-overview {
.nd__btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.875rem;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease, opacity 150ms ease;
text-decoration: none;
white-space: nowrap;
}
.nd__btn--secondary {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
}
.nd__btn--secondary:hover:not(:disabled) {
border-color: var(--color-border-secondary);
background: var(--color-surface-secondary);
}
.nd__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- Stats Strip ---- */
.nd__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
transition: opacity 200ms ease;
}
.stats-overview.loading {
opacity: 0.6;
.nd__stats--loading {
opacity: 0.55;
pointer-events: none;
}
.stat-card {
.nd__stat {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
padding: 0.875rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
transition: box-shadow 0.2s, transform 0.2s;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
.nd__stat:hover {
border-color: var(--color-border-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-icon {
width: 40px;
height: 40px;
.nd__stat-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: 1rem;
color: white;
flex-shrink: 0;
}
.rules-icon { background: var(--color-status-info); }
.channels-icon { background: var(--color-status-excepted); }
.sent-icon { background: var(--color-status-success); }
.failed-icon { background: var(--color-status-error); }
.pending-icon { background: var(--color-status-warning); }
.rate-icon { background: var(--color-brand-secondary); }
.nd__stat-icon--rules { background: rgba(34, 211, 238, 0.12); color: var(--color-status-info); }
.nd__stat-icon--channels { background: rgba(167, 139, 250, 0.12); color: var(--color-status-excepted-border); }
.nd__stat-icon--sent { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
.nd__stat-icon--failed { background: rgba(239, 68, 68, 0.10); color: var(--color-status-error); }
.nd__stat-icon--pending { background: rgba(251, 191, 36, 0.12); color: var(--color-status-warning-border); }
.nd__stat-icon--rate { background: rgba(99, 102, 241, 0.12); color: var(--color-brand-primary); }
.stat-content {
.nd__stat-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-value {
font-size: 1.5rem;
.nd__stat-value {
font-size: 1.375rem;
font-weight: var(--font-weight-bold);
color: var(--color-surface-inverse);
line-height: 1.2;
color: var(--color-text-heading);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
.nd__stat-value--alert {
color: var(--color-status-error);
}
.nd__stat-label {
font-size: 0.6875rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
letter-spacing: 0.03em;
}
/* Tab Navigation */
.tab-navigation {
/* ---- Skeleton Stats ---- */
.nd__stat--skeleton {
pointer-events: none;
}
.nd__stat-icon-skel {
width: 36px;
height: 36px;
border-radius: var(--radius-lg);
flex-shrink: 0;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
background-size: 200% 100%;
animation: nd-shimmer 1.5s ease-in-out infinite;
}
.nd__stat-text-skel {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.skel-line {
border-radius: var(--radius-sm);
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
background-size: 200% 100%;
animation: nd-shimmer 1.5s ease-in-out infinite;
}
.skel-line--value { height: 1.25rem; width: 2.5rem; }
.skel-line--label { height: 0.6rem; width: 5rem; }
@keyframes nd-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ---- Tabs container override ---- */
stella-page-tabs {
display: block;
}
/* ---- Sub-navigation (config & delivery) ---- */
.nd__sub-nav {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 1.5rem;
overflow-x: auto;
scrollbar-width: thin;
}
.tab-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.tab-button:hover {
color: var(--color-status-info-text);
background: var(--color-surface-secondary);
}
.tab-button.active {
color: var(--color-status-info-text);
border-bottom-color: var(--color-status-info-text);
}
.tab-icon {
font-size: 1rem;
}
/* Sub-Navigation */
.sub-navigation {
display: flex;
gap: 0.25rem;
padding: 0.5rem 1rem;
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 0;
}
.sub-tab-button {
padding: 0.5rem 1rem;
.nd__sub-tab {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
font-size: 0.8125rem;
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease;
}
.sub-tab-button:hover {
background: var(--color-surface-primary);
color: var(--color-status-info-text);
}
.sub-tab-button.active {
background: var(--color-surface-primary);
color: var(--color-status-info-text);
border-color: var(--color-border-primary);
box-shadow: var(--shadow-sm);
}
/* Tab Content */
.tab-content {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
min-height: 400px;
}
.content-section {
padding: 1.5rem;
}
.section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.section-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
flex: 0 0 auto;
}
.section-header p {
flex: 1 1 100%;
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
order: 3;
}
.section-header .btn {
margin-left: auto;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.btn-primary {
background: var(--color-status-info-text);
color: var(--color-btn-primary-text);
border-color: var(--color-status-info-text);
}
.btn-primary:hover {
background: var(--color-status-info-text);
border-color: var(--color-status-info-text);
}
.btn-secondary {
background: var(--color-surface-primary);
.nd__sub-tab:hover {
color: var(--color-text-primary);
border-color: var(--color-border-secondary);
background: var(--color-surface-tertiary);
}
.btn-secondary:hover {
background: var(--color-surface-primary);
border-color: var(--color-text-muted);
.nd__sub-tab--active {
color: var(--color-tab-active-text);
background: var(--color-surface-tertiary);
border-color: var(--color-border-primary);
}
/* Error Banner */
.error-banner {
/* ---- Content area ---- */
.nd__content {
min-height: 350px;
}
/* ---- Error toast ---- */
.nd__toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: 0.6rem;
padding: 0.65rem 1rem;
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-lg);
color: var(--color-status-error-text);
box-shadow: var(--shadow-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 1000;
animation: nd-toast-in 200ms ease;
}
.error-icon {
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: var(--color-status-error);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: 0.875rem;
@keyframes nd-toast-in {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.error-message {
.nd__toast-icon {
color: var(--color-status-error);
flex-shrink: 0;
}
.nd__toast-msg {
font-size: 0.8125rem;
flex: 1;
font-size: 0.875rem;
}
.error-dismiss {
padding: 0.25rem 0.5rem;
.nd__toast-dismiss {
padding: 0.2rem 0.5rem;
border: none;
background: transparent;
color: var(--color-status-error-text);
font-size: 0.75rem;
cursor: pointer;
text-decoration: underline;
opacity: 0.8;
transition: opacity 150ms;
}
/* Responsive */
.nd__toast-dismiss:hover { opacity: 1; }
/* ---- Responsive ---- */
@media (max-width: 768px) {
.notification-dashboard {
padding: 1rem;
}
.owner-banner {
flex-direction: column;
}
.stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.tab-button {
padding: 0.5rem 0.75rem;
}
.tab-label {
display: none;
}
.section-header {
flex-direction: column;
align-items: flex-start;
}
.section-header .btn {
margin-left: 0;
}
.nd { padding: 1rem; }
.nd__header { flex-direction: column; }
.nd__stats { grid-template-columns: repeat(2, 1fr); }
.nd__sub-nav { overflow-x: auto; scrollbar-width: none; }
.nd__sub-nav::-webkit-scrollbar { display: none; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -546,15 +520,9 @@ export class NotificationDashboardComponent implements OnInit {
private readonly api = inject<NotifierApi>(NOTIFIER_API);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly tabs: TabDefinition[] = [
{ id: 'rules', label: 'Rules', description: 'Notification rules', icon: 'R', route: 'rules' },
{ id: 'channels', label: 'Channels', description: 'Delivery channels', icon: 'C', route: 'channels' },
{ id: 'templates', label: 'Templates', description: 'Message templates', icon: 'T', route: 'templates' },
{ id: 'delivery', label: 'Delivery', description: 'Delivery history & analytics', icon: 'D', route: 'delivery' },
{ id: 'simulator', label: 'Simulator', description: 'Test rules', icon: 'S', route: 'simulator' },
{ id: 'config', label: 'Config', description: 'Quiet hours, overrides, escalation, throttling', icon: '*', route: 'config' },
];
readonly pageTabs: readonly StellaPageTab[] = NOTIFICATION_TABS;
readonly configSubTabs: ConfigSubTab[] = [
{ id: 'quiet-hours', label: 'Quiet Hours', route: 'config/quiet-hours' },
@@ -583,14 +551,16 @@ export class NotificationDashboardComponent implements OnInit {
});
async ngOnInit(): Promise<void> {
// Determine initial tab from route
const path = this.route.snapshot.firstChild?.routeConfig?.path;
if (path) {
const matchedTab = this.tabs.find(t => t.route === path || path.startsWith(t.route));
if (matchedTab) {
this.activeTab.set(matchedTab.id);
}
}
this.setActiveTabFromUrl(this.router.url);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
this.setActiveTabFromUrl(event.urlAfterRedirects);
});
await this.loadInitialData();
}
@@ -620,8 +590,11 @@ export class NotificationDashboardComponent implements OnInit {
await this.loadInitialData();
}
setActiveTab(tabId: NotificationTab): void {
this.activeTab.set(tabId);
onTabChange(tabId: string): void {
const tab = tabId as NotificationTab;
this.activeTab.set(tab);
const routePath = TAB_ROUTE_MAP[tab] ?? tab;
this.router.navigate([routePath], { relativeTo: this.route, queryParamsHandling: 'merge' });
}
async refreshDeliveryHistory(): Promise<void> {
@@ -636,4 +609,15 @@ export class NotificationDashboardComponent implements OnInit {
dismissError(): void {
this.error.set(null);
}
private setActiveTabFromUrl(url: string): void {
const segments = url.split('?')[0].split('/').filter(Boolean);
const knownTabs: NotificationTab[] = ['rules', 'channels', 'templates', 'delivery', 'simulator', 'config'];
const matched = segments.find(s => knownTabs.includes(s as NotificationTab));
if (matched) {
this.activeTab.set(matched as NotificationTab);
} else {
this.activeTab.set('rules');
}
}
}

View File

@@ -405,7 +405,7 @@ import {
}
.btn-primary {
background: var(--color-status-info-text);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: none;
}

View File

@@ -4,6 +4,7 @@
*/
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
import {
ChangeDetectionStrategy,
Component,
@@ -21,7 +22,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
@Component({
selector: 'app-notification-rule-list',
imports: [FormsModule],
imports: [FormsModule, LoadingStateComponent],
template: `
<div class="rule-list-container">
<!-- Filters -->
@@ -51,10 +52,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
<!-- Loading State -->
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<span>Loading rules...</span>
</div>
<app-loading-state size="md" message="Loading rules..." />
}
<!-- Empty State -->
@@ -74,7 +72,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
<!-- Rules Table -->
@if (!loading() && filteredRules().length > 0) {
<div class="table-container">
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th class="col-status">Status</th>
@@ -232,7 +230,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
}
.btn-primary {
background: var(--color-status-info-text);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: none;
}
@@ -291,12 +289,6 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;

View File

@@ -279,9 +279,9 @@ import {
}
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; }
.btn-icon.btn-danger { color: var(--color-status-error); }

View File

@@ -4,6 +4,7 @@
*/
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
import {
ChangeDetectionStrategy,
Component,
@@ -28,7 +29,7 @@ import {
@Component({
selector: 'app-operator-override',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule],
imports: [CommonModule, FormsModule, ReactiveFormsModule, LoadingStateComponent],
template: `
<div class="operator-override">
<header class="section-header">
@@ -49,7 +50,7 @@ import {
<!-- Loading -->
@if (loading()) {
<div class="loading-state">Loading...</div>
<app-loading-state size="md" />
}
<!-- Filters -->
@@ -327,8 +328,8 @@ import {
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; }
.btn-icon.btn-danger { color: var(--color-status-error); }

View File

@@ -4,6 +4,7 @@
*/
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
import {
ChangeDetectionStrategy,
Component,
@@ -19,7 +20,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
@Component({
selector: 'app-quiet-hours-config',
imports: [FormsModule, ReactiveFormsModule],
imports: [FormsModule, ReactiveFormsModule, LoadingStateComponent],
template: `
<div class="quiet-hours-config">
<header class="section-header">
@@ -32,7 +33,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
<!-- Loading -->
@if (loading()) {
<div class="loading-state">Loading...</div>
<app-loading-state size="md" />
}
<!-- Quiet Hours List -->
@@ -231,8 +232,8 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; }
.btn-icon.btn-danger { color: var(--color-status-error); }

View File

@@ -340,9 +340,9 @@ import {
flex: 1;
}
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
.quick-templates {

View File

@@ -345,9 +345,9 @@ import {
cursor: pointer;
}
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
.btn-icon {

View File

@@ -294,9 +294,9 @@ type ThrottleScope = 'global' | 'channel' | 'rule' | 'event';
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
.btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; }
.btn-primary { background: var(--color-status-info-text); color: var(--color-btn-primary-text); border: none; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid var(--color-btn-primary-border, transparent); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-primary); color: var(--color-text-primary); border: 1px solid var(--color-border-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); }
.btn-icon { padding: 0.25rem 0.5rem; background: transparent; border: none; color: var(--color-status-info-text); font-size: 0.75rem; cursor: pointer; }
.btn-icon.btn-danger { color: var(--color-status-error); }

View File

@@ -61,22 +61,22 @@ interface SetupCard {
<aside class="admin-overview__aside">
<section class="admin-overview__aside-section">
<h2 class="admin-overview__section-heading">First-Time Setup Path</h2>
<div class="admin-overview__quick-actions">
<a routerLink="/setup-wizard/wizard">Start guided setup</a>
<a routerLink="/setup/identity-access">1. Identity &amp; Access</a>
<a routerLink="/setup/trust-signing">2. Trust &amp; Signing</a>
<a routerLink="/setup/integrations">3. Integrations</a>
<a routerLink="/setup/topology/overview">4. Topology</a>
<a routerLink="/setup/notifications">5. Notifications</a>
<div class="quick-links-row">
<a routerLink="/setup-wizard/wizard" class="quick-link-pill">Start guided setup</a>
<a routerLink="/setup/identity-access" class="quick-link-pill">1. Identity &amp; Access</a>
<a routerLink="/setup/trust-signing" class="quick-link-pill">2. Trust &amp; Signing</a>
<a routerLink="/setup/integrations" class="quick-link-pill">3. Integrations</a>
<a routerLink="/setup/topology/overview" class="quick-link-pill">4. Topology</a>
<a routerLink="/setup/notifications" class="quick-link-pill">5. Notifications</a>
</div>
</section>
<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 class="quick-links-row">
<a routerLink="/setup/topology/targets" class="quick-link-pill">Add Target</a>
<a routerLink="/setup/integrations" class="quick-link-pill">Configure Integrations</a>
<a routerLink="/setup/identity-access" class="quick-link-pill">Review Access</a>
</div>
</section>
@@ -213,26 +213,7 @@ interface SetupCard {
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);
}
/* Quick Actions — uses global .quick-links-row / .quick-link-pill */
.admin-overview__links {
list-style: none;

View File

@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
}
.evidence-type-badge.type-patch {
color: var(--color-brand-primary);
color: var(--color-text-link);
background: var(--color-status-excepted-bg);
}

View File

@@ -471,7 +471,7 @@ import type {
}
.citation-type.type-patch {
color: var(--color-brand-primary);
color: var(--color-text-link);
background: var(--color-status-excepted-bg);
}

View File

@@ -1,5 +1,7 @@
import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core';
import { Component, EventEmitter, Input, Output, signal, computed,
inject,} from '@angular/core';
import { DateFormatService } from '../../core/i18n/date-format.service';
import type {
PullRequestInfo,
PullRequestStatus,
@@ -581,6 +583,8 @@ import type {
`]
})
export class PrTrackerComponent {
private readonly dateFmt = inject(DateFormatService);
@Input() pullRequest: PullRequestInfo | null = null;
@Output() readonly merge = new EventEmitter<void>();
@@ -658,7 +662,7 @@ export class PrTrackerComponent {
formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {
return new Date(iso).toLocaleDateString(this.dateFmt.locale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -549,7 +549,7 @@ import type {
}
.step-type.type-vex_document {
color: var(--color-brand-primary);
color: var(--color-text-link);
background: var(--color-status-excepted-bg);
}

View File

@@ -9,6 +9,7 @@
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { AgentStore } from './services/agent.store';
import {
@@ -24,8 +25,18 @@ import { AgentHealthTabComponent } from './components/agent-health-tab/agent-hea
import { AgentTasksTabComponent } from './components/agent-tasks-tab/agent-tasks-tab.component';
import { AgentActionModalComponent } from './components/agent-action-modal/agent-action-modal.component';
import { DateFormatService } from '../../core/i18n/date-format.service';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
const AGENT_DETAIL_TABS: readonly StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10' },
{ id: 'health', label: 'Health', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'tasks', label: 'Tasks', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
{ id: 'logs', label: 'Logs', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'config', label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
];
interface ActionFeedback {
type: 'success' | 'error';
message: string;
@@ -33,7 +44,7 @@ interface ActionFeedback {
@Component({
selector: 'st-agent-detail-page',
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent],
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent, LoadingStateComponent, StellaPageTabsComponent],
template: `
<div class="agent-detail-page">
<!-- Breadcrumb -->
@@ -44,10 +55,7 @@ interface ActionFeedback {
</nav>
@if (store.isLoading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading agent details...</p>
</div>
<app-loading-state size="lg" message="Loading agent details..." />
} @else if (store.error()) {
<div class="error-state">
<p class="error-state__message">{{ store.error() }}</p>
@@ -127,23 +135,12 @@ interface ActionFeedback {
</div>
<!-- Tabs -->
<nav class="tabs" role="tablist">
@for (tab of tabs; track tab.id) {
<button
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === tab.id"
[attr.aria-selected]="activeTab() === tab.id"
(click)="setActiveTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<!-- Tab Content -->
<div class="tab-content" role="tabpanel">
<stella-page-tabs
[tabs]="tabs"
[activeTab]="activeTab()"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Agent detail tabs"
>
@switch (activeTab()) {
@case ('overview') {
<section class="overview-section">
@@ -310,7 +307,7 @@ interface ActionFeedback {
</section>
}
}
</div>
</stella-page-tabs>
}
<!-- Action Confirmation Modal -->
@@ -371,7 +368,7 @@ interface ActionFeedback {
}
.breadcrumb__link {
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
&:hover {
@@ -452,34 +449,6 @@ interface ActionFeedback {
color: var(--color-text-secondary);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s;
&:hover {
color: var(--color-text-primary);
}
&--active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
}
/* Stats Grid */
.stats-grid {
display: grid;
@@ -773,6 +742,8 @@ interface ActionFeedback {
})
export class AgentDetailPageComponent implements OnInit {
private readonly dateFmt = inject(DateFormatService);
readonly store = inject(AgentStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@@ -785,13 +756,7 @@ export class AgentDetailPageComponent implements OnInit {
readonly actionFeedback = signal<ActionFeedback | null>(null);
private feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
readonly tabs: { id: DetailTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'health', label: 'Health' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Configuration' },
];
readonly tabs = AGENT_DETAIL_TABS;
readonly agent = computed(() => this.store.selectedAgent());
readonly statusColor = computed(() =>
@@ -811,10 +776,6 @@ export class AgentDetailPageComponent implements OnInit {
}
}
setActiveTab(tab: DetailTab): void {
this.activeTab.set(tab);
}
toggleActionsMenu(): void {
this.showActionsMenu.update((v) => !v);
}
@@ -889,7 +850,7 @@ export class AgentDetailPageComponent implements OnInit {
}
formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
return new Date(iso).toLocaleDateString(this.dateFmt.locale(), {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -10,6 +10,7 @@ import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { AgentStore } from './services/agent.store';
import { Agent, AgentStatus, getStatusColor, getStatusLabel } from './models/agent.models';
@@ -21,7 +22,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
@Component({
selector: 'st-agent-fleet-dashboard',
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent],
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent, LoadingStateComponent],
template: `
<div class="agent-fleet-dashboard">
<!-- Page Header -->
@@ -218,10 +219,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
<!-- Loading State -->
@if (store.isLoading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading agents...</p>
</div>
<app-loading-state size="lg" message="Loading agents..." />
}
<!-- Error State -->
@@ -410,7 +408,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
.btn--text {
background: transparent;
color: var(--color-brand-primary);
color: var(--color-text-link);
padding: 0.5rem;
&:hover {

View File

@@ -208,7 +208,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
.wizard-header__back {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
font-size: 0.875rem;
@@ -270,16 +270,16 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
.progress-step--active .progress-step__number {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.progress-step--active .progress-step__label {
color: var(--color-brand-primary);
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
}
.progress-step--completed .progress-step__number {
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-color: var(--color-brand-primary);
color: white;
}
@@ -466,7 +466,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
summary {
cursor: pointer;
color: var(--color-brand-primary);
color: var(--color-text-link);
}
ul {

View File

@@ -233,7 +233,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
}
.modal__icon--info {
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.modal__close {

View File

@@ -275,7 +275,7 @@ import { AgentHealthResult } from '../../models/agent.models';
summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
&:hover {

View File

@@ -6,12 +6,14 @@
* Shows active and historical tasks for an agent.
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { Component, input, output, signal, computed,
inject,} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AgentTask } from '../../models/agent.models';
import { DateFormatService } from '../../../../core/i18n/date-format.service';
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
@Component({
@@ -229,7 +231,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
}
&--active {
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-color: var(--color-brand-primary);
color: white;
}
@@ -302,7 +304,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
.queue-item__progress-fill {
height: 100%;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
transition: width 0.3s;
}
@@ -360,7 +362,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
&--running {
background: rgba(59, 130, 246, 0.1);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
&--pending {
@@ -429,7 +431,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
color: var(--color-text-secondary);
a {
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
&:hover {
@@ -449,7 +451,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
.task-item__progress-fill {
height: 100%;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-radius: var(--radius-sm);
transition: width 0.3s;
}
@@ -527,7 +529,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
.btn--text {
background: transparent;
color: var(--color-brand-primary);
color: var(--color-text-link);
&:hover {
text-decoration: underline;
@@ -549,6 +551,8 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
`]
})
export class AgentTasksTabComponent {
private readonly dateFmt = inject(DateFormatService);
/** Agent tasks */
readonly tasks = input<AgentTask[]>([]);
@@ -615,7 +619,7 @@ export class AgentTasksTabComponent {
formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
return date.toLocaleString(this.dateFmt.locale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',

View File

@@ -346,7 +346,7 @@ interface ColumnConfig {
}
&.sorted {
color: var(--color-brand-primary);
color: var(--color-text-link);
}
}

View File

@@ -496,7 +496,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border: 2px solid var(--color-surface-primary);
box-shadow: 0 0 0 2px var(--color-brand-primary);
}
@@ -524,7 +524,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.event-time {
@@ -579,7 +579,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
background: none;
border: none;
padding: 0;
color: var(--color-brand-primary);
color: var(--color-text-link);
cursor: pointer;
font-family: monospace;
font-size: 0.8125rem;
@@ -714,7 +714,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.artifact-date {

View File

@@ -427,7 +427,7 @@ const SEVERITY_RANK: Record<string, number> = {
</div>
} @else if (backlogRows().length > 0) {
<div class="table-container">
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Service</th>
@@ -480,7 +480,7 @@ const SEVERITY_RANK: Record<string, number> = {
</div>
} @else if (backlogComponents().length > 0) {
<div class="table-container">
<table class="data-table">
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
<thead>
<tr>
<th>Component</th>
@@ -714,7 +714,7 @@ const SEVERITY_RANK: Record<string, number> = {
}
.metric-row__fill {
height: 100%;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
}
.metric-row__fill--accent {
background: var(--color-status-success);
@@ -825,7 +825,7 @@ const SEVERITY_RANK: Record<string, number> = {
}
.trend-bar {
width: 100%;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-radius: var(--radius-sm) 4px 0 0;
min-height: 6px;
}
@@ -852,7 +852,6 @@ const SEVERITY_RANK: Record<string, number> = {
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 520px;
}
.data-table th,

View File

@@ -9,6 +9,7 @@ import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy }
import { Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AocClient } from '../../core/api/aoc.client';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import {
AocComplianceDashboardData,
AocComplianceMetrics,
@@ -19,7 +20,7 @@ import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-aoc-compliance-dashboard',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, LoadingStateComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="aoc-dashboard">
@@ -35,10 +36,7 @@ import { aocPath } from '../platform/ops/operations-paths';
</header>
@if (loading()) {
<div class="loading-overlay">
<div class="spinner"></div>
<p>Loading compliance data...</p>
</div>
<app-loading-state size="lg" message="Loading compliance data..." />
}
@if (error()) {
@@ -120,7 +118,7 @@ import { aocPath } from '../platform/ops/operations-paths';
<a routerLink="violations" class="view-all">View All <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</header>
<div class="violations-table">
<table>
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Time</th>
@@ -423,7 +421,7 @@ import { aocPath } from '../platform/ops/operations-paths';
align-items: center;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
}
@@ -431,23 +429,6 @@ import { aocPath } from '../platform/ops/operations-paths';
overflow-x: auto;
}
.violations-table table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.violations-table th,
.violations-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
.violations-table th {
background: var(--color-surface-elevated);
font-weight: var(--font-weight-semibold);
}
.timestamp { font-family: monospace; font-size: 0.8rem; }
@@ -557,7 +538,7 @@ import { aocPath } from '../platform/ops/operations-paths';
.bar {
height: 100%;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
transition: width 0.3s ease;
}

View File

@@ -97,7 +97,7 @@ import { aocPath } from '../platform/ops/operations-paths';
.report-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); font-size: 0.9rem; margin: 0; }
.report-config { background: var(--color-surface-primary); border-radius: var(--radius-lg); border: 1px solid var(--color-border-primary); padding: 1.5rem; margin-bottom: 2rem; }

View File

@@ -5,12 +5,13 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { skip } from 'rxjs';
import { AocClient } from '../../core/api/aoc.client';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { GuardViolation, GuardViolationReason } from '../../core/api/aoc.models';
import { aocPath } from '../platform/ops/operations-paths';
@Component({
selector: 'app-guard-violations-list',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, LoadingStateComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="violations-page">
@@ -36,7 +37,7 @@ import { aocPath } from '../platform/ops/operations-paths';
</div>
@if (loading()) {
<div class="loading">Loading...</div>
<app-loading-state size="md" message="Loading violations..." />
}
<table class="violations-table">
@@ -79,7 +80,7 @@ import { aocPath } from '../platform/ops/operations-paths';
.violations-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0; }
.filters { display: flex; gap: 1rem; margin-bottom: 1rem; }
.filters select { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }

View File

@@ -89,8 +89,8 @@ import { aocPath } from '../platform/ops/operations-paths';
.page-header { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.page-header h1 { margin: 0; flex: 1; }
.breadcrumb { width: 100%; font-size: 0.85rem; color: var(--color-text-secondary); }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.btn-secondary { padding: 0.5rem 1rem; background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.btn-secondary { padding: 0.5rem 1rem; background: var(--color-btn-secondary-bg); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; }
.summary-strip { display: flex; gap: 2rem; justify-content: center; padding: 1.5rem; background: var(--color-surface-primary); border-radius: var(--radius-lg); margin-bottom: 2rem; }
.summary-item { text-align: center; }
.summary-item .value { display: block; font-size: 2rem; font-weight: var(--font-weight-bold); }

View File

@@ -102,7 +102,7 @@ import { aocPath } from '../platform/ops/operations-paths';
.provenance-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); font-size: 0.9rem; margin: 0; }
.validator-input { display: flex; gap: 0.75rem; margin-bottom: 2rem; }

View File

@@ -1,83 +1,87 @@
<div class="verify-action" [class]="'state-' + state()">
<!-- Action Header -->
<div class="action-header">
<div class="action-info">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="action-text">
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
<p class="action-desc">{{ statusLabel() }}</p>
</div>
</div>
<div class="action-buttons">
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
<button class="btn-verify" (click)="runVerification()">
@if (state() === 'idle') {
Run Verification
} @else {
Re-run
}
</button>
}
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
CLI
</button>
</div>
</div>
<!-- Progress Bar -->
@if (state() === 'running') {
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" [style.width]="progress() + '%'"></div>
</div>
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
</div>
}
<!-- Error State -->
@if (state() === 'error' && error()) {
<div class="error-banner">
<span class="error-icon">[X]</span>
<span class="error-message">{{ error() }}</span>
<button class="btn-retry" (click)="runVerification()">Retry</button>
</div>
}
<!-- Results -->
@if (state() === 'completed' && result()) {
<div class="results-section">
<!-- Summary Stats -->
<div class="results-summary">
<div class="stat-card" [class.success]="result()!.status === 'passed'">
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
<span class="stat-label">Documents Checked</span>
</div>
<div class="stat-card success">
<span class="stat-value">{{ result()!.passedCount | number }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat-card" [class.error]="result()!.failedCount > 0">
<span class="stat-value">{{ result()!.failedCount | number }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
<span class="stat-label">Pass Rate</span>
</div>
</div>
<!-- Violations Preview -->
@if (result()!.violations.length > 0) {
<div class="violations-preview">
<h5 class="preview-title">
Violations Found
<span class="violation-count">{{ result()!.violations.length }}</span>
</h5>
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<div class="verify-action" [class]="'state-' + state()">
<!-- Action Header -->
<div class="action-header">
<div class="action-info">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="action-text">
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
<p class="action-desc">{{ statusLabel() }}</p>
</div>
</div>
<div class="action-buttons">
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
<button class="btn-verify" (click)="runVerification()">
@if (state() === 'idle') {
Run Verification
} @else {
Re-run
}
</button>
}
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
CLI
</button>
</div>
</div>
<!-- Progress Bar -->
@if (state() === 'running') {
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" [style.width]="progress() + '%'"></div>
</div>
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
</div>
}
<!-- Error State -->
@if (state() === 'error' && error()) {
<div class="error-banner">
<span class="error-icon">[X]</span>
<span class="error-message">{{ error() }}</span>
<button class="btn-retry" (click)="runVerification()">Retry</button>
</div>
}
<!-- Results -->
@if (state() === 'completed' && result()) {
<div class="results-section">
<!-- Summary Stats -->
<stella-metric-grid [columns]="4">
<stella-metric-card
label="Documents Checked"
[value]="'' + result()!.checkedCount"
icon="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8"
/>
<stella-metric-card
label="Passed"
[value]="'' + result()!.passedCount"
icon="M22 11.08V12a10 10 0 1 1-5.93-9.14|||M9 11l3 3L22 4"
/>
<stella-metric-card
label="Failed"
[value]="'' + result()!.failedCount"
icon="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|||M12 9v4|||M12 17h.01"
/>
<stella-metric-card
label="Pass Rate"
[value]="resultSummary()?.passRate + '%'"
icon="M12 20V10|||M18 20V4|||M6 20v-4"
/>
</stella-metric-grid>
<!-- Violations Preview -->
@if (result()!.violations.length > 0) {
<div class="violations-preview">
<h5 class="preview-title">
Violations Found
<span class="violation-count">{{ result()!.violations.length }}</span>
</h5>
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<span class="code-chip">
{{ code }}
<span class="code-count">
@@ -86,94 +90,94 @@
</span>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
<li class="violation-item">
<button class="violation-btn" (click)="onSelectViolation(v)">
<span class="v-code">{{ v.violationCode }}</span>
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
@if (v.field) {
<span class="v-field">{{ v.field }}</span>
}
</button>
</li>
}
@if (result()!.violations.length > 3) {
<li class="more-violations">
+ {{ result()!.violations.length - 3 }} more violations
</li>
}
</ul>
</div>
} @else {
<div class="no-violations">
<span class="success-icon">[+]</span>
<span>No violations found in the last {{ windowHours() }} hours</span>
</div>
}
<!-- Completion Info -->
<div class="completion-info">
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
</div>
</div>
}
<!-- CLI Guidance Panel -->
@if (showCliGuidance()) {
<div class="cli-guidance">
<h5 class="cli-title">CLI Parity</h5>
<p class="cli-desc">{{ cliGuidance.description }}</p>
<!-- Current Command -->
<div class="cli-command-section">
<label class="cli-label">Equivalent Command</label>
<div class="cli-command">
<code>{{ getCliCommand() }}</code>
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
[C]
</button>
</div>
</div>
<!-- Available Flags -->
<div class="cli-flags-section">
<label class="cli-label">Available Flags</label>
<table class="flags-table">
<tbody>
@for (flag of cliGuidance.flags; track flag.flag) {
<tr>
<td class="flag-name"><code>{{ flag.flag }}</code></td>
<td class="flag-desc">{{ flag.description }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Examples -->
<div class="cli-examples-section">
<label class="cli-label">Examples</label>
<div class="examples-list">
@for (example of cliGuidance.examples; track example) {
<div class="example-item">
<code>{{ example }}</code>
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
[C]
</button>
</div>
}
</div>
</div>
<!-- Install hint -->
<div class="install-hint">
<span class="hint-icon">[i]</span>
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
</div>
</div>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
<li class="violation-item">
<button class="violation-btn" (click)="onSelectViolation(v)">
<span class="v-code">{{ v.violationCode }}</span>
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
@if (v.field) {
<span class="v-field">{{ v.field }}</span>
}
</button>
</li>
}
@if (result()!.violations.length > 3) {
<li class="more-violations">
+ {{ result()!.violations.length - 3 }} more violations
</li>
}
</ul>
</div>
} @else {
<div class="no-violations">
<span class="success-icon">[+]</span>
<span>No violations found in the last {{ windowHours() }} hours</span>
</div>
}
<!-- Completion Info -->
<div class="completion-info">
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
</div>
</div>
}
<!-- CLI Guidance Panel -->
@if (showCliGuidance()) {
<div class="cli-guidance">
<h5 class="cli-title">CLI Parity</h5>
<p class="cli-desc">{{ cliGuidance.description }}</p>
<!-- Current Command -->
<div class="cli-command-section">
<label class="cli-label">Equivalent Command</label>
<div class="cli-command">
<code>{{ getCliCommand() }}</code>
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
[C]
</button>
</div>
</div>
<!-- Available Flags -->
<div class="cli-flags-section">
<label class="cli-label">Available Flags</label>
<table class="flags-table">
<tbody>
@for (flag of cliGuidance.flags; track flag.flag) {
<tr>
<td class="flag-name"><code>{{ flag.flag }}</code></td>
<td class="flag-desc">{{ flag.description }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Examples -->
<div class="cli-examples-section">
<label class="cli-label">Examples</label>
<div class="examples-list">
@for (example of cliGuidance.examples; track example) {
<div class="example-item">
<code>{{ example }}</code>
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
[C]
</button>
</div>
}
</div>
</div>
<!-- Install hint -->
<div class="install-hint">
<span class="hint-icon">[i]</span>
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
</div>
</div>
}
</div>

View File

@@ -9,6 +9,8 @@ import {
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import {
AocVerificationRequest,
AocVerificationResult,
@@ -27,7 +29,7 @@ export interface CliParityGuidance {
@Component({
selector: 'app-verify-action',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, StellaMetricCardComponent, StellaMetricGridComponent],
templateUrl: './verify-action.component.html',
styleUrls: ['./verify-action.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,139 +1,139 @@
<div class="violation-drilldown">
<!-- Header with Summary -->
<header class="drilldown-header">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{ totalViolations() }}</span>
<span class="stat-label">Violations</span>
</div>
<div class="stat">
<span class="stat-value">{{ totalDocuments() }}</span>
<span class="stat-label">Documents</span>
</div>
<div class="severity-breakdown">
@if (severityCounts().critical > 0) {
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
}
@if (severityCounts().high > 0) {
<span class="severity-chip high">{{ severityCounts().high }} high</span>
}
@if (severityCounts().medium > 0) {
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
}
@if (severityCounts().low > 0) {
<span class="severity-chip low">{{ severityCounts().low }} low</span>
}
</div>
</div>
<div class="controls">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-violation'"
(click)="setViewMode('by-violation')"
>
By Violation
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-document'"
(click)="setViewMode('by-document')"
>
By Document
</button>
</div>
<input
type="search"
class="search-input"
placeholder="Filter violations..."
[value]="searchFilter()"
(input)="onSearch($event)"
/>
</div>
</header>
<!-- By Violation View -->
@if (viewMode() === 'by-violation') {
<div class="violation-list">
@for (group of filteredGroups(); track group.code) {
<div class="violation-group" [class]="'severity-' + group.severity">
<button
class="group-header"
(click)="toggleGroup(group.code)"
[attr.aria-expanded]="expandedCode() === group.code"
>
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
<div class="group-info">
<span class="violation-code">{{ group.code }}</span>
<span class="violation-desc">{{ group.description }}</span>
</div>
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
</button>
@if (expandedCode() === group.code) {
<div class="group-details">
@if (group.remediation) {
<div class="remediation-hint">
<strong>Remediation:</strong> {{ group.remediation }}
</div>
}
<table class="violations-table">
<thead>
<tr>
<th>Document</th>
<th>Field</th>
<th>Expected</th>
<th>Actual</th>
<th>Provenance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of group.violations; track v.documentId + v.field) {
<tr class="violation-row">
<td class="doc-cell">
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
{{ v.documentId | slice:0:20 }}...
</button>
</td>
<td class="field-cell">
@if (v.field) {
<code class="field-path highlighted">{{ v.field }}</code>
} @else {
<span class="no-field">-</span>
}
</td>
<td class="expected-cell">
@if (v.expected) {
<code class="value expected">{{ v.expected }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="actual-cell">
@if (v.actual) {
<code class="value actual error">{{ v.actual }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="provenance-cell">
@if (v.provenance) {
<div class="provenance-info">
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
<span class="source-id" [title]="v.provenance.sourceId">
{{ v.provenance.sourceId | slice:0:15 }}
</span>
<span class="digest" [title]="v.provenance.digest">
{{ formatDigest(v.provenance.digest) }}
</span>
</div>
} @else {
<span class="no-provenance">No provenance</span>
}
<div class="violation-drilldown">
<!-- Header with Summary -->
<header class="drilldown-header">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{ totalViolations() }}</span>
<span class="stat-label">Violations</span>
</div>
<div class="stat">
<span class="stat-value">{{ totalDocuments() }}</span>
<span class="stat-label">Documents</span>
</div>
<div class="severity-breakdown">
@if (severityCounts().critical > 0) {
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
}
@if (severityCounts().high > 0) {
<span class="severity-chip high">{{ severityCounts().high }} high</span>
}
@if (severityCounts().medium > 0) {
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
}
@if (severityCounts().low > 0) {
<span class="severity-chip low">{{ severityCounts().low }} low</span>
}
</div>
</div>
<div class="controls">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-violation'"
(click)="setViewMode('by-violation')"
>
By Violation
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-document'"
(click)="setViewMode('by-document')"
>
By Document
</button>
</div>
<input
type="search"
class="search-input"
placeholder="Filter violations..."
[value]="searchFilter()"
(input)="onSearch($event)"
/>
</div>
</header>
<!-- By Violation View -->
@if (viewMode() === 'by-violation') {
<div class="violation-list">
@for (group of filteredGroups(); track group.code) {
<div class="violation-group" [class]="'severity-' + group.severity">
<button
class="group-header"
(click)="toggleGroup(group.code)"
[attr.aria-expanded]="expandedCode() === group.code"
>
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
<div class="group-info">
<span class="violation-code">{{ group.code }}</span>
<span class="violation-desc">{{ group.description }}</span>
</div>
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
</button>
@if (expandedCode() === group.code) {
<div class="group-details">
@if (group.remediation) {
<div class="remediation-hint">
<strong>Remediation:</strong> {{ group.remediation }}
</div>
}
<table class="violations-table">
<thead>
<tr>
<th>Document</th>
<th>Field</th>
<th>Expected</th>
<th>Actual</th>
<th>Provenance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of group.violations; track v.documentId + v.field) {
<tr class="violation-row">
<td class="doc-cell">
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
{{ v.documentId | slice:0:20 }}...
</button>
</td>
<td class="field-cell">
@if (v.field) {
<code class="field-path highlighted">{{ v.field }}</code>
} @else {
<span class="no-field">-</span>
}
</td>
<td class="expected-cell">
@if (v.expected) {
<code class="value expected">{{ v.expected }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="actual-cell">
@if (v.actual) {
<code class="value actual error">{{ v.actual }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="provenance-cell">
@if (v.provenance) {
<div class="provenance-info">
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
<span class="source-id" [title]="v.provenance.sourceId">
{{ v.provenance.sourceId | slice:0:15 }}
</span>
<span class="digest" [title]="v.provenance.digest">
{{ formatDigest(v.provenance.digest) }}
</span>
</div>
} @else {
<span class="no-provenance">No provenance</span>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
@@ -142,138 +142,138 @@
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
@if (filteredGroups().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No violations match "{{ searchFilter() }}"</p>
} @else {
<p>No violations to display</p>
}
</div>
}
</div>
}
<!-- By Document View -->
@if (viewMode() === 'by-document') {
<div class="document-list">
@for (doc of filteredDocuments(); track doc.documentId) {
<div class="document-card">
<button
class="doc-header"
(click)="toggleDocument(doc.documentId)"
[attr.aria-expanded]="expandedDocId() === doc.documentId"
>
<span class="doc-type-badge">{{ doc.documentType }}</span>
<span class="doc-id">{{ doc.documentId }}</span>
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
</button>
@if (expandedDocId() === doc.documentId) {
<div class="doc-details">
<!-- Provenance Section -->
<div class="provenance-section">
<h4 class="section-title">Provenance</h4>
<dl class="provenance-grid">
<div class="prov-item">
<dt>Source</dt>
<dd>
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
{{ doc.provenance.sourceId }}
</dd>
</div>
<div class="prov-item">
<dt>Digest</dt>
<dd><code>{{ doc.provenance.digest }}</code></dd>
</div>
<div class="prov-item">
<dt>Ingested</dt>
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
</div>
@if (doc.provenance.submitter) {
<div class="prov-item">
<dt>Submitter</dt>
<dd>{{ doc.provenance.submitter }}</dd>
</div>
}
@if (doc.provenance.sourceUrl) {
<div class="prov-item">
<dt>Source URL</dt>
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
</div>
}
</dl>
</div>
<!-- Violations Section -->
<div class="violations-section">
<h4 class="section-title">Violations</h4>
<ul class="doc-violations-list">
@for (v of doc.violations; track v.violationCode + v.field) {
<li class="doc-violation-item">
<div class="violation-header">
<code class="violation-code">{{ v.violationCode }}</code>
@if (v.field) {
<span class="at-field">at</span>
<code class="field-path highlighted">{{ v.field }}</code>
}
</div>
@if (v.expected || v.actual) {
<div class="value-diff">
<div class="expected-row">
<span class="label">Expected:</span>
<code class="value">{{ v.expected || 'N/A' }}</code>
</div>
<div class="actual-row">
<span class="label">Actual:</span>
<code class="value error">{{ v.actual || 'N/A' }}</code>
</div>
</div>
}
</li>
}
</ul>
</div>
<!-- Raw Content Preview -->
@if (doc.rawContent) {
<div class="raw-content-section">
<h4 class="section-title">
Document Fields
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
</h4>
<div class="field-preview">
@for (field of doc.highlightedFields; track field) {
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
<span class="field-name">{{ field }}</span>
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
@if (filteredDocuments().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No documents match "{{ searchFilter() }}"</p>
} @else {
<p>No documents to display</p>
}
</div>
}
</div>
}
</div>
</tbody>
</table>
</div>
}
</div>
}
@if (filteredGroups().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No violations match "{{ searchFilter() }}"</p>
} @else {
<p>No violations to display</p>
}
</div>
}
</div>
}
<!-- By Document View -->
@if (viewMode() === 'by-document') {
<div class="document-list">
@for (doc of filteredDocuments(); track doc.documentId) {
<div class="document-card">
<button
class="doc-header"
(click)="toggleDocument(doc.documentId)"
[attr.aria-expanded]="expandedDocId() === doc.documentId"
>
<span class="doc-type-badge">{{ doc.documentType }}</span>
<span class="doc-id">{{ doc.documentId }}</span>
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
</button>
@if (expandedDocId() === doc.documentId) {
<div class="doc-details">
<!-- Provenance Section -->
<div class="provenance-section">
<h4 class="section-title">Provenance</h4>
<dl class="provenance-grid">
<div class="prov-item">
<dt>Source</dt>
<dd>
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
{{ doc.provenance.sourceId }}
</dd>
</div>
<div class="prov-item">
<dt>Digest</dt>
<dd><code>{{ doc.provenance.digest }}</code></dd>
</div>
<div class="prov-item">
<dt>Ingested</dt>
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
</div>
@if (doc.provenance.submitter) {
<div class="prov-item">
<dt>Submitter</dt>
<dd>{{ doc.provenance.submitter }}</dd>
</div>
}
@if (doc.provenance.sourceUrl) {
<div class="prov-item">
<dt>Source URL</dt>
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
</div>
}
</dl>
</div>
<!-- Violations Section -->
<div class="violations-section">
<h4 class="section-title">Violations</h4>
<ul class="doc-violations-list">
@for (v of doc.violations; track v.violationCode + v.field) {
<li class="doc-violation-item">
<div class="violation-header">
<code class="violation-code">{{ v.violationCode }}</code>
@if (v.field) {
<span class="at-field">at</span>
<code class="field-path highlighted">{{ v.field }}</code>
}
</div>
@if (v.expected || v.actual) {
<div class="value-diff">
<div class="expected-row">
<span class="label">Expected:</span>
<code class="value">{{ v.expected || 'N/A' }}</code>
</div>
<div class="actual-row">
<span class="label">Actual:</span>
<code class="value error">{{ v.actual || 'N/A' }}</code>
</div>
</div>
}
</li>
}
</ul>
</div>
<!-- Raw Content Preview -->
@if (doc.rawContent) {
<div class="raw-content-section">
<h4 class="section-title">
Document Fields
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
</h4>
<div class="field-preview">
@for (field of doc.highlightedFields; track field) {
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
<span class="field-name">{{ field }}</span>
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
@if (filteredDocuments().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No documents match "{{ searchFilter() }}"</p>
} @else {
<p>No documents to display</p>
}
</div>
}
</div>
}
</div>

View File

@@ -267,7 +267,7 @@
.doc-link {
background: none;
border: none;
color: var(--color-brand-primary);
color: var(--color-text-link);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
cursor: pointer;
@@ -431,7 +431,7 @@
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
color: var(--color-text-link);
font-size: var(--font-size-xs);
cursor: pointer;
padding: 0;

View File

@@ -34,7 +34,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
&:hover {

View File

@@ -98,22 +98,22 @@ interface ApprovalRequest {
styles: [`
.approvals-page { max-width: 1000px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.page-header { margin-bottom: 0.75rem; }
.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); }
.filter-row {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.5rem 0;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding: 0;
}
.status-chips {
display: flex;
display: inline-flex;
gap: 0.375rem;
flex-wrap: wrap;
flex-wrap: nowrap;
}
.chip {
display: inline-flex;
@@ -129,7 +129,7 @@ interface ApprovalRequest {
}
.chip:hover { background: var(--color-nav-hover); }
.chip--active {
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-color: var(--color-brand-primary);
color: #fff;
}

View File

@@ -1,246 +1,541 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
import { FilterBarComponent, type FilterOption, type ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type QueueTab = 'pending' | 'approved' | 'rejected' | 'expiring' | 'my-team';
const QUEUE_TABS: StellaPageTab[] = [
{ id: 'pending', label: 'Pending', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'approved', label: 'Approved', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' },
{ id: 'rejected', label: 'Rejected', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M15 9l-6 6|||M9 9l6 6' },
{ id: 'expiring', label: 'Expiring', icon: '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|||M12 9v4|||M12 17h.01' },
{ id: 'my-team', label: 'My Team', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' },
];
@Component({
selector: 'app-approvals-inbox',
standalone: true,
imports: [RouterLink, FormsModule],
imports: [RouterModule, FilterBarComponent, StellaPageTabsComponent],
template: `
<section class="approvals">
<header>
<h1>Release Run Approvals Queue</h1>
<p>Run-centric approval queue with gate/env/hotfix/risk filtering.</p>
<div class="approval-list">
<header class="list-header">
<div class="list-header__title">
<h1>Approvals Queue</h1>
<p class="subtitle">Review and act on release promotion requests across environments</p>
</div>
</header>
<nav class="tabs" aria-label="Approvals queue tabs">
@for (tab of tabs; track tab.id) {
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
}
</nav>
<!-- Queue tabs -->
<stella-page-tabs
[tabs]="pageTabs()"
[activeTab]="activeTab()"
(tabChange)="switchTab($any($event))"
ariaLabel="Approvals queue tabs"
/>
<div class="filters">
<select [(ngModel)]="gateTypeFilter" (ngModelChange)="applyFilters()">
<option value="all">Gate Type: All</option>
<option value="policy">Policy</option>
<option value="ops">Ops</option>
<option value="security">Security</option>
</select>
<app-filter-bar
searchPlaceholder="Search by release name, requester, or environment"
[filters]="filterOptions"
[activeFilters]="activeFilterPills()"
(searchChange)="onSearch($event)"
(filterChange)="onFilterChanged($event)"
(filterRemove)="onFilterRemoved($event)"
(filtersCleared)="clearAllFilters()"
></app-filter-bar>
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
<option value="all">Environment: All</option>
<option value="dev">Dev</option>
<option value="qa">QA</option>
<option value="staging">Staging</option>
<option value="prod">Prod</option>
</select>
<select [(ngModel)]="hotfixFilter" (ngModelChange)="applyFilters()">
<option value="all">Hotfix: All</option>
<option value="true">Hotfix Only</option>
<option value="false">Non-hotfix</option>
</select>
<select [(ngModel)]="riskFilter" (ngModelChange)="applyFilters()">
<option value="all">Risk: All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="normal">Normal/Low</option>
</select>
</div>
@if (loading()) { <div class="banner">Loading approvals...</div> }
@if (error()) { <div class="banner error">{{ error() }}</div> }
@if (!loading()) {
<table>
<thead>
<tr>
<th>Release</th>
<th>Flow</th>
<th>Gate Type</th>
<th>Risk</th>
<th>Status</th>
<th>Requester</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (approval of filtered(); track approval.id) {
<tr>
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'timeline']">{{ approval.releaseName }} {{ approval.releaseVersion }}</a></td>
<td>{{ approval.sourceEnvironment }} ? {{ approval.targetEnvironment }}</td>
<td>{{ deriveGateType(approval) }}</td>
<td>{{ approval.urgency }}</td>
<td>{{ approval.status }}</td>
<td>{{ approval.requestedBy }}</td>
<td>{{ timeRemaining(approval.expiresAt) }}</td>
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'approvals']" [queryParams]="{ approvalId: approval.id }">Open</a></td>
</tr>
} @empty {
<tr><td colspan="8">No approvals match the active queue filters.</td></tr>
}
</tbody>
</table>
@if (error()) {
<div class="status-banner error">
<svg class="status-banner__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ error() }}</span>
<button type="button" class="status-banner__retry" (click)="reload()">Retry</button>
</div>
}
</section>
@if (loading() && filtered().length === 0) {
<div class="loading-state">
<div class="loading-state__spinner"></div>
<span>Loading approvals...</span>
</div>
} @else if (filtered().length === 0 && !error()) {
<div class="empty-state">
<div class="empty-state__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 21V9"/>
</svg>
</div>
<h3 class="empty-state__title">No approvals found</h3>
<p class="empty-state__desc">
@if (hasActiveFilters()) {
No approvals match the current filters. Try broadening your search or clearing filters.
} @else {
There are no {{ activeTab() }} approval requests at this time.
}
</p>
@if (hasActiveFilters()) {
<div class="empty-state__actions">
<button type="button" class="btn-secondary" (click)="clearAllFilters()">Clear All Filters</button>
</div>
}
</div>
} @else {
<div class="table-container">
<table class="approval-table">
<thead>
<tr>
<th class="col-identity">Release</th>
<th class="col-flow">Promotion</th>
<th class="col-gate">Gate</th>
<th class="col-risk">Risk</th>
<th class="col-status">Status</th>
<th class="col-actor">Requester</th>
<th class="col-expires">Expires</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (approval of filtered(); track approval.id) {
<tr [class.expiring-soon]="isExpiringSoon(approval.expiresAt)"
[class.expired]="isExpired(approval.expiresAt)">
<td class="col-identity">
<a [routerLink]="['/releases/runs', approval.releaseId, 'timeline']" class="identity-link">
<strong>{{ approval.releaseName }}</strong>
</a>
<div class="meta">{{ approval.releaseVersion }}</div>
</td>
<td class="col-flow">
<div class="flow-badge">
<span class="flow-env">{{ approval.sourceEnvironment }}</span>
<svg class="flow-arrow" width="14" height="8" viewBox="0 0 14 8" fill="none" aria-hidden="true">
<path d="M0 4h11m0 0L8 1m3 3L8 7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="flow-env flow-env--target">{{ approval.targetEnvironment }}</span>
</div>
</td>
<td class="col-gate">
<span class="gate-chip" [class]="'gate-chip--' + deriveGateType(approval)">
{{ deriveGateType(approval) }}
</span>
</td>
<td class="col-risk">
<span class="gate-chip" [class]="'gate-chip--' + urgencyToGate(approval.urgency)">
{{ approval.urgency }}
</span>
</td>
<td class="col-status">
<span class="gate-chip" [class]="'gate-chip--' + statusToGate(approval.status)">
{{ approval.status }}
</span>
</td>
<td class="col-actor">
<div>{{ approval.requestedBy }}</div>
</td>
<td class="col-expires" [class.col-expires--urgent]="isExpiringSoon(approval.expiresAt)" [class.col-expires--expired]="isExpired(approval.expiresAt)">
{{ timeRemaining(approval.expiresAt) }}
</td>
<td class="col-actions">
<a [routerLink]="['/releases/approvals', approval.id]" class="row-action" title="Open approval details">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
`,
styles: [`
.approvals {
/* ─── Page layout ─── */
.approval-list {
display: grid;
gap: 1rem;
max-width: 1400px;
gap: 0.5rem;
max-width: 1600px;
margin: 0 auto;
}
.approvals header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold, 600);
}
.approvals header p {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
/* Tabs */
.tabs {
/* ─── Header ─── */
.list-header {
display: flex;
gap: 0.25rem;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 0;
}
.tabs a {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: var(--color-tab-inactive-text, var(--color-text-secondary));
text-decoration: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
transition: color 150ms ease, border-color 150ms ease;
font-weight: 500;
.list-header h1 {
margin: 0;
font-size: var(--font-size-xl, 1.25rem);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight, 1.25);
}
.tabs a:hover {
color: var(--color-text-primary);
.subtitle {
margin: 0.2rem 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm, 0.75rem);
}
.tabs a.active {
color: var(--color-tab-active-text, var(--color-text-primary));
border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary));
font-weight: 600;
}
/* Filters */
.filters {
/* ─── Status / Error banner ─── */
.status-banner {
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-md);
background: var(--color-status-error-bg);
padding: 0.6rem 0.8rem;
font-size: var(--font-size-sm, 0.75rem);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.filters select {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
.status-banner.error {
color: var(--color-status-error-text);
}
.filters select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
.status-banner__icon {
flex-shrink: 0;
}
/* Banners */
.banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-surface-secondary) 25%, var(--color-surface-primary) 50%, var(--color-surface-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
.status-banner span {
flex: 1;
}
.status-banner__retry {
flex-shrink: 0;
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-status-error-text);
font-size: var(--font-size-xs, 0.6875rem);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: background var(--motion-duration-sm, 140ms) ease;
}
.status-banner__retry:hover {
background: color-mix(in srgb, var(--color-status-error-text) 10%, transparent);
}
/* ─── Loading state ─── */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 3rem 1rem;
font-size: var(--font-size-sm, 0.75rem);
color: var(--color-text-secondary);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
.loading-state__spinner {
width: 18px;
height: 18px;
border: 2px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.banner.error {
color: var(--color-status-error-text);
background: var(--color-surface-primary);
animation: none;
border-color: var(--color-status-error);
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
/* ─── Empty state ─── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 3.5rem 1.5rem 4rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
overflow: hidden;
}
th, td {
border-bottom: 1px solid var(--color-border-primary);
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
text-align: left;
vertical-align: top;
.empty-state__icon {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: var(--radius-xl);
background: var(--color-brand-primary-10, var(--color-surface-secondary));
color: var(--color-brand-primary);
margin-bottom: 1.25rem;
}
th {
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold, 600);
.empty-state__title {
margin: 0 0 0.4rem;
font-size: var(--font-size-md, 1rem);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.empty-state__desc {
margin: 0 0 1.5rem;
max-width: 420px;
font-size: var(--font-size-sm, 0.75rem);
color: var(--color-text-secondary);
line-height: var(--line-height-relaxed, 1.625);
}
.empty-state__actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
border-radius: var(--radius-md);
font-size: var(--font-size-sm, 0.75rem);
font-weight: var(--font-weight-medium);
cursor: pointer;
padding: 0.4rem 0.75rem;
white-space: nowrap;
transition: background var(--motion-duration-sm, 140ms) ease,
border-color var(--motion-duration-sm, 140ms) ease;
border: 1px solid var(--color-btn-secondary-border);
background: var(--color-btn-secondary-bg);
color: var(--color-btn-secondary-text);
}
.btn-secondary:hover {
background: var(--color-btn-secondary-hover-bg);
border-color: var(--color-btn-secondary-hover-border);
}
/* ─── Table ─── */
.table-container {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface-primary);
}
.approval-table {
width: 100%;
border-collapse: collapse;
}
.approval-table th,
.approval-table td {
text-align: left;
padding: 0.5rem 0.6rem;
vertical-align: top;
font-size: var(--font-size-sm, 0.75rem);
}
.approval-table thead {
border-bottom: 1px solid var(--color-border-primary);
}
.approval-table th {
font-size: var(--font-size-xs, 0.6875rem);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
background: var(--color-surface-secondary);
position: sticky;
top: 0;
z-index: 1;
padding: 0.45rem 0.6rem;
white-space: nowrap;
}
tbody tr:nth-child(even) {
background: var(--color-surface-secondary);
.approval-table tbody tr {
border-bottom: 1px solid var(--color-border-primary);
transition: background var(--motion-duration-sm, 140ms) ease;
}
tbody tr:hover {
background: var(--color-nav-hover);
}
tr:last-child td {
.approval-table tbody tr:last-child {
border-bottom: none;
}
td a {
color: var(--color-brand-primary);
text-decoration: none;
font-weight: var(--font-weight-medium, 500);
.approval-table tbody tr:hover {
background: var(--color-surface-secondary);
}
td a:hover {
.approval-table tbody tr.expiring-soon {
background: var(--color-status-warning-bg);
}
.approval-table tbody tr.expiring-soon:hover {
background: color-mix(in srgb, var(--color-status-warning-bg) 80%, var(--color-surface-secondary));
}
.approval-table tbody tr.expired {
background: var(--color-status-error-bg);
opacity: 0.7;
}
/* Column sizing */
.col-identity { min-width: 160px; }
.col-flow { min-width: 140px; }
.col-gate { min-width: 80px; }
.col-risk { min-width: 80px; }
.col-status { min-width: 80px; }
.col-actor { min-width: 100px; }
.col-expires { min-width: 80px; }
.col-actions { width: 40px; text-align: center; vertical-align: middle; }
.identity-link {
color: var(--color-text-link);
text-decoration: none;
font-size: var(--font-size-sm, 0.75rem);
line-height: 1.3;
}
.identity-link:hover {
text-decoration: underline;
}
.meta {
margin-top: 0.15rem;
color: var(--color-text-muted);
font-size: var(--font-size-xs, 0.6875rem);
font-family: var(--font-family-mono, ui-monospace, monospace);
}
/* Flow badge */
.flow-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.flow-env {
font-size: var(--font-size-xs, 0.6875rem);
color: var(--color-text-secondary);
}
.flow-env--target {
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
.flow-arrow {
color: var(--color-brand-primary);
flex-shrink: 0;
}
/* Gate chip (matches versions page pattern) */
.gate-chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-full);
padding: 0.06rem 0.45rem;
font-size: var(--font-size-xs, 0.6875rem);
font-weight: var(--font-weight-semibold);
text-transform: capitalize;
letter-spacing: 0.04em;
line-height: 1.4;
border: 1px solid var(--color-border-primary);
}
.gate-chip--policy {
color: var(--color-status-info-text);
border-color: var(--color-status-info-border, var(--color-status-info-text));
background: var(--color-status-info-bg);
}
.gate-chip--security,
.gate-chip--critical {
color: var(--color-status-error-text);
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
}
.gate-chip--ops,
.gate-chip--warn,
.gate-chip--pending,
.gate-chip--high {
color: var(--color-status-warning-text);
border-color: var(--color-status-warning-border);
background: var(--color-status-warning-bg);
}
.gate-chip--pass,
.gate-chip--approved,
.gate-chip--low,
.gate-chip--normal {
color: var(--color-status-success-text);
border-color: var(--color-status-success-border);
background: var(--color-status-success-bg);
}
.gate-chip--rejected,
.gate-chip--block {
color: var(--color-status-error-text);
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
}
.gate-chip--expired {
color: var(--color-text-muted);
border-color: var(--color-border-primary);
background: var(--color-surface-secondary);
}
/* Expires column */
.col-expires {
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: var(--font-size-xs, 0.6875rem);
}
.col-expires--urgent {
color: var(--color-severity-high-text, #A04808);
font-weight: var(--font-weight-semibold);
}
.col-expires--expired {
color: var(--color-status-error-text);
font-weight: var(--font-weight-semibold);
}
/* Row action (chevron - matches versions page) */
.row-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
text-decoration: none;
transition: color var(--motion-duration-sm, 140ms) ease,
background var(--motion-duration-sm, 140ms) ease;
}
.row-action:hover {
color: var(--color-text-link);
background: var(--color-surface-tertiary);
}
/* ─── Responsive ─── */
@media (max-width: 920px) {
.table-container {
overflow-x: auto;
}
.list-header {
flex-direction: column;
gap: 0.75rem;
}
.queue-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -255,23 +550,31 @@ export class ApprovalsInboxComponent {
readonly filtered = signal<ApprovalRequest[]>([]);
readonly activeTab = signal<QueueTab>('pending');
readonly tabs: Array<{ id: QueueTab; label: string }> = [
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'rejected', label: 'Rejected' },
{ id: 'expiring', label: 'Expiring' },
{ id: 'my-team', label: 'My Team' },
readonly pageTabs = computed(() =>
QUEUE_TABS.map(tab => ({ ...tab, badge: this.tabCount(tab.id as QueueTab) }))
);
// Shared filter bar integration
readonly filterOptions: FilterOption[] = [
{ key: 'gateType', label: 'Gate Type', options: [{ value: 'policy', label: 'Policy' }, { value: 'ops', label: 'Ops' }, { value: 'security', label: 'Security' }] },
{ key: 'environment', label: 'Environment', options: [{ value: 'dev', label: 'Dev' }, { value: 'qa', label: 'QA' }, { value: 'staging', label: 'Staging' }, { value: 'prod', label: 'Prod' }] },
{ key: 'hotfix', label: 'Hotfix', options: [{ value: 'true', label: 'Hotfix Only' }, { value: 'false', label: 'Non-hotfix' }] },
{ key: 'risk', label: 'Risk', options: [{ value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, { value: 'normal', label: 'Normal/Low' }] },
];
readonly activeFilterPills = signal<ActiveFilter[]>([]);
gateTypeFilter = 'all';
envFilter = 'all';
hotfixFilter = 'all';
riskFilter = 'all';
searchTerm = '';
constructor() {
this.route.queryParamMap.subscribe((params) => {
const tab = (params.get('tab') ?? 'pending') as QueueTab;
if (this.tabs.some((item) => item.id === tab)) {
if (QUEUE_TABS.some((item) => item.id === tab)) {
this.activeTab.set(tab);
} else {
this.activeTab.set('pending');
@@ -281,6 +584,89 @@ export class ApprovalsInboxComponent {
});
}
tabCount(tabId: QueueTab): number {
const all = this.approvals();
const now = Date.now();
switch (tabId) {
case 'pending':
return all.filter((a) => a.status === 'pending').length;
case 'approved':
return all.filter((a) => a.status === 'approved').length;
case 'rejected':
return all.filter((a) => a.status === 'rejected').length;
case 'expiring':
return all.filter((a) => a.status === 'pending' && (new Date(a.expiresAt).getTime() - now) <= 24 * 60 * 60 * 1000).length;
case 'my-team':
return all.filter((a) => a.status === 'pending' && a.requestedBy.toLowerCase().includes('team')).length;
default:
return 0;
}
}
switchTab(tabId: QueueTab): void {
void this.router.navigate([], { queryParams: { tab: tabId }, queryParamsHandling: 'merge' });
}
onSearch(value: string): void {
this.searchTerm = value;
this.applyFilters();
}
onFilterChanged(filter: ActiveFilter): void {
const map: Record<string, string> = {
gateType: 'gateTypeFilter',
environment: 'envFilter',
hotfix: 'hotfixFilter',
risk: 'riskFilter',
};
const prop = map[filter.key];
if (prop) {
(this as any)[prop] = filter.value;
}
this.applyFilters();
}
onFilterRemoved(filter: ActiveFilter): void {
const map: Record<string, string> = {
gateType: 'gateTypeFilter',
environment: 'envFilter',
hotfix: 'hotfixFilter',
risk: 'riskFilter',
};
const prop = map[filter.key];
if (prop) {
(this as any)[prop] = 'all';
}
this.applyFilters();
}
clearAllFilters(): void {
this.gateTypeFilter = 'all';
this.envFilter = 'all';
this.hotfixFilter = 'all';
this.riskFilter = 'all';
this.searchTerm = '';
this.activeFilterPills.set([]);
this.applyFilters();
}
hasActiveFilters(): boolean {
return this.activeFilterPills().length > 0 || this.searchTerm.trim().length > 0;
}
reload(): void {
this.load();
}
isExpiringSoon(expiresAt: string): boolean {
const ms = new Date(expiresAt).getTime() - Date.now();
return ms > 0 && ms <= 4 * 60 * 60 * 1000;
}
isExpired(expiresAt: string): boolean {
return new Date(expiresAt).getTime() - Date.now() <= 0;
}
deriveGateType(approval: ApprovalRequest): 'policy' | 'ops' | 'security' {
const releaseName = approval.releaseName.toLowerCase();
if (!approval.gatesPassed || releaseName.includes('policy')) {
@@ -292,6 +678,26 @@ export class ApprovalsInboxComponent {
return 'ops';
}
urgencyToGate(urgency: string): string {
switch (urgency) {
case 'critical': return 'critical';
case 'high': return 'high';
case 'normal': return 'normal';
case 'low': return 'low';
default: return 'normal';
}
}
statusToGate(status: string): string {
switch (status) {
case 'pending': return 'pending';
case 'approved': return 'approved';
case 'rejected': return 'rejected';
case 'expired': return 'expired';
default: return 'pending';
}
}
applyFilters(): void {
const tab = this.activeTab();
const now = Date.now();
@@ -305,6 +711,16 @@ export class ApprovalsInboxComponent {
rows = rows.filter((item) => item.status === tab);
}
if (this.searchTerm.trim()) {
const q = this.searchTerm.trim().toLowerCase();
rows = rows.filter((item) =>
item.releaseName.toLowerCase().includes(q) ||
item.requestedBy.toLowerCase().includes(q) ||
item.targetEnvironment.toLowerCase().includes(q) ||
item.sourceEnvironment.toLowerCase().includes(q)
);
}
if (this.gateTypeFilter !== 'all') {
rows = rows.filter((item) => this.deriveGateType(item) === this.gateTypeFilter);
}
@@ -324,6 +740,7 @@ export class ApprovalsInboxComponent {
}
this.filtered.set(rows.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()));
this.rebuildFilterPills();
}
timeRemaining(expiresAt: string): string {
@@ -337,6 +754,24 @@ export class ApprovalsInboxComponent {
return `${hours}h ${minutes}m`;
}
private rebuildFilterPills(): void {
const pills: ActiveFilter[] = [];
const defs: { key: string; prop: string; label: string }[] = [
{ key: 'gateType', prop: 'gateTypeFilter', label: 'Gate' },
{ key: 'environment', prop: 'envFilter', label: 'Env' },
{ key: 'hotfix', prop: 'hotfixFilter', label: 'Hotfix' },
{ key: 'risk', prop: 'riskFilter', label: 'Risk' },
];
for (const def of defs) {
const val = (this as any)[def.prop] as string;
if (val !== 'all') {
const opt = this.filterOptions.find(f => f.key === def.key)?.options.find(o => o.value === val);
pills.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) });
}
}
this.activeFilterPills.set(pills);
}
private load(): void {
this.loading.set(true);
this.error.set(null);
@@ -359,4 +794,3 @@ export class ApprovalsInboxComponent {
});
}
}

View File

@@ -639,18 +639,18 @@ export interface GateContext {
}
.btn--primary {
background: var(--color-brand-600);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn--primary:hover:not(:disabled) {
background: var(--color-brand-700);
background: var(--color-btn-primary-bg-hover);
}
.btn--secondary {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
background: var(--color-btn-secondary-bg);
color: var(--color-btn-secondary-text);
border: 1px solid var(--color-btn-secondary-border);
}
.btn--secondary:hover:not(:disabled) {

View File

@@ -90,7 +90,7 @@ import { AuditAnomalyAlert } from '../../core/api/audit-log.models';
.anomalies-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
@@ -113,7 +113,7 @@ import { AuditAnomalyAlert } from '../../core/api/audit-log.models';
.ack-info { font-size: 0.8rem; color: var(--color-text-secondary); font-style: italic; }
.alert-actions { display: flex; gap: 0.75rem; }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-secondary { background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; }
.btn-secondary { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); padding: 0.5rem 1rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; }
.no-alerts { text-align: center; padding: 3rem; color: var(--color-text-secondary); background: var(--color-surface-primary); border-radius: var(--radius-lg); }
.anomaly-types h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.types-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }

View File

@@ -4,10 +4,17 @@ import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@ang
import { RouterModule } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditEvent } from '../../core/api/audit-log.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
const AUTHORITY_TABS: StellaPageTab[] = [
{ id: 'tokens', label: 'Token Lifecycle', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' },
{ id: 'airgap', label: 'Air-Gap Events', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'incidents', label: 'Incidents', icon: '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|||M12 9v4|||M12 17h.01' },
];
@Component({
selector: 'app-audit-authority',
imports: [RouterModule],
imports: [RouterModule, StellaPageTabsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="authority-audit-page">
@@ -19,11 +26,12 @@ import { AuditEvent } from '../../core/api/audit-log.models';
<p class="description">Token lifecycle, revocations, air-gap events, and incidents</p>
</header>
<div class="tabs">
<button [class.active]="tab === 'tokens'" (click)="switchTab('tokens')">Token Lifecycle</button>
<button [class.active]="tab === 'airgap'" (click)="switchTab('airgap')">Air-Gap Events</button>
<button [class.active]="tab === 'incidents'" (click)="switchTab('incidents')">Incidents</button>
</div>
<stella-page-tabs
[tabs]="authorityTabs"
[activeTab]="tab"
(tabChange)="switchTab($any($event))"
ariaLabel="Authority audit tabs"
/>
<table class="events-table">
<thead>
@@ -80,12 +88,10 @@ import { AuditEvent } from '../../core/api/audit-log.models';
.authority-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.tabs button { padding: 0.5rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; }
.tabs button.active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-bg); }
.events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); }
.events-table th, .events-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.events-table th { background: var(--color-surface-elevated); font-weight: var(--font-weight-semibold); font-size: 0.85rem; }
@@ -113,6 +119,7 @@ import { AuditEvent } from '../../core/api/audit-log.models';
export class AuditAuthorityComponent implements OnInit {
private readonly auditClient = inject(AuditLogClient);
readonly authorityTabs = AUTHORITY_TABS;
readonly events = signal<AuditEvent[]>([]);
readonly hasMore = signal(false);
private cursor: string | null = null;

View File

@@ -78,7 +78,7 @@ import { AuditCorrelationCluster } from '../../core/api/audit-log.models';
.correlations-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.clusters-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1rem; }
@@ -99,7 +99,7 @@ import { AuditCorrelationCluster } from '../../core/api/audit-log.models';
.cluster-detail { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1.5rem; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.detail-header h2 { margin: 0; font-size: 1.1rem; }
.btn-secondary { padding: 0.5rem 1rem; background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; }
.btn-secondary { padding: 0.5rem 1rem; background: var(--color-btn-secondary-bg); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; }
.cluster-meta { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--color-text-secondary); }
.root-event, .related-events { margin-bottom: 1.5rem; }
.root-event h3, .related-events h3 { margin: 0 0 0.75rem; font-size: 0.95rem; }

View File

@@ -167,7 +167,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
.event-detail-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0; }
.event-card { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.event-header { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; background: var(--color-surface-elevated); border-bottom: 1px solid var(--color-border-primary); }
@@ -178,7 +178,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo
.label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; }
.value { font-size: 0.9rem; }
.mono { font-family: monospace; font-size: 0.85rem; }
.link { color: var(--color-brand-primary); text-decoration: none; }
.link { color: var(--color-text-link); text-decoration: none; }
.description-section, .tags-section, .details-section, .diff-section { margin-bottom: 1.5rem; }
.description-section h3, .tags-section h3, .details-section h3, .diff-section h3 { margin: 0 0 0.75rem; font-size: 1rem; }
.description-section p { margin: 0; }

View File

@@ -167,7 +167,7 @@ import { AuditExportRequest, AuditExportResponse, AuditLogFilters, AuditModule,
.export-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.export-config { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 2rem; }

View File

@@ -104,7 +104,7 @@ import { AuditEvent } from '../../core/api/audit-log.models';
.integrations-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); }

View File

@@ -4,10 +4,12 @@ import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '../../core/api/audit-log.models';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
@Component({
selector: 'app-audit-log-dashboard',
imports: [CommonModule, RouterModule],
imports: [CommonModule, RouterModule, StellaMetricCardComponent, StellaMetricGridComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-dashboard">
@@ -21,18 +23,20 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
</header>
@if (stats()) {
<section class="stats-strip">
<div class="stat-card">
<span class="stat-value">{{ stats()?.totalEvents | number }}</span>
<span class="stat-label">Total Events (7d)</span>
</div>
<stella-metric-grid [columns]="moduleStats().length + 1">
<stella-metric-card
label="Total Events (7d)"
[value]="(stats()?.totalEvents | number) ?? '0'"
icon="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8"
/>
@for (entry of moduleStats(); track entry.module) {
<div class="stat-card" [class]="entry.module">
<span class="stat-value">{{ entry.count | number }}</span>
<span class="stat-label">{{ formatModule(entry.module) }}</span>
</div>
<stella-metric-card
[label]="formatModule(entry.module)"
[value]="(entry.count | number) ?? '0'"
[icon]="getModuleIcon(entry.module)"
/>
}
</section>
</stella-metric-grid>
}
@if (allCountsZero()) {
@@ -159,20 +163,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
transition: border-color 150ms ease;
}
.btn-secondary:hover { border-color: var(--color-brand-primary); }
.stats-strip { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
.stat-card {
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); padding: 0.85rem 1.25rem; min-width: 120px; text-align: center;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); line-height: 1.2; }
.stat-label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.03em; }
.stat-card.policy { border-left: 4px solid var(--color-status-info); }
.stat-card.authority { border-left: 4px solid var(--color-status-excepted); }
.stat-card.vex { border-left: 4px solid var(--color-status-success); }
.stat-card.integrations { border-left: 4px solid var(--color-status-warning); }
.stat-card.jobengine { border-left: 4px solid var(--color-brand-secondary); }
stella-metric-grid { margin-bottom: 2rem; }
.anomaly-alerts { margin-bottom: 2rem; }
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
@@ -211,13 +202,13 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; }
.access-label { font-weight: var(--font-weight-semibold); display: block; margin-bottom: 0.25rem; }
.access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; color: var(--color-text-secondary); }
.access-label { font-weight: var(--font-weight-semibold); display: block; margin-bottom: 0.25rem; color: var(--color-text-heading); }
.access-desc { font-size: 0.8rem; color: var(--color-text-secondary); }
.recent-events { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.section-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary); }
.section-header h2 { margin: 0; font-size: 1rem; }
.link { font-size: 0.85rem; color: var(--color-brand-primary); text-decoration: none; }
.link { font-size: 0.85rem; color: var(--color-text-link); text-decoration: none; }
.link:hover { text-decoration: underline; }
.events-table { width: 100%; border-collapse: collapse; }
.events-table th, .events-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.84rem; }
@@ -322,6 +313,21 @@ export class AuditLogDashboardComponent implements OnInit {
return labels[module] || module;
}
getModuleIcon(module: AuditModule): string {
const icons: Record<string, string> = {
policy: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
authority: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M23 21v-2a4 4 0 0 0-3-3.87|||M16 3.13a4 4 0 0 1 0 7.75',
vex: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M9 11l3 3L22 4',
integrations: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6|||M15 3h6v6|||M10 14L21 3',
jobengine: 'M12 2v4|||M12 18v4|||M4.93 4.93l2.83 2.83|||M16.24 16.24l2.83 2.83|||M2 12h4|||M18 12h4|||M4.93 19.07l2.83-2.83|||M16.24 7.76l2.83-2.83',
scanner: 'M11 1a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M21 21l-4.35-4.35',
attestor: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
sbom: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6',
scheduler: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M12 6v6l4 2',
};
return icons[module] || 'M12 20V10|||M18 20V4|||M6 20v-4';
}
formatAnomalyType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -242,7 +242,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
.page-header { margin-bottom: 1.5rem; }
.page-header h1 { margin: 0; font-size: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem 1rem; margin-bottom: 1.5rem; }
.filter-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; align-items: flex-end; }
@@ -326,7 +326,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity }
.badge.severity.critical { background: var(--color-status-error-text); color: white; }
.actor-type { font-size: 0.7rem; color: var(--color-text-muted); }
.resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: var(--color-brand-primary); text-decoration: none; font-size: 0.8rem; }
.link { color: var(--color-text-link); text-decoration: none; font-size: 0.8rem; }
.link:hover { text-decoration: underline; }
.btn-xs {
padding: 0.15rem 0.4rem; font-size: 0.7rem; cursor: pointer; margin-left: 0.5rem;

View File

@@ -80,7 +80,7 @@ import { AuditEvent } from '../../core/api/audit-log.models';
.policy-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.event-categories { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }

View File

@@ -66,7 +66,7 @@ function mapActionToKind(action: string): TimelineEventKind {
.timeline-page { padding: 1.5rem; max-width: 1000px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }

View File

@@ -106,7 +106,7 @@ import { AuditEvent } from '../../core/api/audit-log.models';
.vex-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a { color: var(--color-text-link); text-decoration: none; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); }

View File

@@ -25,14 +25,43 @@ import {
BinaryFingerprintExport,
FingerprintExportEntry,
} from '../../core/api/binary-index-ops.client';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
// Sprint: SPRINT_20260117_007_CLI_binary_analysis (BAN-004)
type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
const BINARY_INDEX_TABS: readonly StellaPageTab[] = [
{
id: 'health',
label: 'Health',
icon: 'M22 12h-4l-3 9L9 3l-3 9H2',
},
{
id: 'bench',
label: 'Benchmark',
icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
},
{
id: 'cache',
label: 'Cache',
icon: 'M12 2C6.48 2 2 4.02 2 6.5v11c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5v-11C22 4.02 17.52 2 12 2z|||M2 6.5c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5|||M2 12c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5',
},
{
id: 'config',
label: 'Configuration',
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
},
{
id: 'fingerprint',
label: 'Fingerprint Export',
icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3',
},
];
@Component({
selector: 'app-binary-index-ops',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, StellaPageTabsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="binidx-ops">
@@ -57,54 +86,12 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
</div>
</header>
<nav class="binidx-ops__tabs" role="tablist">
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'health'"
(click)="setTab('health')"
role="tab"
[attr.aria-selected]="activeTab() === 'health'"
>
Health
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'bench'"
(click)="setTab('bench')"
role="tab"
[attr.aria-selected]="activeTab() === 'bench'"
>
Benchmark
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'cache'"
(click)="setTab('cache')"
role="tab"
[attr.aria-selected]="activeTab() === 'cache'"
>
Cache
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'config'"
(click)="setTab('config')"
role="tab"
[attr.aria-selected]="activeTab() === 'config'"
>
Configuration
</button>
<button
class="binidx-ops__tab"
[class.binidx-ops__tab--active]="activeTab() === 'fingerprint'"
(click)="setTab('fingerprint')"
role="tab"
[attr.aria-selected]="activeTab() === 'fingerprint'"
>
Fingerprint Export
</button>
</nav>
<stella-page-tabs
[tabs]="BINARY_INDEX_TABS"
[activeTab]="activeTab()"
(tabChange)="setTab($any($event))"
ariaLabel="BinaryIndex operations"
>
<main class="binidx-ops__content">
@if (loading()) {
<div class="loading-state">Loading BinaryIndex status...</div>
@@ -616,6 +603,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
}
}
</main>
</stella-page-tabs>
</div>
`,
styles: [`
@@ -662,9 +650,9 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
text-transform: uppercase;
}
.status-badge--healthy { background: var(--color-status-success-text); color: var(--color-status-success-border); }
.status-badge--degraded { background: var(--color-status-warning-text); color: var(--color-status-warning-border); }
.status-badge--unhealthy { background: var(--color-status-error-text); color: var(--color-status-error-border); }
.status-badge--healthy { background: var(--color-status-success-text); color: #fff; }
.status-badge--degraded { background: var(--color-status-warning-text); color: #fff; }
.status-badge--unhealthy { background: var(--color-status-error-text); color: #fff; }
.status-badge--unknown { background: var(--color-text-primary); color: var(--color-text-muted); }
.status-timestamp {
@@ -672,33 +660,6 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
color: var(--color-text-secondary);
}
.binidx-ops__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-text-primary);
margin-bottom: 1.5rem;
}
.binidx-ops__tab {
padding: 0.75rem 1.25rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s ease;
}
.binidx-ops__tab:hover {
color: rgba(212, 201, 168, 0.3);
}
.binidx-ops__tab--active {
color: var(--color-status-info);
border-bottom-color: var(--color-status-info);
}
.binidx-ops__content {
min-height: 400px;
}
@@ -1183,6 +1144,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
`],
})
export class BinaryIndexOpsComponent implements OnInit, OnDestroy {
readonly BINARY_INDEX_TABS = BINARY_INDEX_TABS;
private readonly client = inject(BinaryIndexOpsClient);
private refreshInterval: ReturnType<typeof setInterval> | null = null;

View File

@@ -92,7 +92,7 @@ interface ComponentDraft {
<section aria-label="Step 2: Component selector">
<h2 class="bundle-builder__step-title">Select Components</h2>
<p class="bundle-builder__hint">Add artifact versions to include in this bundle.</p>
<table class="bundle-builder__table" aria-label="Selected component versions">
<table class="stella-table stella-table--striped stella-table--hoverable bundle-builder__table" aria-label="Selected component versions">
<thead>
<tr>
<th>Component</th>
@@ -272,7 +272,7 @@ interface ComponentDraft {
}
.step-item--active .step-item__num {
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
}
@@ -329,7 +329,6 @@ interface ComponentDraft {
.bundle-builder__table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
margin-bottom: 0.85rem;
}
@@ -378,7 +377,7 @@ interface ComponentDraft {
}
.btn-primary {
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
}

View File

@@ -91,7 +91,7 @@ interface BundleRow {
{{ errorMessage }}
</p>
} @else {
<table class="bundle-catalog__table" aria-label="Bundle catalog">
<table class="stella-table stella-table--striped stella-table--hoverable bundle-catalog__table" aria-label="Bundle catalog">
<thead>
<tr>
<th>Bundle</th>
@@ -198,14 +198,13 @@ interface BundleRow {
}
.filter-chip--active {
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
border-color: var(--color-brand-primary, #4f46e5);
}
.bundle-catalog__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
@@ -305,7 +304,7 @@ interface BundleRow {
.btn-primary {
padding: 0.5rem 1rem;
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
border-radius: var(--radius-sm, 4px);
text-decoration: none;

View File

@@ -6,11 +6,30 @@ import {
ReleaseControlBundleDetailDto,
ReleaseControlBundleVersionSummaryDto,
} from './bundle-organizer.api';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
const BUNDLE_DETAIL_TABS: readonly StellaPageTab[] = [
{
id: 'versions',
label: 'Versions',
icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2',
},
{
id: 'config',
label: 'Config Contract',
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0',
},
{
id: 'changelog',
label: 'Changelog',
icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8',
},
];
@Component({
selector: 'app-bundle-detail',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, StellaPageTabsComponent],
template: `
<div class="bundle-detail">
<nav class="bundle-detail__back">
@@ -52,30 +71,12 @@ import {
</article>
</section>
<div class="bundle-detail__tabs" role="tablist">
<button
role="tab"
[class.tab--active]="activeTab() === 'versions'"
(click)="setTab('versions')"
>
Versions
</button>
<button
role="tab"
[class.tab--active]="activeTab() === 'config'"
(click)="setTab('config')"
>
Config Contract
</button>
<button
role="tab"
[class.tab--active]="activeTab() === 'changelog'"
(click)="setTab('changelog')"
>
Changelog
</button>
</div>
<stella-page-tabs
[tabs]="BUNDLE_DETAIL_TABS"
[activeTab]="activeTab()"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Bundle details"
>
@if (activeTab() === 'versions') {
<section class="bundle-detail__section" aria-label="Bundle versions">
<h2>Version timeline</h2>
@@ -87,7 +88,7 @@ import {
Create first bundle version
</a>
} @else {
<table class="bundle-detail__table">
<table class="stella-table stella-table--striped stella-table--hoverable bundle-detail__table">
<thead>
<tr>
<th>Version</th>
@@ -147,6 +148,7 @@ import {
<p class="bundle-detail__hint">Per-repository changelog exports are attached to evidence packs.</p>
</section>
}
</stella-page-tabs>
}
</div>
`,
@@ -197,30 +199,6 @@ import {
color: var(--color-text-secondary, #666);
}
.bundle-detail__tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border, #e5e7eb);
margin-bottom: 1.5rem;
}
.bundle-detail__tabs button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab--active {
color: var(--color-brand-primary, #4f46e5) !important;
border-bottom-color: var(--color-brand-primary, #4f46e5) !important;
font-weight: 600;
}
.bundle-detail__section {
padding: 1rem 0;
}
@@ -286,7 +264,6 @@ import {
.bundle-detail__table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
@@ -318,7 +295,7 @@ import {
.btn-primary {
padding: 0.5rem 1rem;
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
border-radius: var(--radius-sm, 4px);
text-decoration: none;
@@ -338,6 +315,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BundleDetailComponent implements OnInit {
readonly BUNDLE_DETAIL_TABS = BUNDLE_DETAIL_TABS;
private readonly route = inject(ActivatedRoute);
private readonly bundleApi = inject(BundleOrganizerApi);

View File

@@ -4,11 +4,30 @@ import {
BundleOrganizerApi,
ReleaseControlBundleVersionDetailDto,
} from './bundle-organizer.api';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [
{
id: 'components',
label: 'Components',
icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12',
},
{
id: 'validation',
label: 'Validation',
icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3',
},
{
id: 'releases',
label: 'Promotions',
icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z',
},
];
@Component({
selector: 'app-bundle-version-detail',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, StellaPageTabsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="bvd">
@@ -68,19 +87,19 @@ import {
</section>
}
<div class="bvd__tabs" role="tablist">
<button role="tab" [class.tab--active]="activeTab() === 'components'" (click)="setTab('components')">Components</button>
<button role="tab" [class.tab--active]="activeTab() === 'validation'" (click)="setTab('validation')">Validation</button>
<button role="tab" [class.tab--active]="activeTab() === 'releases'" (click)="setTab('releases')">Promotions</button>
</div>
<stella-page-tabs
[tabs]="BUNDLE_VERSION_TABS"
[activeTab]="activeTab()"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Bundle version details"
>
@if (activeTab() === 'components') {
<section aria-label="Manifest components">
<h2>Manifest components (digest-first)</h2>
@if (versionDetailModel.components.length === 0) {
<p class="bvd__empty">No components listed for this version.</p>
} @else {
<table class="bvd__table">
<table class="stella-table stella-table--striped stella-table--hoverable bvd__table">
<thead>
<tr>
<th>Component</th>
@@ -143,6 +162,7 @@ import {
<a routerLink="/releases/approvals" class="bvd__link">Create promotion from this version</a>
</section>
}
</stella-page-tabs>
}
</div>
`,
@@ -271,29 +291,6 @@ import {
border-color: var(--color-brand-primary, #6366f1);
}
.bvd__tabs {
display: flex;
border-bottom: 2px solid var(--color-border, #e5e7eb);
margin-bottom: 1rem;
}
.bvd__tabs button {
padding: 0.5rem 1rem;
border: none;
background: transparent;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text-secondary, #666);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab--active {
color: var(--color-brand-primary, #4f46e5) !important;
border-bottom-color: var(--color-brand-primary, #4f46e5) !important;
font-weight: 600;
}
section h2 {
margin: 0 0 0.35rem;
font-size: 0.9rem;
@@ -318,7 +315,6 @@ import {
.bvd__table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
@@ -360,7 +356,7 @@ import {
width: fit-content;
border: 0;
border-radius: 4px;
background: var(--color-brand-primary, #4f46e5);
background: var(--color-btn-primary-bg);
color: #fff;
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
@@ -423,6 +419,7 @@ import {
`],
})
export class BundleVersionDetailComponent implements OnInit {
readonly BUNDLE_VERSION_TABS = BUNDLE_VERSION_TABS;
private readonly route = inject(ActivatedRoute);
private readonly bundleApi = inject(BundleOrganizerApi);

View File

@@ -172,7 +172,7 @@ import { readReleaseInvestigationQueryState } from '../release-investigation/rel
}
.btn-primary {
background: var(--color-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
&:hover:not(:disabled) {

View File

@@ -26,7 +26,7 @@
.icon {
font-family: var(--font-family-mono);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
}
}
@@ -91,7 +91,7 @@
.offset {
font-family: var(--font-family-mono);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.size {
@@ -150,7 +150,7 @@
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.section {

View File

@@ -174,7 +174,7 @@
&.change-patched {
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
&.change-rebuilt {
@@ -242,7 +242,7 @@
&.change-patched {
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
&.change-rebuilt {
@@ -335,7 +335,7 @@
&.symbol-patched {
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
&.symbol-unchanged {

View File

@@ -30,7 +30,7 @@
.icon {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.title {

View File

@@ -126,7 +126,7 @@
.method-chip {
padding: var(--space-1) var(--space-3);
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);

View File

@@ -5,9 +5,11 @@
// -----------------------------------------------------------------------------
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input,
inject,} from '@angular/core';
import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.models';
import { DateFormatService } from '../../../../core/i18n/date-format.service';
@Component({
selector: 'stella-summary-header',
imports: [CommonModule],
@@ -16,6 +18,8 @@ import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.model
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SummaryHeaderComponent {
private readonly dateFmt = inject(DateFormatService);
@Input({ required: true }) trace!: ChangeTrace;
get verdictClass(): string {
@@ -64,7 +68,7 @@ export class SummaryHeaderComponent {
}
formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
return new Intl.NumberFormat(this.dateFmt.locale()).format(value);
}
formatDate(isoDate: string): string {

View File

@@ -14,7 +14,7 @@
font-weight: var(--font-weight-semibold);
mat-icon {
color: var(--color-brand-primary);
color: var(--color-text-link);
}
}
@@ -28,8 +28,8 @@
}
.action-upgrade mat-icon { color: var(--color-status-info); }
.action-patch mat-icon { color: var(--color-brand-secondary); }
.action-vex mat-icon { color: var(--color-brand-primary); }
.action-patch mat-icon { color: var(--color-text-link); }
.action-vex mat-icon { color: var(--color-text-link); }
.action-config mat-icon { color: var(--color-status-warning); }
.action-investigate mat-icon { color: var(--color-status-error); }

View File

@@ -67,7 +67,7 @@ interface CategoryInfo {
}
.categories-pane__clear {
font-size: 0.75rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
background: none;
border: none;
cursor: pointer;

View File

@@ -15,6 +15,7 @@ import { TrustIndicatorsComponent } from './trust-indicators.component';
import { DeltaSummaryStripComponent } from './delta-summary-strip.component';
import { ThreePaneLayoutComponent } from './three-pane-layout.component';
import { ExportActionsComponent } from './export-actions.component';
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
export type UserRole = 'developer' | 'security' | 'audit';
@@ -28,8 +29,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
TrustIndicatorsComponent,
DeltaSummaryStripComponent,
ThreePaneLayoutComponent,
ExportActionsComponent,
],
ExportActionsComponent, LoadingStateComponent],
template: `
<div class="compare-view" [class.compare-view--loading]="loading()">
<header class="compare-view__header">
@@ -62,8 +62,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
@if (loading()) {
<div class="compare-view__loading">
<div class="spinner"></div>
<span>Loading comparison...</span>
<app-loading-state size="md" message="Loading comparison..." />
</div>
}
@@ -161,7 +160,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; padding: 3rem; color: var(--color-text-muted);
}
.compare-view__empty a { color: var(--color-brand-primary); text-decoration: none; }
.compare-view__empty a { color: var(--color-text-link); text-decoration: none; }
.compare-view__empty a:hover { text-decoration: underline; }
.compare-view__explain-toggle {
position: fixed; bottom: 1.5rem; right: 1.5rem;

View File

@@ -17,7 +17,7 @@
font-size: var(--font-size-sm);
strong {
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.conflict {
@@ -68,7 +68,7 @@
.source-status {
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.source-priority {
@@ -79,7 +79,7 @@
.winner-badge {
margin-left: auto;
color: var(--color-brand-primary);
color: var(--color-text-link);
font-size: 24px;
width: 24px;
height: 24px;

View File

@@ -56,7 +56,7 @@
border-left: 3px solid var(--color-brand-primary);
.node-icon mat-icon {
color: var(--color-brand-primary);
color: var(--color-text-link);
}
}
@@ -163,7 +163,7 @@
mat-chip {
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
}
}

View File

@@ -364,12 +364,12 @@ import {
}
.btn-primary {
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.btn-secondary {

View File

@@ -13,10 +13,16 @@ import {
VAULT_PROVIDER_DEFINITIONS,
SETTINGS_STORE_PROVIDER_DEFINITIONS,
} from '../models/configuration-pane.models';
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component';
const CONFIG_DETAIL_TABS: StellaPageTab[] = [
{ id: 'config', label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
{ id: 'health', label: 'Health Checks', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' },
];
@Component({
selector: 'app-integration-detail',
imports: [FormsModule],
imports: [FormsModule, StellaPageTabsComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="detail-container">
@@ -52,23 +58,12 @@ import {
</div>
<!-- Tabs -->
<div class="tabs">
<button
class="tab"
[class.active]="activeTab === 'config'"
(click)="activeTab = 'config'">
Configuration
</button>
<button
class="tab"
[class.active]="activeTab === 'health'"
(click)="activeTab = 'health'">
Health Checks
@if (failedChecksCount() > 0) {
<span class="badge-error">{{ failedChecksCount() }}</span>
}
</button>
</div>
<stella-page-tabs
[tabs]="configDetailTabs"
[activeTab]="activeTab"
(tabChange)="activeTab = $any($event)"
ariaLabel="Integration detail tabs"
/>
<!-- Configuration Tab -->
@if (activeTab === 'config') {
@@ -603,7 +598,7 @@ import {
}
.btn-primary {
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
@@ -623,7 +618,7 @@ import {
}
.btn-primary:hover:not(:disabled) {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.btn-secondary:hover:not(:disabled) {
@@ -659,6 +654,7 @@ export class IntegrationDetailComponent {
readonly runChecks = output<void>();
readonly removeIntegration = output<void>();
readonly configDetailTabs = CONFIG_DETAIL_TABS;
activeTab: 'config' | 'health' = 'config';
getStatusLabel(status: string): string {

View File

@@ -164,7 +164,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
.btn-add {
padding: 6px 14px;
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
border: none;
border-radius: var(--radius-sm);
@@ -174,7 +174,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
}
.btn-add:hover {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.integrations-list {
@@ -284,7 +284,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
padding: 1px 5px;
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
border-radius: var(--radius-sm);
text-transform: uppercase;

View File

@@ -117,7 +117,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
} @else if (clients.length === 0 && !isCreating) {
<div class="empty-state">No OAuth2 clients configured</div>
} @else {
<table class="admin-table">
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
<thead>
<tr>
<th>Client ID</th>
@@ -293,7 +293,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--theme-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
@@ -333,7 +332,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
.badge {
display: inline-block;
padding: 2px 8px;
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
@@ -378,12 +377,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
}
.btn-primary {
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.btn-primary:disabled {

View File

@@ -1,15 +1,73 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, ChangeDetectionStrategy, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type TabType = 'tenants' | 'users' | 'roles' | 'clients' | 'tokens' | 'audit' | 'branding';
const KNOWN_TAB_IDS: readonly string[] = ['tenants', 'users', 'roles', 'clients', 'tokens', 'audit', 'branding'];
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'tenants', label: 'Tenants', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' },
{ id: 'users', label: 'Users', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' },
{ id: 'roles', label: 'Roles', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'clients', label: 'Clients', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' },
{ id: 'tokens', label: 'Tokens', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' },
{ id: 'audit', label: 'Audit', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' },
{ id: 'branding', label: 'Branding', icon: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5' },
];
/**
* Simple layout wrapper for Console Admin child routes.
* Provides the <router-outlet> needed for nested route rendering.
* Layout wrapper for Console Admin child routes.
* Provides canonical stella-page-tabs navigation and <router-outlet> for nested route rendering.
*/
@Component({
selector: 'app-console-admin-layout',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet />`,
imports: [RouterOutlet, StellaPageTabsComponent],
template: `
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Console admin tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet />
</stella-page-tabs>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConsoleAdminLayoutComponent {}
export class ConsoleAdminLayoutComponent implements OnInit {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<string>('tenants');
ngOnInit(): void {
this.setActiveTabFromUrl(this.router.url);
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects));
}
onTabChange(tabId: string): void {
this.activeTab.set(tabId as TabType);
this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' });
}
private setActiveTabFromUrl(url: string): void {
const segments = url.split('?')[0].split('/').filter(Boolean);
const lastSegment = segments.at(-1) ?? '';
if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) {
this.activeTab.set(lastSegment as TabType);
} else {
// Default to 'tenants' when at the admin root
this.activeTab.set('tenants');
}
}
}

View File

@@ -152,7 +152,7 @@ interface RoleBundle {
} @else if (customRoles.length === 0 && !isCreating) {
<div class="empty-state">No custom roles defined</div>
} @else {
<table class="admin-table">
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
<thead>
<tr>
<th>Role Name</th>
@@ -436,7 +436,6 @@ interface RoleBundle {
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--theme-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
@@ -471,7 +470,7 @@ interface RoleBundle {
.badge {
display: inline-block;
padding: 2px 8px;
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
@@ -494,12 +493,12 @@ interface RoleBundle {
}
.btn-primary {
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.btn-primary:disabled {

View File

@@ -84,7 +84,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
} @else if (users.length === 0 && !isCreating) {
<div class="empty-state">No users found</div>
} @else {
<table class="admin-table">
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
<thead>
<tr>
<th>Email</th>
@@ -210,7 +210,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--theme-bg-secondary);
border-radius: var(--radius-lg);
overflow: hidden;
@@ -249,7 +248,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
.badge {
display: inline-block;
padding: 2px 8px;
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-text-heading);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
@@ -290,12 +289,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
}
.btn-primary {
background: var(--theme-brand-primary);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--theme-brand-hover);
background: var(--color-btn-primary-bg-hover);
}
.btn-primary:disabled {

View File

@@ -23,9 +23,7 @@
}
@if (loading()) {
<div class="console-profile__loading">
Loading profile context...
</div>
<app-loading-state size="md" message="Loading profile context..." />
}
@if (!loading()) {

View File

@@ -57,7 +57,7 @@
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
}
@@ -140,7 +140,7 @@
.tenant-chip {
background-color: var(--color-brand-light);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.tenant-count {

View File

@@ -7,12 +7,13 @@ import {
inject,
} from '@angular/core';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
@Component({
selector: 'app-console-profile',
imports: [CommonModule],
imports: [CommonModule, LoadingStateComponent],
templateUrl: './console-profile.component.html',
styleUrls: ['./console-profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -863,12 +863,12 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
}
.btn--primary {
background: var(--so-brand);
color: var(--color-surface-inverse);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn--primary:hover {
background: var(--so-brand-hover);
background: var(--color-btn-primary-bg-hover);
transform: translateY(-1px);
}

View File

@@ -77,7 +77,7 @@
.cvss-tabs button.active {
border-bottom: 2px solid var(--color-brand-primary);
color: var(--color-brand-primary);
color: var(--color-text-link);
}
.cvss-panel {

View File

@@ -256,7 +256,7 @@ export interface DashboardAiData {
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: var(--color-brand-primary);
background: var(--color-btn-primary-bg);
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
@@ -293,7 +293,7 @@ export interface DashboardAiData {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--color-brand-primary);
color: var(--color-text-link);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;

View File

@@ -1,148 +1,145 @@
<div class="sources-dashboard">
<header class="dashboard-header">
<h1>{{ 'ui.sources_dashboard.title' | translate }}</h1>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="verifying()"
(click)="onVerifyLast24h()"
>
{{ verifying() ? ('ui.sources_dashboard.verifying' | translate) : ('ui.sources_dashboard.verify_24h' | translate) }}
</button>
<button class="btn btn-secondary" (click)="loadMetrics()">
{{ 'ui.actions.refresh' | translate }}
</button>
</div>
</header>
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<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()">{{ 'ui.actions.retry' | translate }}</button>
</div>
}
@if (metrics(); as m) {
<div class="metrics-grid">
<!-- Pass/Fail Tile -->
<div class="tile tile-pass-fail" [class]="passRateClass()">
<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">{{ '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">{{ 'ui.sources_dashboard.passed' | translate }}</span>
</div>
<div class="detail">
<span class="count fail">{{ m.failCount | number }}</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">{{ 'ui.labels.total' | translate }}</span>
</div>
</div>
</div>
</div>
<!-- Recent Violations Tile -->
<div class="tile tile-violations">
<h2 class="tile-title">{{ 'ui.sources_dashboard.recent_violations' | translate }}</h2>
<div class="tile-content">
@if (m.recentViolations.length === 0) {
<p class="empty-state">{{ 'ui.sources_dashboard.no_violations' | translate }}</p>
} @else {
<ul class="violations-list">
@for (v of m.recentViolations; track v.code) {
<li class="violation-item" [class]="getSeverityClass(v.severity)">
<div class="violation-header">
<code class="violation-code">{{ v.code }}</code>
<span class="violation-count">{{ v.count }}x</span>
</div>
<p class="violation-desc">{{ v.description }}</p>
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Ingest Throughput Tile -->
<div class="tile tile-throughput" [class]="throughputStatus()">
<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">{{ 'ui.sources_dashboard.docs_per_min' | translate }}</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</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">{{ 'ui.sources_dashboard.p95_ms' | translate }}</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.queueDepth }}</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">{{ 'ui.sources_dashboard.errors' | translate }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Verification Result -->
@if (verificationResult(); as result) {
<div class="verification-result" [class]="'status-' + result.status">
<h3>{{ 'ui.sources_dashboard.verification_complete' | translate }}</h3>
<div class="result-summary">
<span class="status-badge">{{ result.status | titlecase }}</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>{{ '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>{{ 'ui.sources_dashboard.field' | translate }} {{ v.field }} ({{ 'ui.sources_dashboard.expected' | translate }} {{ v.expected }}, {{ 'ui.sources_dashboard.actual' | translate }} {{ v.actual }})
}
</li>
}
</ul>
</details>
}
<p class="cli-hint">
{{ 'ui.sources_dashboard.cli_equivalent' | translate }} <code>stella aoc verify --since=24h --tenant=default</code>
</p>
</div>
}
<p class="time-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>
<div class="sources-dashboard">
<header class="dashboard-header">
<h1>{{ 'ui.sources_dashboard.title' | translate }}</h1>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="verifying()"
(click)="onVerifyLast24h()"
>
{{ verifying() ? ('ui.sources_dashboard.verifying' | translate) : ('ui.sources_dashboard.verify_24h' | translate) }}
</button>
<button class="btn btn-secondary" (click)="loadMetrics()">
{{ 'ui.actions.refresh' | translate }}
</button>
</div>
</header>
@if (loading()) {
<app-loading-state size="lg" [message]="'ui.sources_dashboard.loading_aoc' | translate" />
}
@if (error()) {
<div class="error-state">
<p class="error-message">{{ error() }}</p>
<button class="btn btn-secondary" (click)="loadMetrics()">{{ 'ui.actions.retry' | translate }}</button>
</div>
}
@if (metrics(); as m) {
<div class="metrics-grid">
<!-- Pass/Fail Tile -->
<div class="tile tile-pass-fail" [class]="passRateClass()">
<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">{{ '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">{{ 'ui.sources_dashboard.passed' | translate }}</span>
</div>
<div class="detail">
<span class="count fail">{{ m.failCount | number }}</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">{{ 'ui.labels.total' | translate }}</span>
</div>
</div>
</div>
</div>
<!-- Recent Violations Tile -->
<div class="tile tile-violations">
<h2 class="tile-title">{{ 'ui.sources_dashboard.recent_violations' | translate }}</h2>
<div class="tile-content">
@if (m.recentViolations.length === 0) {
<p class="empty-state">{{ 'ui.sources_dashboard.no_violations' | translate }}</p>
} @else {
<ul class="violations-list">
@for (v of m.recentViolations; track v.code) {
<li class="violation-item" [class]="getSeverityClass(v.severity)">
<div class="violation-header">
<code class="violation-code">{{ v.code }}</code>
<span class="violation-count">{{ v.count }}x</span>
</div>
<p class="violation-desc">{{ v.description }}</p>
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Ingest Throughput Tile -->
<div class="tile tile-throughput" [class]="throughputStatus()">
<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">{{ 'ui.sources_dashboard.docs_per_min' | translate }}</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</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">{{ 'ui.sources_dashboard.p95_ms' | translate }}</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.queueDepth }}</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">{{ 'ui.sources_dashboard.errors' | translate }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Verification Result -->
@if (verificationResult(); as result) {
<div class="verification-result" [class]="'status-' + result.status">
<h3>{{ 'ui.sources_dashboard.verification_complete' | translate }}</h3>
<div class="result-summary">
<span class="status-badge">{{ result.status | titlecase }}</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>{{ '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>{{ 'ui.sources_dashboard.field' | translate }} {{ v.field }} ({{ 'ui.sources_dashboard.expected' | translate }} {{ v.expected }}, {{ 'ui.sources_dashboard.actual' | translate }} {{ v.actual }})
}
</li>
}
</ul>
</details>
}
<p class="cli-hint">
{{ 'ui.sources_dashboard.cli_equivalent' | translate }} <code>stella aoc verify --since=24h --tenant=default</code>
</p>
</div>
}
<p class="time-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

@@ -296,7 +296,7 @@
summary {
cursor: pointer;
color: var(--color-brand-primary);
color: var(--color-text-link);
font-size: var(--font-size-base);
}

View File

@@ -14,10 +14,11 @@ import {
AocViolationSummary,
AocVerificationResult,
} from '../../core/api/aoc.models';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
@Component({
selector: 'app-sources-dashboard',
imports: [CommonModule, TranslatePipe],
imports: [CommonModule, TranslatePipe, LoadingStateComponent],
templateUrl: './sources-dashboard.component.html',
styleUrls: ['./sources-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -432,7 +432,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
.btn:hover:not(:disabled) { background: var(--color-surface-tertiary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: var(--color-btn-primary-text); border-color: var(--color-primary); }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-border, transparent); }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-icon { padding: 0.5rem; }
.spinning { animation: spin 1s linear infinite; }

View File

@@ -399,8 +399,8 @@ import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operation
.btn:hover:not(:disabled) { background: var(--color-surface-tertiary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: var(--color-btn-primary-text); border-color: var(--color-primary); }
.btn-secondary { background: var(--color-surface-secondary); }
.btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-border, transparent); }
.btn-secondary { background: var(--color-btn-secondary-bg); }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; }

View File

@@ -311,7 +311,7 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation
.btn:hover:not(:disabled) { background: var(--color-surface-tertiary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--color-surface-secondary); }
.btn-secondary { background: var(--color-btn-secondary-bg); }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn-icon { padding: 0.5rem; }
.spinning { animation: spin 1s linear infinite; }

View File

@@ -515,7 +515,7 @@ import {
.vuln-link {
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--color-brand-primary);
color: var(--color-text-link);
text-decoration: none;
&:hover {

Some files were not shown because too many files have changed in this diff Show More