UI fixes
This commit is contained in:
@@ -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": []
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export class BrandingService {
|
||||
// Default branding configuration
|
||||
private readonly defaultBranding: BrandingConfiguration = {
|
||||
tenantId: 'default',
|
||||
title: 'Stella Ops Dashboard',
|
||||
title: 'Stella Ops',
|
||||
themeTokens: {}
|
||||
};
|
||||
|
||||
|
||||
111
src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts
Normal file
111
src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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 & Access</a>
|
||||
<a routerLink="/setup/trust-signing">2. Trust & 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 & Access</a>
|
||||
<a routerLink="/setup/trust-signing" class="quick-link-pill">2. Trust & 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -346,7 +346,7 @@ interface ColumnConfig {
|
||||
}
|
||||
|
||||
&.sorted {
|
||||
color: var(--color-brand-primary);
|
||||
color: var(--color-text-link);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-brand-primary);
|
||||
color: var(--color-text-link);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user