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(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(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(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": [],
|
"deny": [],
|
||||||
"ask": []
|
"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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
|
import { ApplicationConfig, inject, LOCALE_ID, provideAppInitializer } from '@angular/core';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
|
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
|
||||||
import { provideMarkdown } from 'ngx-markdown';
|
import { provideMarkdown } from 'ngx-markdown';
|
||||||
@@ -307,6 +307,13 @@ export const appConfig: ApplicationConfig = {
|
|||||||
{ provide: TitleStrategy, useClass: PageTitleStrategy },
|
{ provide: TitleStrategy, useClass: PageTitleStrategy },
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideMarkdown(),
|
provideMarkdown(),
|
||||||
|
// Wire Angular's LOCALE_ID to the user's chosen locale so that built-in
|
||||||
|
// pipes (DatePipe, DecimalPipe, CurrencyPipe, etc.) respect regional settings.
|
||||||
|
{
|
||||||
|
provide: LOCALE_ID,
|
||||||
|
deps: [I18nService],
|
||||||
|
useFactory: (i18n: I18nService) => i18n.locale(),
|
||||||
|
},
|
||||||
provideAppInitializer(() => {
|
provideAppInitializer(() => {
|
||||||
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => {
|
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => {
|
||||||
await configService.load();
|
await configService.load();
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ export const routes: Routes = [
|
|||||||
data: { breadcrumb: 'Mission Control' },
|
data: { breadcrumb: 'Mission Control' },
|
||||||
loadChildren: () => import('./routes/mission-control.routes').then((m) => m.MISSION_CONTROL_ROUTES),
|
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',
|
path: 'releases',
|
||||||
title: 'Releases',
|
title: 'Releases',
|
||||||
@@ -329,7 +336,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'setup/regions-environments',
|
path: 'setup/regions-environments',
|
||||||
redirectTo: preserveAppRedirect('/setup/topology/regions'),
|
redirectTo: preserveAppRedirect('/environments/regions'),
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -382,11 +389,11 @@ export const routes: Routes = [
|
|||||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{ path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
{ path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
||||||
{ path: 'regions', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
{ path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' },
|
||||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
|
{ path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' },
|
||||||
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
|
{ path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' },
|
||||||
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
{ path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' },
|
||||||
{ path: 'governance', redirectTo: '/ops/policy', pathMatch: 'full' },
|
{ path: 'governance', redirectTo: '/ops/policy', pathMatch: 'full' },
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe('BrandingService', () => {
|
|||||||
|
|
||||||
service.fetchBranding().subscribe((response) => {
|
service.fetchBranding().subscribe((response) => {
|
||||||
expect(response.branding.tenantId).toBe('default');
|
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({});
|
expect(response.branding.themeTokens).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export class BrandingService {
|
|||||||
// Default branding configuration
|
// Default branding configuration
|
||||||
private readonly defaultBranding: BrandingConfiguration = {
|
private readonly defaultBranding: BrandingConfiguration = {
|
||||||
tenantId: 'default',
|
tenantId: 'default',
|
||||||
title: 'Stella Ops Dashboard',
|
title: 'Stella Ops',
|
||||||
themeTokens: {}
|
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 { TranslatePipe } from './translate.pipe';
|
||||||
export { LocaleCatalogService } from './locale-catalog.service';
|
export { LocaleCatalogService } from './locale-catalog.service';
|
||||||
export { UserLocalePreferenceService } from './user-locale-preference.service';
|
export { UserLocalePreferenceService } from './user-locale-preference.service';
|
||||||
|
export { DateFormatService } from './date-format.service';
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
|||||||
export const USER_MENU_ITEMS = [
|
export const USER_MENU_ITEMS = [
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Settings',
|
label: 'User Preferences',
|
||||||
route: '/settings/user-preferences',
|
route: '/settings/user-preferences',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
BundleFreshnessInfo,
|
BundleFreshnessInfo,
|
||||||
OfflineManifest
|
OfflineManifest
|
||||||
} from '../api/offline-kit.models';
|
} from '../api/offline-kit.models';
|
||||||
|
import { DateFormatService } from '../i18n/date-format.service';
|
||||||
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds
|
const HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds
|
||||||
const HEALTH_CHECK_TIMEOUT_MS = 3000; // 3 seconds
|
const HEALTH_CHECK_TIMEOUT_MS = 3000; // 3 seconds
|
||||||
@@ -19,6 +20,7 @@ const MANIFEST_CACHE_KEY = 'stellaops_offline_manifest';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class OfflineModeService implements OnDestroy {
|
export class OfflineModeService implements OnDestroy {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
private healthCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// Signals for reactive state management
|
// Signals for reactive state management
|
||||||
@@ -43,7 +45,7 @@ export class OfflineModeService implements OnDestroy {
|
|||||||
|
|
||||||
const freshness = this.bundleFreshness();
|
const freshness = this.bundleFreshness();
|
||||||
const dateStr = state.bundleCreatedAt
|
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'
|
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
})
|
})
|
||||||
: 'unknown';
|
: 'unknown';
|
||||||
|
|||||||
@@ -30,13 +30,23 @@ import {
|
|||||||
LocalizationConfig,
|
LocalizationConfig,
|
||||||
NotifyIncident,
|
NotifyIncident,
|
||||||
} from '../../core/api/notify.models';
|
} 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';
|
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({
|
@Component({
|
||||||
selector: 'app-admin-notifications',
|
selector: 'app-admin-notifications',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="admin-notifications-container">
|
<div class="admin-notifications-container">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
@@ -107,26 +117,12 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<stella-page-tabs
|
||||||
<button class="tab" [class.active]="activeTab() === 'channels'" (click)="activeTab.set('channels')">
|
[tabs]="NOTIFY_ADMIN_TABS"
|
||||||
Channels
|
[activeTab]="activeTab()"
|
||||||
</button>
|
(tabChange)="activeTab.set($any($event))"
|
||||||
<button class="tab" [class.active]="activeTab() === 'rules'" (click)="activeTab.set('rules')">
|
ariaLabel="Notification admin tabs"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Channels Tab -->
|
<!-- Channels Tab -->
|
||||||
@if (activeTab() === 'channels') {
|
@if (activeTab() === 'channels') {
|
||||||
@@ -139,7 +135,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
|||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading channels...</div>
|
<div class="loading">Loading channels...</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -197,7 +193,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
|||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading rules...</div>
|
<div class="loading">Loading rules...</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -284,7 +280,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
|||||||
} @else if (deliveries().length === 0) {
|
} @else if (deliveries().length === 0) {
|
||||||
<div class="empty-state">No delivery records found.</div>
|
<div class="empty-state">No delivery records found.</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<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>
|
<p class="text-muted">Incidents are created when escalation policies are triggered.</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<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; }
|
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; border-bottom: 1px solid var(--color-border-primary); flex-wrap: wrap; }
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.75rem 1rem; border: none; background: none; cursor: pointer;
|
height: var(--color-tab-height, 48px); padding: 0 1rem; display: inline-flex; align-items: center;
|
||||||
font-size: 0.875rem; color: var(--color-text-secondary); border-bottom: 2px solid transparent;
|
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-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 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
.tab-header h2 { margin: 0; font-size: 1.25rem; }
|
.tab-header h2 { margin: 0; font-size: 1.25rem; }
|
||||||
|
|
||||||
.btn-primary {
|
.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);
|
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 { 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); }
|
.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, .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); }
|
.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; }
|
.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; }
|
.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 { 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.enabled { background: var(--color-status-success-border); color: #fff; }
|
||||||
.status-badge.disabled { background: var(--color-status-error-border); color: var(--color-status-error-text); }
|
.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 { 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; }
|
.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; }
|
.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); }
|
.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); }
|
.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-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-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); }
|
.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; }
|
.error-text { color: var(--color-status-error-text); font-size: 0.875rem; }
|
||||||
.incident-id { font-family: monospace; 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); }
|
.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-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 {
|
.setup-guidance {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
@@ -574,6 +571,7 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
|
|||||||
export class AdminNotificationsComponent implements OnInit {
|
export class AdminNotificationsComponent implements OnInit {
|
||||||
private readonly api = inject<NotifyApi>(NOTIFY_API);
|
private readonly api = inject<NotifyApi>(NOTIFY_API);
|
||||||
|
|
||||||
|
readonly NOTIFY_ADMIN_TABS = NOTIFY_ADMIN_TABS;
|
||||||
readonly activeTab = signal<NotifyAdminTab>('channels');
|
readonly activeTab = signal<NotifyAdminTab>('channels');
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -32,7 +33,7 @@ interface ChannelTypeOption {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-channel-management',
|
selector: 'app-channel-management',
|
||||||
imports: [FormsModule, ReactiveFormsModule],
|
imports: [FormsModule, ReactiveFormsModule, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="channel-management">
|
<div class="channel-management">
|
||||||
<!-- Channel List View -->
|
<!-- Channel List View -->
|
||||||
@@ -60,10 +61,7 @@ interface ChannelTypeOption {
|
|||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="md" message="Loading channels..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading channels...</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Channel Cards -->
|
<!-- Channel Cards -->
|
||||||
@@ -659,13 +657,13 @@ interface ChannelTypeOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
.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 {
|
.loading-state, .empty-state {
|
||||||
display: flex;
|
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 { 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); }
|
.loading-state { padding: 3rem; text-align: center; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -24,7 +25,7 @@ import {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-delivery-history',
|
selector: 'app-delivery-history',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="delivery-history">
|
<div class="delivery-history">
|
||||||
<!-- Statistics Summary -->
|
<!-- Statistics Summary -->
|
||||||
@@ -100,16 +101,13 @@ import {
|
|||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="md" message="Loading delivery history..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading delivery history...</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Delivery Table -->
|
<!-- Delivery Table -->
|
||||||
@if (!loading() && deliveries().length > 0) {
|
@if (!loading() && deliveries().length > 0) {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
@@ -349,7 +347,7 @@ import {
|
|||||||
.stat-value.failed { color: var(--color-status-error); }
|
.stat-value.failed { color: var(--color-status-error); }
|
||||||
.stat-value.pending { color: var(--color-status-warning-text); }
|
.stat-value.pending { color: var(--color-status-warning-text); }
|
||||||
.stat-value.throttled { color: var(--color-status-info-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 {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -391,18 +389,12 @@ import {
|
|||||||
cursor: pointer;
|
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-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); }
|
||||||
|
|
||||||
.table-container {
|
.table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th,
|
.data-table th,
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|||||||
@@ -250,9 +250,9 @@ import {
|
|||||||
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
|
.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 { 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-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-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 { 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); }
|
.btn-icon.btn-danger { color: var(--color-status-error); }
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
DestroyRef,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} 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 { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||||
@@ -22,16 +25,51 @@ import {
|
|||||||
NotifierDelivery,
|
NotifierDelivery,
|
||||||
NotifierDeliveryStats,
|
NotifierDeliveryStats,
|
||||||
} from '../../../core/api/notifier.models';
|
} 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';
|
export type NotificationTab = 'rules' | 'channels' | 'templates' | 'delivery' | 'simulator' | 'config';
|
||||||
|
|
||||||
interface TabDefinition {
|
const TAB_ROUTE_MAP: Record<NotificationTab, string> = {
|
||||||
id: NotificationTab;
|
rules: 'rules',
|
||||||
label: string;
|
channels: 'channels',
|
||||||
description: string;
|
templates: 'templates',
|
||||||
icon: string;
|
delivery: 'delivery',
|
||||||
route: string;
|
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 {
|
interface ConfigSubTab {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,503 +79,439 @@ interface ConfigSubTab {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notification-dashboard',
|
selector: 'app-notification-dashboard',
|
||||||
imports: [RouterModule],
|
imports: [CommonModule, RouterModule, StellaPageTabsComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="notification-dashboard">
|
<div class="nd">
|
||||||
<header class="dashboard-header">
|
<header class="nd__header">
|
||||||
<div class="header-content">
|
<div class="nd__title-block">
|
||||||
|
<p class="nd__eyebrow">Setup</p>
|
||||||
<h1>Notification Administration</h1>
|
<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>
|
||||||
<div class="header-actions">
|
<div class="nd__actions">
|
||||||
<button class="btn btn-secondary" (click)="refreshStats()">
|
<a routerLink="/ops/operations/notifications" class="nd__btn nd__btn--secondary">
|
||||||
Refresh Stats
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="owner-banner">
|
<!-- Stats Strip -->
|
||||||
<div>
|
@if (loadingStats() && !stats()) {
|
||||||
<strong>Setup-owned notification studio</strong>
|
<div class="nd__stats">
|
||||||
<p>
|
@for (i of [1,2,3,4,5,6]; track i) {
|
||||||
Use this setup surface for channel lifecycle, routing policy, templates, throttles, and escalation design.
|
<div class="nd__stat nd__stat--skeleton">
|
||||||
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
|
<div class="nd__stat-icon-skel"></div>
|
||||||
</p>
|
<div class="nd__stat-text-skel">
|
||||||
</div>
|
<div class="skel-line skel-line--value"></div>
|
||||||
<div class="owner-banner__actions">
|
<div class="skel-line skel-line--label"></div>
|
||||||
<a routerLink="/ops/operations/notifications" class="btn btn-secondary">Open operator console</a>
|
</div>
|
||||||
<a
|
</div>
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
</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 -->
|
<!-- Tabs -->
|
||||||
@if (activeTab() === 'delivery') {
|
<stella-page-tabs
|
||||||
<nav class="sub-navigation" role="tablist">
|
[tabs]="pageTabs"
|
||||||
<a class="sub-tab-button" routerLink="delivery" queryParamsHandling="merge" aria-label="History" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">History</a>
|
[activeTab]="activeTab()"
|
||||||
<a class="sub-tab-button" routerLink="delivery/analytics" queryParamsHandling="merge" aria-label="Analytics" routerLinkActive="active">Analytics</a>
|
ariaLabel="Notification administration tabs"
|
||||||
</nav>
|
(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 -->
|
<!-- Delivery Sub-Navigation -->
|
||||||
<main class="tab-content" role="tabpanel" [attr.id]="'panel-' + activeTab()">
|
@if (activeTab() === 'delivery') {
|
||||||
<div class="content-section">
|
<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>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</stella-page-tabs>
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<div class="error-banner" role="alert">
|
<div class="nd__toast" role="alert">
|
||||||
<span class="error-icon">!</span>
|
<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="error-message">{{ error() }}</span>
|
<span class="nd__toast-msg">{{ error() }}</span>
|
||||||
<button class="error-dismiss" (click)="dismissError()">Dismiss</button>
|
<button class="nd__toast-dismiss" (click)="dismissError()">Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.notification-dashboard {
|
:host { display: block; }
|
||||||
padding: 1.5rem;
|
|
||||||
|
.nd {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
/* ---- Header ---- */
|
||||||
|
.nd__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content h1 {
|
.nd__eyebrow {
|
||||||
margin: 0;
|
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-size: 1.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-surface-inverse);
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.nd__lede {
|
||||||
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 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-muted);
|
||||||
max-width: 64ch;
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-banner__actions {
|
.nd__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Statistics Overview */
|
.nd__btn {
|
||||||
.stats-overview {
|
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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-overview.loading {
|
.nd__stats--loading {
|
||||||
opacity: 0.6;
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.nd__stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
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 {
|
.nd__stat:hover {
|
||||||
box-shadow: var(--shadow-md);
|
border-color: var(--color-border-secondary);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.nd__stat-icon {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: var(--font-weight-bold);
|
flex-shrink: 0;
|
||||||
font-size: 1rem;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-icon { background: var(--color-status-info); }
|
.nd__stat-icon--rules { background: rgba(34, 211, 238, 0.12); color: var(--color-status-info); }
|
||||||
.channels-icon { background: var(--color-status-excepted); }
|
.nd__stat-icon--channels { background: rgba(167, 139, 250, 0.12); color: var(--color-status-excepted-border); }
|
||||||
.sent-icon { background: var(--color-status-success); }
|
.nd__stat-icon--sent { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); }
|
||||||
.failed-icon { background: var(--color-status-error); }
|
.nd__stat-icon--failed { background: rgba(239, 68, 68, 0.10); color: var(--color-status-error); }
|
||||||
.pending-icon { background: var(--color-status-warning); }
|
.nd__stat-icon--pending { background: rgba(251, 191, 36, 0.12); color: var(--color-status-warning-border); }
|
||||||
.rate-icon { background: var(--color-brand-secondary); }
|
.nd__stat-icon--rate { background: rgba(99, 102, 241, 0.12); color: var(--color-brand-primary); }
|
||||||
|
|
||||||
.stat-content {
|
.nd__stat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.nd__stat-value {
|
||||||
font-size: 1.5rem;
|
font-size: 1.375rem;
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
color: var(--color-surface-inverse);
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.nd__stat-value--alert {
|
||||||
font-size: 0.75rem;
|
color: var(--color-status-error);
|
||||||
color: var(--color-text-secondary);
|
}
|
||||||
|
|
||||||
|
.nd__stat-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Navigation */
|
/* ---- Skeleton Stats ---- */
|
||||||
.tab-navigation {
|
.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;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.nd__sub-tab {
|
||||||
display: flex;
|
padding: 0.375rem 0.75rem;
|
||||||
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;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub-tab-button:hover {
|
.nd__sub-tab: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);
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-color: var(--color-border-secondary);
|
background: var(--color-surface-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.nd__sub-tab--active {
|
||||||
background: var(--color-surface-primary);
|
color: var(--color-tab-active-text);
|
||||||
border-color: var(--color-text-muted);
|
background: var(--color-surface-tertiary);
|
||||||
|
border-color: var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error Banner */
|
/* ---- Content area ---- */
|
||||||
.error-banner {
|
.nd__content {
|
||||||
|
min-height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Error toast ---- */
|
||||||
|
.nd__toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.65rem 1rem;
|
||||||
background: var(--color-status-error-bg);
|
background: var(--color-status-error-bg);
|
||||||
border: 1px solid var(--color-status-error-border);
|
border: 1px solid var(--color-status-error-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
color: var(--color-status-error-text);
|
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;
|
z-index: 1000;
|
||||||
|
animation: nd-toast-in 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon {
|
@keyframes nd-toast-in {
|
||||||
width: 24px;
|
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||||
height: 24px;
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.nd__toast-icon {
|
||||||
|
color: var(--color-status-error);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nd__toast-msg {
|
||||||
|
font-size: 0.8125rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-dismiss {
|
.nd__toast-dismiss {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-status-error-text);
|
color: var(--color-status-error-text);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
.nd__toast-dismiss:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* ---- Responsive ---- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.notification-dashboard {
|
.nd { padding: 1rem; }
|
||||||
padding: 1rem;
|
.nd__header { flex-direction: column; }
|
||||||
}
|
.nd__stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.nd__sub-nav { overflow-x: auto; scrollbar-width: none; }
|
||||||
.owner-banner {
|
.nd__sub-nav::-webkit-scrollbar { display: none; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`],
|
`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -546,15 +520,9 @@ export class NotificationDashboardComponent implements OnInit {
|
|||||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
readonly tabs: TabDefinition[] = [
|
readonly pageTabs: readonly StellaPageTab[] = NOTIFICATION_TABS;
|
||||||
{ 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 configSubTabs: ConfigSubTab[] = [
|
readonly configSubTabs: ConfigSubTab[] = [
|
||||||
{ id: 'quiet-hours', label: 'Quiet Hours', route: 'config/quiet-hours' },
|
{ id: 'quiet-hours', label: 'Quiet Hours', route: 'config/quiet-hours' },
|
||||||
@@ -583,14 +551,16 @@ export class NotificationDashboardComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Determine initial tab from route
|
this.setActiveTabFromUrl(this.router.url);
|
||||||
const path = this.route.snapshot.firstChild?.routeConfig?.path;
|
|
||||||
if (path) {
|
this.router.events
|
||||||
const matchedTab = this.tabs.find(t => t.route === path || path.startsWith(t.route));
|
.pipe(
|
||||||
if (matchedTab) {
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
this.activeTab.set(matchedTab.id);
|
takeUntilDestroyed(this.destroyRef),
|
||||||
}
|
)
|
||||||
}
|
.subscribe((event) => {
|
||||||
|
this.setActiveTabFromUrl(event.urlAfterRedirects);
|
||||||
|
});
|
||||||
|
|
||||||
await this.loadInitialData();
|
await this.loadInitialData();
|
||||||
}
|
}
|
||||||
@@ -620,8 +590,11 @@ export class NotificationDashboardComponent implements OnInit {
|
|||||||
await this.loadInitialData();
|
await this.loadInitialData();
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveTab(tabId: NotificationTab): void {
|
onTabChange(tabId: string): void {
|
||||||
this.activeTab.set(tabId);
|
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> {
|
async refreshDeliveryHistory(): Promise<void> {
|
||||||
@@ -636,4 +609,15 @@ export class NotificationDashboardComponent implements OnInit {
|
|||||||
dismissError(): void {
|
dismissError(): void {
|
||||||
this.error.set(null);
|
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 {
|
.btn-primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -21,7 +22,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-notification-rule-list',
|
selector: 'app-notification-rule-list',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="rule-list-container">
|
<div class="rule-list-container">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -51,10 +52,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
|||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="md" message="Loading rules..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading rules...</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
@@ -74,7 +72,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
|||||||
<!-- Rules Table -->
|
<!-- Rules Table -->
|
||||||
@if (!loading() && filteredRules().length > 0) {
|
@if (!loading() && filteredRules().length > 0) {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-status">Status</th>
|
<th class="col-status">Status</th>
|
||||||
@@ -232,7 +230,7 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-status-info-text);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -291,12 +289,6 @@ import { NotifierRule, NotifierRuleStatus, NotifierSeverity } from '../../../cor
|
|||||||
.table-container {
|
.table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th,
|
.data-table th,
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 0.75rem;
|
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 { 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-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 { 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); }
|
.btn-icon.btn-danger { color: var(--color-status-error); }
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-operator-override',
|
selector: 'app-operator-override',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ReactiveFormsModule],
|
imports: [CommonModule, FormsModule, ReactiveFormsModule, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="operator-override">
|
<div class="operator-override">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
@@ -49,7 +50,7 @@ import {
|
|||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">Loading...</div>
|
<app-loading-state size="md" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -327,8 +328,8 @@ import {
|
|||||||
.section-header p { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; }
|
.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 { 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-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-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 { 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); }
|
.btn-icon.btn-danger { color: var(--color-status-error); }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -19,7 +20,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-quiet-hours-config',
|
selector: 'app-quiet-hours-config',
|
||||||
imports: [FormsModule, ReactiveFormsModule],
|
imports: [FormsModule, ReactiveFormsModule, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="quiet-hours-config">
|
<div class="quiet-hours-config">
|
||||||
<header class="section-header">
|
<header class="section-header">
|
||||||
@@ -32,7 +33,7 @@ import { NotifierQuietHours, NotifierQuietHoursRequest, NotifierQuietWindow } fr
|
|||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">Loading...</div>
|
<app-loading-state size="md" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Quiet Hours List -->
|
<!-- 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; }
|
.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 { 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-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-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 { 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); }
|
.btn-icon.btn-danger { color: var(--color-status-error); }
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ import {
|
|||||||
flex: 1;
|
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-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; }
|
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
.quick-templates {
|
.quick-templates {
|
||||||
|
|||||||
@@ -345,9 +345,9 @@ import {
|
|||||||
cursor: pointer;
|
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-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-sm { padding: 0.375rem 0.75rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
.btn-icon {
|
.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; }
|
.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 { 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-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 { 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); }
|
.btn-icon.btn-danger { color: var(--color-status-error); }
|
||||||
|
|
||||||
|
|||||||
@@ -61,22 +61,22 @@ interface SetupCard {
|
|||||||
<aside class="admin-overview__aside">
|
<aside class="admin-overview__aside">
|
||||||
<section class="admin-overview__aside-section">
|
<section class="admin-overview__aside-section">
|
||||||
<h2 class="admin-overview__section-heading">First-Time Setup Path</h2>
|
<h2 class="admin-overview__section-heading">First-Time Setup Path</h2>
|
||||||
<div class="admin-overview__quick-actions">
|
<div class="quick-links-row">
|
||||||
<a routerLink="/setup-wizard/wizard">Start guided setup</a>
|
<a routerLink="/setup-wizard/wizard" class="quick-link-pill">Start guided setup</a>
|
||||||
<a routerLink="/setup/identity-access">1. Identity & Access</a>
|
<a routerLink="/setup/identity-access" class="quick-link-pill">1. Identity & Access</a>
|
||||||
<a routerLink="/setup/trust-signing">2. Trust & Signing</a>
|
<a routerLink="/setup/trust-signing" class="quick-link-pill">2. Trust & Signing</a>
|
||||||
<a routerLink="/setup/integrations">3. Integrations</a>
|
<a routerLink="/setup/integrations" class="quick-link-pill">3. Integrations</a>
|
||||||
<a routerLink="/setup/topology/overview">4. Topology</a>
|
<a routerLink="/setup/topology/overview" class="quick-link-pill">4. Topology</a>
|
||||||
<a routerLink="/setup/notifications">5. Notifications</a>
|
<a routerLink="/setup/notifications" class="quick-link-pill">5. Notifications</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="admin-overview__aside-section">
|
<section class="admin-overview__aside-section">
|
||||||
<h2 class="admin-overview__section-heading">Quick Actions</h2>
|
<h2 class="admin-overview__section-heading">Quick Actions</h2>
|
||||||
<div class="admin-overview__quick-actions">
|
<div class="quick-links-row">
|
||||||
<a routerLink="/setup/topology/targets">Add Target</a>
|
<a routerLink="/setup/topology/targets" class="quick-link-pill">Add Target</a>
|
||||||
<a routerLink="/setup/integrations">Configure Integrations</a>
|
<a routerLink="/setup/integrations" class="quick-link-pill">Configure Integrations</a>
|
||||||
<a routerLink="/setup/identity-access">Review Access</a>
|
<a routerLink="/setup/identity-access" class="quick-link-pill">Review Access</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -213,26 +213,7 @@ interface SetupCard {
|
|||||||
color: var(--color-text-secondary, #4f4b3e);
|
color: var(--color-text-secondary, #4f4b3e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-overview__quick-actions {
|
/* Quick Actions — uses global .quick-links-row / .quick-link-pill */
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-overview__quick-actions a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--color-border-primary, #e5e7eb);
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
padding: 0.45rem 0.55rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-text-primary, #1c1200);
|
|
||||||
background: var(--color-surface-secondary, #faf8f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-overview__quick-actions a:hover {
|
|
||||||
border-color: var(--color-brand-primary, #f5a623);
|
|
||||||
color: var(--color-brand-primary, #f5a623);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-overview__links {
|
.admin-overview__links {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ import type { ExplanationCitation, EvidenceType } from '../../core/api/advisory-
|
|||||||
}
|
}
|
||||||
|
|
||||||
.evidence-type-badge.type-patch {
|
.evidence-type-badge.type-patch {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
background: var(--color-status-excepted-bg);
|
background: var(--color-status-excepted-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ import type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.citation-type.type-patch {
|
.citation-type.type-patch {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
background: var(--color-status-excepted-bg);
|
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 {
|
import type {
|
||||||
PullRequestInfo,
|
PullRequestInfo,
|
||||||
PullRequestStatus,
|
PullRequestStatus,
|
||||||
@@ -581,6 +583,8 @@ import type {
|
|||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class PrTrackerComponent {
|
export class PrTrackerComponent {
|
||||||
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
|
|
||||||
@Input() pullRequest: PullRequestInfo | null = null;
|
@Input() pullRequest: PullRequestInfo | null = null;
|
||||||
|
|
||||||
@Output() readonly merge = new EventEmitter<void>();
|
@Output() readonly merge = new EventEmitter<void>();
|
||||||
@@ -658,7 +662,7 @@ export class PrTrackerComponent {
|
|||||||
|
|
||||||
formatDate(iso: string): string {
|
formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
return new Date(iso).toLocaleDateString(this.dateFmt.locale(), {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ import type {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-type.type-vex_document {
|
.step-type.type-vex_document {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
background: var(--color-status-excepted-bg);
|
background: var(--color-status-excepted-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||||
|
|
||||||
import { AgentStore } from './services/agent.store';
|
import { AgentStore } from './services/agent.store';
|
||||||
import {
|
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 { AgentTasksTabComponent } from './components/agent-tasks-tab/agent-tasks-tab.component';
|
||||||
import { AgentActionModalComponent } from './components/agent-action-modal/agent-action-modal.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';
|
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 {
|
interface ActionFeedback {
|
||||||
type: 'success' | 'error';
|
type: 'success' | 'error';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -33,7 +44,7 @@ interface ActionFeedback {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'st-agent-detail-page',
|
selector: 'st-agent-detail-page',
|
||||||
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent],
|
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent, LoadingStateComponent, StellaPageTabsComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="agent-detail-page">
|
<div class="agent-detail-page">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
@@ -44,10 +55,7 @@ interface ActionFeedback {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (store.isLoading()) {
|
@if (store.isLoading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="lg" message="Loading agent details..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Loading agent details...</p>
|
|
||||||
</div>
|
|
||||||
} @else if (store.error()) {
|
} @else if (store.error()) {
|
||||||
<div class="error-state">
|
<div class="error-state">
|
||||||
<p class="error-state__message">{{ store.error() }}</p>
|
<p class="error-state__message">{{ store.error() }}</p>
|
||||||
@@ -127,23 +135,12 @@ interface ActionFeedback {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<nav class="tabs" role="tablist">
|
<stella-page-tabs
|
||||||
@for (tab of tabs; track tab.id) {
|
[tabs]="tabs"
|
||||||
<button
|
[activeTab]="activeTab()"
|
||||||
type="button"
|
(tabChange)="activeTab.set($any($event))"
|
||||||
role="tab"
|
ariaLabel="Agent detail tabs"
|
||||||
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">
|
|
||||||
@switch (activeTab()) {
|
@switch (activeTab()) {
|
||||||
@case ('overview') {
|
@case ('overview') {
|
||||||
<section class="overview-section">
|
<section class="overview-section">
|
||||||
@@ -310,7 +307,7 @@ interface ActionFeedback {
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</stella-page-tabs>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Action Confirmation Modal -->
|
<!-- Action Confirmation Modal -->
|
||||||
@@ -371,7 +368,7 @@ interface ActionFeedback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb__link {
|
.breadcrumb__link {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -452,34 +449,6 @@ interface ActionFeedback {
|
|||||||
color: var(--color-text-secondary);
|
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 */
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -773,6 +742,8 @@ interface ActionFeedback {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export class AgentDetailPageComponent implements OnInit {
|
export class AgentDetailPageComponent implements OnInit {
|
||||||
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
|
|
||||||
readonly store = inject(AgentStore);
|
readonly store = inject(AgentStore);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
@@ -785,13 +756,7 @@ export class AgentDetailPageComponent implements OnInit {
|
|||||||
readonly actionFeedback = signal<ActionFeedback | null>(null);
|
readonly actionFeedback = signal<ActionFeedback | null>(null);
|
||||||
private feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
private feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
readonly tabs: { id: DetailTab; label: string }[] = [
|
readonly tabs = AGENT_DETAIL_TABS;
|
||||||
{ id: 'overview', label: 'Overview' },
|
|
||||||
{ id: 'health', label: 'Health' },
|
|
||||||
{ id: 'tasks', label: 'Tasks' },
|
|
||||||
{ id: 'logs', label: 'Logs' },
|
|
||||||
{ id: 'config', label: 'Configuration' },
|
|
||||||
];
|
|
||||||
|
|
||||||
readonly agent = computed(() => this.store.selectedAgent());
|
readonly agent = computed(() => this.store.selectedAgent());
|
||||||
readonly statusColor = computed(() =>
|
readonly statusColor = computed(() =>
|
||||||
@@ -811,10 +776,6 @@ export class AgentDetailPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveTab(tab: DetailTab): void {
|
|
||||||
this.activeTab.set(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleActionsMenu(): void {
|
toggleActionsMenu(): void {
|
||||||
this.showActionsMenu.update((v) => !v);
|
this.showActionsMenu.update((v) => !v);
|
||||||
}
|
}
|
||||||
@@ -889,7 +850,7 @@ export class AgentDetailPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formatDate(iso: string): string {
|
formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
return new Date(iso).toLocaleDateString(this.dateFmt.locale(), {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular
|
|||||||
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||||
|
|
||||||
import { AgentStore } from './services/agent.store';
|
import { AgentStore } from './services/agent.store';
|
||||||
import { Agent, AgentStatus, getStatusColor, getStatusLabel } from './models/agent.models';
|
import { Agent, AgentStatus, getStatusColor, getStatusLabel } from './models/agent.models';
|
||||||
@@ -21,7 +22,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'st-agent-fleet-dashboard',
|
selector: 'st-agent-fleet-dashboard',
|
||||||
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent],
|
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent, LoadingStateComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="agent-fleet-dashboard">
|
<div class="agent-fleet-dashboard">
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
@@ -218,10 +219,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
|||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@if (store.isLoading()) {
|
@if (store.isLoading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="lg" message="Loading agents..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Loading agents...</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@@ -410,7 +408,7 @@ type ViewMode = 'grid' | 'heatmap' | 'table';
|
|||||||
|
|
||||||
.btn--text {
|
.btn--text {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
|||||||
.wizard-header__back {
|
.wizard-header__back {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
||||||
@@ -270,16 +270,16 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
|||||||
|
|
||||||
.progress-step--active .progress-step__number {
|
.progress-step--active .progress-step__number {
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-step--active .progress-step__label {
|
.progress-step--active .progress-step__label {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-step--completed .progress-step__number {
|
.progress-step--completed .progress-step__number {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -466,7 +466,7 @@ type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete
|
|||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal__icon--info {
|
.modal__icon--info {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal__close {
|
.modal__close {
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ import { AgentHealthResult } from '../../models/agent.models';
|
|||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
* Shows active and historical tasks for an agent.
|
* 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 { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { AgentTask } from '../../models/agent.models';
|
import { AgentTask } from '../../models/agent.models';
|
||||||
|
|
||||||
|
import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||||
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -229,7 +231,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -302,7 +304,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
|
|
||||||
.queue-item__progress-fill {
|
.queue-item__progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +362,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
|
|
||||||
&--running {
|
&--running {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba(59, 130, 246, 0.1);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--pending {
|
&--pending {
|
||||||
@@ -429,7 +431,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -449,7 +451,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
|
|
||||||
.task-item__progress-fill {
|
.task-item__progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
@@ -527,7 +529,7 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
|
|
||||||
.btn--text {
|
.btn--text {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -549,6 +551,8 @@ type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
|
|||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class AgentTasksTabComponent {
|
export class AgentTasksTabComponent {
|
||||||
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
|
|
||||||
/** Agent tasks */
|
/** Agent tasks */
|
||||||
readonly tasks = input<AgentTask[]>([]);
|
readonly tasks = input<AgentTask[]>([]);
|
||||||
|
|
||||||
@@ -615,7 +619,7 @@ export class AgentTasksTabComponent {
|
|||||||
|
|
||||||
formatTime(timestamp: string): string {
|
formatTime(timestamp: string): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleString('en-US', {
|
return date.toLocaleString(this.dateFmt.locale(), {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ interface ColumnConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.sorted {
|
&.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;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border: 2px solid var(--color-surface-primary);
|
border: 2px solid var(--color-surface-primary);
|
||||||
box-shadow: 0 0 0 2px var(--color-brand-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-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-time {
|
.event-time {
|
||||||
@@ -579,7 +579,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -714,7 +714,7 @@ import { buildContextReturnTo } from '../../shared/ui/context-route-state/contex
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-date {
|
.artifact-date {
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
|||||||
</div>
|
</div>
|
||||||
} @else if (backlogRows().length > 0) {
|
} @else if (backlogRows().length > 0) {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Service</th>
|
<th>Service</th>
|
||||||
@@ -480,7 +480,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
|||||||
</div>
|
</div>
|
||||||
} @else if (backlogComponents().length > 0) {
|
} @else if (backlogComponents().length > 0) {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Component</th>
|
<th>Component</th>
|
||||||
@@ -714,7 +714,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
|||||||
}
|
}
|
||||||
.metric-row__fill {
|
.metric-row__fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
}
|
}
|
||||||
.metric-row__fill--accent {
|
.metric-row__fill--accent {
|
||||||
background: var(--color-status-success);
|
background: var(--color-status-success);
|
||||||
@@ -825,7 +825,7 @@ const SEVERITY_RANK: Record<string, number> = {
|
|||||||
}
|
}
|
||||||
.trend-bar {
|
.trend-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-radius: var(--radius-sm) 4px 0 0;
|
border-radius: var(--radius-sm) 4px 0 0;
|
||||||
min-height: 6px;
|
min-height: 6px;
|
||||||
}
|
}
|
||||||
@@ -852,7 +852,6 @@ const SEVERITY_RANK: Record<string, number> = {
|
|||||||
}
|
}
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 520px;
|
min-width: 520px;
|
||||||
}
|
}
|
||||||
.data-table th,
|
.data-table th,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy }
|
|||||||
import { Router, RouterModule } from '@angular/router';
|
import { Router, RouterModule } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AocClient } from '../../core/api/aoc.client';
|
import { AocClient } from '../../core/api/aoc.client';
|
||||||
|
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||||
import {
|
import {
|
||||||
AocComplianceDashboardData,
|
AocComplianceDashboardData,
|
||||||
AocComplianceMetrics,
|
AocComplianceMetrics,
|
||||||
@@ -19,7 +20,7 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-aoc-compliance-dashboard',
|
selector: 'app-aoc-compliance-dashboard',
|
||||||
imports: [RouterModule, FormsModule],
|
imports: [RouterModule, FormsModule, LoadingStateComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="aoc-dashboard">
|
<div class="aoc-dashboard">
|
||||||
@@ -35,10 +36,7 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-overlay">
|
<app-loading-state size="lg" message="Loading compliance data..." />
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Loading compliance data...</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (error()) {
|
@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>
|
<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>
|
</header>
|
||||||
<div class="violations-table">
|
<div class="violations-table">
|
||||||
<table>
|
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
@@ -423,7 +421,7 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,23 +429,6 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
overflow-x: auto;
|
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; }
|
.timestamp { font-family: monospace; font-size: 0.8rem; }
|
||||||
|
|
||||||
@@ -557,7 +538,7 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
transition: width 0.3s ease;
|
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; }
|
.report-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); font-size: 0.9rem; margin: 0; }
|
.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; }
|
.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 { FormsModule } from '@angular/forms';
|
||||||
import { skip } from 'rxjs';
|
import { skip } from 'rxjs';
|
||||||
import { AocClient } from '../../core/api/aoc.client';
|
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 { GuardViolation, GuardViolationReason } from '../../core/api/aoc.models';
|
||||||
import { aocPath } from '../platform/ops/operations-paths';
|
import { aocPath } from '../platform/ops/operations-paths';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-guard-violations-list',
|
selector: 'app-guard-violations-list',
|
||||||
imports: [RouterModule, FormsModule],
|
imports: [RouterModule, FormsModule, LoadingStateComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="violations-page">
|
<div class="violations-page">
|
||||||
@@ -36,7 +37,7 @@ import { aocPath } from '../platform/ops/operations-paths';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading">Loading...</div>
|
<app-loading-state size="md" message="Loading violations..." />
|
||||||
}
|
}
|
||||||
|
|
||||||
<table class="violations-table">
|
<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; }
|
.violations-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0; }
|
||||||
.filters { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
.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); }
|
.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 { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
.page-header h1 { margin: 0; flex: 1; }
|
.page-header h1 { margin: 0; flex: 1; }
|
||||||
.breadcrumb { width: 100%; font-size: 0.85rem; color: var(--color-text-secondary); }
|
.breadcrumb { width: 100%; font-size: 0.85rem; color: var(--color-text-secondary); }
|
||||||
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
|
.breadcrumb a { color: var(--color-text-link); 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; }
|
.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-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 { text-align: center; }
|
||||||
.summary-item .value { display: block; font-size: 2rem; font-weight: var(--font-weight-bold); }
|
.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; }
|
.provenance-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); font-size: 0.9rem; margin: 0; }
|
.description { color: var(--color-text-secondary); font-size: 0.9rem; margin: 0; }
|
||||||
.validator-input { display: flex; gap: 0.75rem; margin-bottom: 2rem; }
|
.validator-input { display: flex; gap: 0.75rem; margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -48,24 +48,28 @@
|
|||||||
@if (state() === 'completed' && result()) {
|
@if (state() === 'completed' && result()) {
|
||||||
<div class="results-section">
|
<div class="results-section">
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="results-summary">
|
<stella-metric-grid [columns]="4">
|
||||||
<div class="stat-card" [class.success]="result()!.status === 'passed'">
|
<stella-metric-card
|
||||||
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
|
label="Documents Checked"
|
||||||
<span class="stat-label">Documents Checked</span>
|
[value]="'' + result()!.checkedCount"
|
||||||
</div>
|
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"
|
||||||
<div class="stat-card success">
|
/>
|
||||||
<span class="stat-value">{{ result()!.passedCount | number }}</span>
|
<stella-metric-card
|
||||||
<span class="stat-label">Passed</span>
|
label="Passed"
|
||||||
</div>
|
[value]="'' + result()!.passedCount"
|
||||||
<div class="stat-card" [class.error]="result()!.failedCount > 0">
|
icon="M22 11.08V12a10 10 0 1 1-5.93-9.14|||M9 11l3 3L22 4"
|
||||||
<span class="stat-value">{{ result()!.failedCount | number }}</span>
|
/>
|
||||||
<span class="stat-label">Failed</span>
|
<stella-metric-card
|
||||||
</div>
|
label="Failed"
|
||||||
<div class="stat-card">
|
[value]="'' + result()!.failedCount"
|
||||||
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
|
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"
|
||||||
<span class="stat-label">Pass Rate</span>
|
/>
|
||||||
</div>
|
<stella-metric-card
|
||||||
</div>
|
label="Pass Rate"
|
||||||
|
[value]="resultSummary()?.passRate + '%'"
|
||||||
|
icon="M12 20V10|||M18 20V4|||M6 20v-4"
|
||||||
|
/>
|
||||||
|
</stella-metric-grid>
|
||||||
|
|
||||||
<!-- Violations Preview -->
|
<!-- Violations Preview -->
|
||||||
@if (result()!.violations.length > 0) {
|
@if (result()!.violations.length > 0) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { AocClient } from '../../core/api/aoc.client';
|
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 {
|
import {
|
||||||
AocVerificationRequest,
|
AocVerificationRequest,
|
||||||
AocVerificationResult,
|
AocVerificationResult,
|
||||||
@@ -27,7 +29,7 @@ export interface CliParityGuidance {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-verify-action',
|
selector: 'app-verify-action',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, StellaMetricCardComponent, StellaMetricGridComponent],
|
||||||
templateUrl: './verify-action.component.html',
|
templateUrl: './verify-action.component.html',
|
||||||
styleUrls: ['./verify-action.component.scss'],
|
styleUrls: ['./verify-action.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
.doc-link {
|
.doc-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
.btn-link {
|
.btn-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
|||||||
.back-link {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -98,22 +98,22 @@ interface ApprovalRequest {
|
|||||||
styles: [`
|
styles: [`
|
||||||
.approvals-page { max-width: 1000px; margin: 0 auto; }
|
.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-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); }
|
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.status-chips {
|
.status-chips {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -129,7 +129,7 @@ interface ApprovalRequest {
|
|||||||
}
|
}
|
||||||
.chip:hover { background: var(--color-nav-hover); }
|
.chip:hover { background: var(--color-nav-hover); }
|
||||||
.chip--active {
|
.chip--active {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +1,541 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
||||||
import { catchError, of } from 'rxjs';
|
import { catchError, of } from 'rxjs';
|
||||||
|
|
||||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||||
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
|
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';
|
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({
|
@Component({
|
||||||
selector: 'app-approvals-inbox',
|
selector: 'app-approvals-inbox',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, FormsModule],
|
imports: [RouterModule, FilterBarComponent, StellaPageTabsComponent],
|
||||||
template: `
|
template: `
|
||||||
<section class="approvals">
|
<div class="approval-list">
|
||||||
<header>
|
<header class="list-header">
|
||||||
<h1>Release Run Approvals Queue</h1>
|
<div class="list-header__title">
|
||||||
<p>Run-centric approval queue with gate/env/hotfix/risk filtering.</p>
|
<h1>Approvals Queue</h1>
|
||||||
|
<p class="subtitle">Review and act on release promotion requests across environments</p>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs" aria-label="Approvals queue tabs">
|
<!-- Queue tabs -->
|
||||||
@for (tab of tabs; track tab.id) {
|
<stella-page-tabs
|
||||||
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
|
[tabs]="pageTabs()"
|
||||||
}
|
[activeTab]="activeTab()"
|
||||||
</nav>
|
(tabChange)="switchTab($any($event))"
|
||||||
|
ariaLabel="Approvals queue tabs"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="filters">
|
<app-filter-bar
|
||||||
<select [(ngModel)]="gateTypeFilter" (ngModelChange)="applyFilters()">
|
searchPlaceholder="Search by release name, requester, or environment"
|
||||||
<option value="all">Gate Type: All</option>
|
[filters]="filterOptions"
|
||||||
<option value="policy">Policy</option>
|
[activeFilters]="activeFilterPills()"
|
||||||
<option value="ops">Ops</option>
|
(searchChange)="onSearch($event)"
|
||||||
<option value="security">Security</option>
|
(filterChange)="onFilterChanged($event)"
|
||||||
</select>
|
(filterRemove)="onFilterRemoved($event)"
|
||||||
|
(filtersCleared)="clearAllFilters()"
|
||||||
|
></app-filter-bar>
|
||||||
|
|
||||||
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
|
@if (error()) {
|
||||||
<option value="all">Environment: All</option>
|
<div class="status-banner error">
|
||||||
<option value="dev">Dev</option>
|
<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>
|
||||||
<option value="qa">QA</option>
|
<span>{{ error() }}</span>
|
||||||
<option value="staging">Staging</option>
|
<button type="button" class="status-banner__retry" (click)="reload()">Retry</button>
|
||||||
<option value="prod">Prod</option>
|
</div>
|
||||||
</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>
|
|
||||||
}
|
}
|
||||||
</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: [`
|
styles: [`
|
||||||
.approvals {
|
/* ─── Page layout ─── */
|
||||||
|
.approval-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
max-width: 1400px;
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.approvals header h1 {
|
/* ─── Header ─── */
|
||||||
margin: 0;
|
.list-header {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs a {
|
.list-header h1 {
|
||||||
padding: 0.5rem 1rem;
|
margin: 0;
|
||||||
font-size: 0.8125rem;
|
font-size: var(--font-size-xl, 1.25rem);
|
||||||
color: var(--color-tab-inactive-text, var(--color-text-secondary));
|
font-weight: var(--font-weight-semibold);
|
||||||
text-decoration: none;
|
line-height: var(--line-height-tight, 1.25);
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
border-radius: 0;
|
|
||||||
transition: color 150ms ease, border-color 150ms ease;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs a:hover {
|
.subtitle {
|
||||||
color: var(--color-text-primary);
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm, 0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs a.active {
|
/* ─── Status / Error banner ─── */
|
||||||
color: var(--color-tab-active-text, var(--color-text-primary));
|
.status-banner {
|
||||||
border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary));
|
border: 1px solid var(--color-status-error-border);
|
||||||
font-weight: 600;
|
border-radius: var(--radius-md);
|
||||||
}
|
background: var(--color-status-error-bg);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
/* Filters */
|
font-size: var(--font-size-sm, 0.75rem);
|
||||||
.filters {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters select {
|
.status-banner.error {
|
||||||
border: 1px solid var(--color-border-primary);
|
color: var(--color-status-error-text);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters select:focus {
|
.status-banner__icon {
|
||||||
outline: none;
|
flex-shrink: 0;
|
||||||
border-color: var(--color-brand-primary);
|
|
||||||
box-shadow: 0 0 0 2px var(--color-focus-ring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banners */
|
.status-banner span {
|
||||||
.banner {
|
flex: 1;
|
||||||
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%);
|
.status-banner__retry {
|
||||||
background-size: 200% 100%;
|
flex-shrink: 0;
|
||||||
animation: shimmer 1.5s infinite;
|
padding: 0.25rem 0.6rem;
|
||||||
padding: 0.75rem 1rem;
|
border: 1px solid var(--color-status-error-border);
|
||||||
font-size: 0.8125rem;
|
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);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
.loading-state__spinner {
|
||||||
0% { background-position: 200% 0; }
|
width: 18px;
|
||||||
100% { background-position: -200% 0; }
|
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 {
|
@keyframes spin {
|
||||||
color: var(--color-status-error-text);
|
to { transform: rotate(360deg); }
|
||||||
background: var(--color-surface-primary);
|
|
||||||
animation: none;
|
|
||||||
border-color: var(--color-status-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table */
|
/* ─── Empty state ─── */
|
||||||
table {
|
.empty-state {
|
||||||
width: 100%;
|
display: flex;
|
||||||
border-collapse: collapse;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3.5rem 1.5rem 4rem;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
.empty-state__icon {
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
display: flex;
|
||||||
padding: 0.5rem 0.75rem;
|
align-items: center;
|
||||||
font-size: 0.8125rem;
|
justify-content: center;
|
||||||
text-align: left;
|
width: 72px;
|
||||||
vertical-align: top;
|
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 {
|
.empty-state__title {
|
||||||
font-size: 0.6875rem;
|
margin: 0 0 0.4rem;
|
||||||
font-weight: var(--font-weight-semibold, 600);
|
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);
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-surface-secondary);
|
||||||
position: sticky;
|
padding: 0.45rem 0.6rem;
|
||||||
top: 0;
|
white-space: nowrap;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:nth-child(even) {
|
.approval-table tbody tr {
|
||||||
background: var(--color-surface-secondary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
|
transition: background var(--motion-duration-sm, 140ms) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:hover {
|
.approval-table tbody tr:last-child {
|
||||||
background: var(--color-nav-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
td a {
|
.approval-table tbody tr:hover {
|
||||||
color: var(--color-brand-primary);
|
background: var(--color-surface-secondary);
|
||||||
text-decoration: none;
|
|
||||||
font-weight: var(--font-weight-medium, 500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
@@ -255,23 +550,31 @@ export class ApprovalsInboxComponent {
|
|||||||
readonly filtered = signal<ApprovalRequest[]>([]);
|
readonly filtered = signal<ApprovalRequest[]>([]);
|
||||||
|
|
||||||
readonly activeTab = signal<QueueTab>('pending');
|
readonly activeTab = signal<QueueTab>('pending');
|
||||||
readonly tabs: Array<{ id: QueueTab; label: string }> = [
|
|
||||||
{ id: 'pending', label: 'Pending' },
|
readonly pageTabs = computed(() =>
|
||||||
{ id: 'approved', label: 'Approved' },
|
QUEUE_TABS.map(tab => ({ ...tab, badge: this.tabCount(tab.id as QueueTab) }))
|
||||||
{ id: 'rejected', label: 'Rejected' },
|
);
|
||||||
{ id: 'expiring', label: 'Expiring' },
|
|
||||||
{ id: 'my-team', label: 'My Team' },
|
// 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';
|
gateTypeFilter = 'all';
|
||||||
envFilter = 'all';
|
envFilter = 'all';
|
||||||
hotfixFilter = 'all';
|
hotfixFilter = 'all';
|
||||||
riskFilter = 'all';
|
riskFilter = 'all';
|
||||||
|
searchTerm = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.queryParamMap.subscribe((params) => {
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
const tab = (params.get('tab') ?? 'pending') as QueueTab;
|
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);
|
this.activeTab.set(tab);
|
||||||
} else {
|
} else {
|
||||||
this.activeTab.set('pending');
|
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' {
|
deriveGateType(approval: ApprovalRequest): 'policy' | 'ops' | 'security' {
|
||||||
const releaseName = approval.releaseName.toLowerCase();
|
const releaseName = approval.releaseName.toLowerCase();
|
||||||
if (!approval.gatesPassed || releaseName.includes('policy')) {
|
if (!approval.gatesPassed || releaseName.includes('policy')) {
|
||||||
@@ -292,6 +678,26 @@ export class ApprovalsInboxComponent {
|
|||||||
return 'ops';
|
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 {
|
applyFilters(): void {
|
||||||
const tab = this.activeTab();
|
const tab = this.activeTab();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -305,6 +711,16 @@ export class ApprovalsInboxComponent {
|
|||||||
rows = rows.filter((item) => item.status === tab);
|
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') {
|
if (this.gateTypeFilter !== 'all') {
|
||||||
rows = rows.filter((item) => this.deriveGateType(item) === this.gateTypeFilter);
|
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.filtered.set(rows.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()));
|
||||||
|
this.rebuildFilterPills();
|
||||||
}
|
}
|
||||||
|
|
||||||
timeRemaining(expiresAt: string): string {
|
timeRemaining(expiresAt: string): string {
|
||||||
@@ -337,6 +754,24 @@ export class ApprovalsInboxComponent {
|
|||||||
return `${hours}h ${minutes}m`;
|
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 {
|
private load(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -359,4 +794,3 @@ export class ApprovalsInboxComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -639,18 +639,18 @@ export interface GateContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background: var(--color-brand-600);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary:hover:not(:disabled) {
|
.btn--primary:hover:not(:disabled) {
|
||||||
background: var(--color-brand-700);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--secondary {
|
.btn--secondary {
|
||||||
background: var(--color-surface-secondary);
|
background: var(--color-btn-secondary-bg);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-btn-secondary-text);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-btn-secondary-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--secondary:hover:not(:disabled) {
|
.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; }
|
.anomalies-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.description { color: var(--color-text-secondary); margin: 0; }
|
||||||
.filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
.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; }
|
.ack-info { font-size: 0.8rem; color: var(--color-text-secondary); font-style: italic; }
|
||||||
.alert-actions { display: flex; gap: 0.75rem; }
|
.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-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); }
|
.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; }
|
.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; }
|
.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 { RouterModule } from '@angular/router';
|
||||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||||
import { AuditEvent } from '../../core/api/audit-log.models';
|
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({
|
@Component({
|
||||||
selector: 'app-audit-authority',
|
selector: 'app-audit-authority',
|
||||||
imports: [RouterModule],
|
imports: [RouterModule, StellaPageTabsComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="authority-audit-page">
|
<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>
|
<p class="description">Token lifecycle, revocations, air-gap events, and incidents</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<stella-page-tabs
|
||||||
<button [class.active]="tab === 'tokens'" (click)="switchTab('tokens')">Token Lifecycle</button>
|
[tabs]="authorityTabs"
|
||||||
<button [class.active]="tab === 'airgap'" (click)="switchTab('airgap')">Air-Gap Events</button>
|
[activeTab]="tab"
|
||||||
<button [class.active]="tab === 'incidents'" (click)="switchTab('incidents')">Incidents</button>
|
(tabChange)="switchTab($any($event))"
|
||||||
</div>
|
ariaLabel="Authority audit tabs"
|
||||||
|
/>
|
||||||
|
|
||||||
<table class="events-table">
|
<table class="events-table">
|
||||||
<thead>
|
<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; }
|
.authority-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.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 { 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, .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; }
|
.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 {
|
export class AuditAuthorityComponent implements OnInit {
|
||||||
private readonly auditClient = inject(AuditLogClient);
|
private readonly auditClient = inject(AuditLogClient);
|
||||||
|
|
||||||
|
readonly authorityTabs = AUTHORITY_TABS;
|
||||||
readonly events = signal<AuditEvent[]>([]);
|
readonly events = signal<AuditEvent[]>([]);
|
||||||
readonly hasMore = signal(false);
|
readonly hasMore = signal(false);
|
||||||
private cursor: string | null = null;
|
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; }
|
.correlations-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.description { color: var(--color-text-secondary); margin: 0; }
|
||||||
.clusters-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1rem; }
|
.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; }
|
.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 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
.detail-header h2 { margin: 0; font-size: 1.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); }
|
.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, .related-events { margin-bottom: 1.5rem; }
|
||||||
.root-event h3, .related-events h3 { margin: 0 0 0.75rem; font-size: 0.95rem; }
|
.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; }
|
.event-detail-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
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-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); }
|
.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; }
|
.label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; }
|
||||||
.value { font-size: 0.9rem; }
|
.value { font-size: 0.9rem; }
|
||||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
.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, .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 h3, .tags-section h3, .details-section h3, .diff-section h3 { margin: 0 0 0.75rem; font-size: 1rem; }
|
||||||
.description-section p { margin: 0; }
|
.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; }
|
.export-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.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; }
|
.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; }
|
.integrations-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.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); }
|
.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 { RouterModule } from '@angular/router';
|
||||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||||
import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '../../core/api/audit-log.models';
|
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({
|
@Component({
|
||||||
selector: 'app-audit-log-dashboard',
|
selector: 'app-audit-log-dashboard',
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, RouterModule, StellaMetricCardComponent, StellaMetricGridComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="audit-dashboard">
|
<div class="audit-dashboard">
|
||||||
@@ -21,18 +23,20 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (stats()) {
|
@if (stats()) {
|
||||||
<section class="stats-strip">
|
<stella-metric-grid [columns]="moduleStats().length + 1">
|
||||||
<div class="stat-card">
|
<stella-metric-card
|
||||||
<span class="stat-value">{{ stats()?.totalEvents | number }}</span>
|
label="Total Events (7d)"
|
||||||
<span class="stat-label">Total Events (7d)</span>
|
[value]="(stats()?.totalEvents | number) ?? '0'"
|
||||||
</div>
|
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) {
|
@for (entry of moduleStats(); track entry.module) {
|
||||||
<div class="stat-card" [class]="entry.module">
|
<stella-metric-card
|
||||||
<span class="stat-value">{{ entry.count | number }}</span>
|
[label]="formatModule(entry.module)"
|
||||||
<span class="stat-label">{{ formatModule(entry.module) }}</span>
|
[value]="(entry.count | number) ?? '0'"
|
||||||
</div>
|
[icon]="getModuleIcon(entry.module)"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</section>
|
</stella-metric-grid>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (allCountsZero()) {
|
@if (allCountsZero()) {
|
||||||
@@ -159,20 +163,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
|||||||
transition: border-color 150ms ease;
|
transition: border-color 150ms ease;
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { border-color: var(--color-brand-primary); }
|
.btn-secondary:hover { border-color: var(--color-brand-primary); }
|
||||||
.stats-strip { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; }
|
stella-metric-grid { 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); }
|
|
||||||
.anomaly-alerts { margin-bottom: 2rem; }
|
.anomaly-alerts { margin-bottom: 2rem; }
|
||||||
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
.anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; }
|
||||||
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
.alert-list { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||||
@@ -211,13 +202,13 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '.
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
.access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; }
|
.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; }
|
.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); }
|
.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; }
|
.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 { 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; }
|
.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; }
|
.link:hover { text-decoration: underline; }
|
||||||
.events-table { width: 100%; border-collapse: collapse; }
|
.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; }
|
.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;
|
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 {
|
formatAnomalyType(type: string): string {
|
||||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
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 { margin-bottom: 1.5rem; }
|
||||||
.page-header h1 { margin: 0; font-size: 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 { 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; }
|
.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; }
|
.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; }
|
.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; }
|
.badge.severity.critical { background: var(--color-status-error-text); color: white; }
|
||||||
.actor-type { font-size: 0.7rem; color: var(--color-text-muted); }
|
.actor-type { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||||
.resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.link:hover { text-decoration: underline; }
|
||||||
.btn-xs {
|
.btn-xs {
|
||||||
padding: 0.15rem 0.4rem; font-size: 0.7rem; cursor: pointer; margin-left: 0.5rem;
|
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; }
|
.policy-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.description { color: var(--color-text-secondary); margin: 0; }
|
||||||
.event-categories { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
|
.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; }
|
.timeline-page { padding: 1.5rem; max-width: 1000px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
.breadcrumb a:hover { text-decoration: underline; }
|
||||||
h1 { margin: 0 0 0.25rem; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.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; }
|
.vex-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
|
||||||
.page-header { margin-bottom: 1.5rem; }
|
.page-header { margin-bottom: 1.5rem; }
|
||||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.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; }
|
h1 { margin: 0 0 0.25rem; }
|
||||||
.description { color: var(--color-text-secondary); margin: 0; }
|
.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); }
|
.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,
|
BinaryFingerprintExport,
|
||||||
FingerprintExportEntry,
|
FingerprintExportEntry,
|
||||||
} from '../../core/api/binary-index-ops.client';
|
} 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)
|
// Sprint: SPRINT_20260117_007_CLI_binary_analysis (BAN-004)
|
||||||
type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
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({
|
@Component({
|
||||||
selector: 'app-binary-index-ops',
|
selector: 'app-binary-index-ops',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, StellaPageTabsComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="binidx-ops">
|
<div class="binidx-ops">
|
||||||
@@ -57,54 +86,12 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="binidx-ops__tabs" role="tablist">
|
<stella-page-tabs
|
||||||
<button
|
[tabs]="BINARY_INDEX_TABS"
|
||||||
class="binidx-ops__tab"
|
[activeTab]="activeTab()"
|
||||||
[class.binidx-ops__tab--active]="activeTab() === 'health'"
|
(tabChange)="setTab($any($event))"
|
||||||
(click)="setTab('health')"
|
ariaLabel="BinaryIndex operations"
|
||||||
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>
|
|
||||||
|
|
||||||
<main class="binidx-ops__content">
|
<main class="binidx-ops__content">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">Loading BinaryIndex status...</div>
|
<div class="loading-state">Loading BinaryIndex status...</div>
|
||||||
@@ -616,6 +603,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
|
</stella-page-tabs>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
@@ -662,9 +650,9 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge--healthy { background: var(--color-status-success-text); color: var(--color-status-success-border); }
|
.status-badge--healthy { background: var(--color-status-success-text); color: #fff; }
|
||||||
.status-badge--degraded { background: var(--color-status-warning-text); color: var(--color-status-warning-border); }
|
.status-badge--degraded { background: var(--color-status-warning-text); color: #fff; }
|
||||||
.status-badge--unhealthy { background: var(--color-status-error-text); color: var(--color-status-error-border); }
|
.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-badge--unknown { background: var(--color-text-primary); color: var(--color-text-muted); }
|
||||||
|
|
||||||
.status-timestamp {
|
.status-timestamp {
|
||||||
@@ -672,33 +660,6 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
|||||||
color: var(--color-text-secondary);
|
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 {
|
.binidx-ops__content {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
@@ -1183,6 +1144,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
|
|||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class BinaryIndexOpsComponent implements OnInit, OnDestroy {
|
export class BinaryIndexOpsComponent implements OnInit, OnDestroy {
|
||||||
|
readonly BINARY_INDEX_TABS = BINARY_INDEX_TABS;
|
||||||
private readonly client = inject(BinaryIndexOpsClient);
|
private readonly client = inject(BinaryIndexOpsClient);
|
||||||
private refreshInterval: ReturnType<typeof setInterval> | null = null;
|
private refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ interface ComponentDraft {
|
|||||||
<section aria-label="Step 2: Component selector">
|
<section aria-label="Step 2: Component selector">
|
||||||
<h2 class="bundle-builder__step-title">Select Components</h2>
|
<h2 class="bundle-builder__step-title">Select Components</h2>
|
||||||
<p class="bundle-builder__hint">Add artifact versions to include in this bundle.</p>
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Component</th>
|
<th>Component</th>
|
||||||
@@ -272,7 +272,7 @@ interface ComponentDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-item--active .step-item__num {
|
.step-item--active .step-item__num {
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +329,6 @@ interface ComponentDraft {
|
|||||||
|
|
||||||
.bundle-builder__table {
|
.bundle-builder__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -378,7 +377,7 @@ interface ComponentDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ interface BundleRow {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
} @else {
|
} @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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Bundle</th>
|
<th>Bundle</th>
|
||||||
@@ -198,14 +198,13 @@ interface BundleRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip--active {
|
.filter-chip--active {
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--color-brand-primary, #4f46e5);
|
border-color: var(--color-brand-primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bundle-catalog__table {
|
.bundle-catalog__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +304,7 @@ interface BundleRow {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm, 4px);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -6,11 +6,30 @@ import {
|
|||||||
ReleaseControlBundleDetailDto,
|
ReleaseControlBundleDetailDto,
|
||||||
ReleaseControlBundleVersionSummaryDto,
|
ReleaseControlBundleVersionSummaryDto,
|
||||||
} from './bundle-organizer.api';
|
} 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({
|
@Component({
|
||||||
selector: 'app-bundle-detail',
|
selector: 'app-bundle-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink],
|
imports: [RouterLink, StellaPageTabsComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="bundle-detail">
|
<div class="bundle-detail">
|
||||||
<nav class="bundle-detail__back">
|
<nav class="bundle-detail__back">
|
||||||
@@ -52,30 +71,12 @@ import {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="bundle-detail__tabs" role="tablist">
|
<stella-page-tabs
|
||||||
<button
|
[tabs]="BUNDLE_DETAIL_TABS"
|
||||||
role="tab"
|
[activeTab]="activeTab()"
|
||||||
[class.tab--active]="activeTab() === 'versions'"
|
(tabChange)="activeTab.set($any($event))"
|
||||||
(click)="setTab('versions')"
|
ariaLabel="Bundle details"
|
||||||
>
|
>
|
||||||
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>
|
|
||||||
|
|
||||||
@if (activeTab() === 'versions') {
|
@if (activeTab() === 'versions') {
|
||||||
<section class="bundle-detail__section" aria-label="Bundle versions">
|
<section class="bundle-detail__section" aria-label="Bundle versions">
|
||||||
<h2>Version timeline</h2>
|
<h2>Version timeline</h2>
|
||||||
@@ -87,7 +88,7 @@ import {
|
|||||||
Create first bundle version
|
Create first bundle version
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="bundle-detail__table">
|
<table class="stella-table stella-table--striped stella-table--hoverable bundle-detail__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Version</th>
|
<th>Version</th>
|
||||||
@@ -147,6 +148,7 @@ import {
|
|||||||
<p class="bundle-detail__hint">Per-repository changelog exports are attached to evidence packs.</p>
|
<p class="bundle-detail__hint">Per-repository changelog exports are attached to evidence packs.</p>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</stella-page-tabs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -197,30 +199,6 @@ import {
|
|||||||
color: var(--color-text-secondary, #666);
|
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 {
|
.bundle-detail__section {
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
@@ -286,7 +264,6 @@ import {
|
|||||||
|
|
||||||
.bundle-detail__table {
|
.bundle-detail__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +295,7 @@ import {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm, 4px);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -338,6 +315,7 @@ import {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class BundleDetailComponent implements OnInit {
|
export class BundleDetailComponent implements OnInit {
|
||||||
|
readonly BUNDLE_DETAIL_TABS = BUNDLE_DETAIL_TABS;
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly bundleApi = inject(BundleOrganizerApi);
|
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,30 @@ import {
|
|||||||
BundleOrganizerApi,
|
BundleOrganizerApi,
|
||||||
ReleaseControlBundleVersionDetailDto,
|
ReleaseControlBundleVersionDetailDto,
|
||||||
} from './bundle-organizer.api';
|
} 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({
|
@Component({
|
||||||
selector: 'app-bundle-version-detail',
|
selector: 'app-bundle-version-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink],
|
imports: [RouterLink, StellaPageTabsComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="bvd">
|
<div class="bvd">
|
||||||
@@ -68,19 +87,19 @@ import {
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="bvd__tabs" role="tablist">
|
<stella-page-tabs
|
||||||
<button role="tab" [class.tab--active]="activeTab() === 'components'" (click)="setTab('components')">Components</button>
|
[tabs]="BUNDLE_VERSION_TABS"
|
||||||
<button role="tab" [class.tab--active]="activeTab() === 'validation'" (click)="setTab('validation')">Validation</button>
|
[activeTab]="activeTab()"
|
||||||
<button role="tab" [class.tab--active]="activeTab() === 'releases'" (click)="setTab('releases')">Promotions</button>
|
(tabChange)="activeTab.set($any($event))"
|
||||||
</div>
|
ariaLabel="Bundle version details"
|
||||||
|
>
|
||||||
@if (activeTab() === 'components') {
|
@if (activeTab() === 'components') {
|
||||||
<section aria-label="Manifest components">
|
<section aria-label="Manifest components">
|
||||||
<h2>Manifest components (digest-first)</h2>
|
<h2>Manifest components (digest-first)</h2>
|
||||||
@if (versionDetailModel.components.length === 0) {
|
@if (versionDetailModel.components.length === 0) {
|
||||||
<p class="bvd__empty">No components listed for this version.</p>
|
<p class="bvd__empty">No components listed for this version.</p>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="bvd__table">
|
<table class="stella-table stella-table--striped stella-table--hoverable bvd__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Component</th>
|
<th>Component</th>
|
||||||
@@ -143,6 +162,7 @@ import {
|
|||||||
<a routerLink="/releases/approvals" class="bvd__link">Create promotion from this version</a>
|
<a routerLink="/releases/approvals" class="bvd__link">Create promotion from this version</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
</stella-page-tabs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -271,29 +291,6 @@ import {
|
|||||||
border-color: var(--color-brand-primary, #6366f1);
|
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 {
|
section h2 {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -318,7 +315,6 @@ import {
|
|||||||
|
|
||||||
.bvd__table {
|
.bvd__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +356,7 @@ import {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--color-brand-primary, #4f46e5);
|
background: var(--color-btn-primary-bg);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.4rem 0.7rem;
|
padding: 0.4rem 0.7rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -423,6 +419,7 @@ import {
|
|||||||
`],
|
`],
|
||||||
})
|
})
|
||||||
export class BundleVersionDetailComponent implements OnInit {
|
export class BundleVersionDetailComponent implements OnInit {
|
||||||
|
readonly BUNDLE_VERSION_TABS = BUNDLE_VERSION_TABS;
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly bundleApi = inject(BundleOrganizerApi);
|
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ import { readReleaseInvestigationQueryState } from '../release-investigation/rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
.offset {
|
.offset {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.size {
|
.size {
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
&.change-patched {
|
&.change-patched {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.change-rebuilt {
|
&.change-rebuilt {
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
|
|
||||||
&.change-patched {
|
&.change-patched {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.change-rebuilt {
|
&.change-rebuilt {
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
|
|
||||||
&.symbol-patched {
|
&.symbol-patched {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.symbol-unchanged {
|
&.symbol-unchanged {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
.icon {
|
.icon {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
.method-chip {
|
.method-chip {
|
||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
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 { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.models';
|
||||||
|
|
||||||
|
import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'stella-summary-header',
|
selector: 'stella-summary-header',
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
@@ -16,6 +18,8 @@ import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.model
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class SummaryHeaderComponent {
|
export class SummaryHeaderComponent {
|
||||||
|
private readonly dateFmt = inject(DateFormatService);
|
||||||
|
|
||||||
@Input({ required: true }) trace!: ChangeTrace;
|
@Input({ required: true }) trace!: ChangeTrace;
|
||||||
|
|
||||||
get verdictClass(): string {
|
get verdictClass(): string {
|
||||||
@@ -64,7 +68,7 @@ export class SummaryHeaderComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
formatNumber(value: number): string {
|
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 {
|
formatDate(isoDate: string): string {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
|
|
||||||
mat-icon {
|
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-upgrade mat-icon { color: var(--color-status-info); }
|
||||||
.action-patch mat-icon { color: var(--color-brand-secondary); }
|
.action-patch mat-icon { color: var(--color-text-link); }
|
||||||
.action-vex mat-icon { color: var(--color-brand-primary); }
|
.action-vex mat-icon { color: var(--color-text-link); }
|
||||||
.action-config mat-icon { color: var(--color-status-warning); }
|
.action-config mat-icon { color: var(--color-status-warning); }
|
||||||
.action-investigate mat-icon { color: var(--color-status-error); }
|
.action-investigate mat-icon { color: var(--color-status-error); }
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ interface CategoryInfo {
|
|||||||
}
|
}
|
||||||
.categories-pane__clear {
|
.categories-pane__clear {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TrustIndicatorsComponent } from './trust-indicators.component';
|
|||||||
import { DeltaSummaryStripComponent } from './delta-summary-strip.component';
|
import { DeltaSummaryStripComponent } from './delta-summary-strip.component';
|
||||||
import { ThreePaneLayoutComponent } from './three-pane-layout.component';
|
import { ThreePaneLayoutComponent } from './three-pane-layout.component';
|
||||||
import { ExportActionsComponent } from './export-actions.component';
|
import { ExportActionsComponent } from './export-actions.component';
|
||||||
|
import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component';
|
||||||
|
|
||||||
export type UserRole = 'developer' | 'security' | 'audit';
|
export type UserRole = 'developer' | 'security' | 'audit';
|
||||||
|
|
||||||
@@ -28,8 +29,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
|
|||||||
TrustIndicatorsComponent,
|
TrustIndicatorsComponent,
|
||||||
DeltaSummaryStripComponent,
|
DeltaSummaryStripComponent,
|
||||||
ThreePaneLayoutComponent,
|
ThreePaneLayoutComponent,
|
||||||
ExportActionsComponent,
|
ExportActionsComponent, LoadingStateComponent],
|
||||||
],
|
|
||||||
template: `
|
template: `
|
||||||
<div class="compare-view" [class.compare-view--loading]="loading()">
|
<div class="compare-view" [class.compare-view--loading]="loading()">
|
||||||
<header class="compare-view__header">
|
<header class="compare-view__header">
|
||||||
@@ -62,8 +62,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
|
|||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="compare-view__loading">
|
<div class="compare-view__loading">
|
||||||
<div class="spinner"></div>
|
<app-loading-state size="md" message="Loading comparison..." />
|
||||||
<span>Loading comparison...</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +160,7 @@ export type UserRole = 'developer' | 'security' | 'audit';
|
|||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
gap: 1rem; padding: 3rem; color: var(--color-text-muted);
|
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__empty a:hover { text-decoration: underline; }
|
||||||
.compare-view__explain-toggle {
|
.compare-view__explain-toggle {
|
||||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conflict {
|
.conflict {
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
.source-status {
|
.source-status {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-priority {
|
.source-priority {
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
|
|
||||||
.winner-badge {
|
.winner-badge {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
border-left: 3px solid var(--color-brand-primary);
|
border-left: 3px solid var(--color-brand-primary);
|
||||||
|
|
||||||
.node-icon mat-icon {
|
.node-icon mat-icon {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
|
|
||||||
mat-chip {
|
mat-chip {
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,12 +364,12 @@ import {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import {
|
|||||||
VAULT_PROVIDER_DEFINITIONS,
|
VAULT_PROVIDER_DEFINITIONS,
|
||||||
SETTINGS_STORE_PROVIDER_DEFINITIONS,
|
SETTINGS_STORE_PROVIDER_DEFINITIONS,
|
||||||
} from '../models/configuration-pane.models';
|
} 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({
|
@Component({
|
||||||
selector: 'app-integration-detail',
|
selector: 'app-integration-detail',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, StellaPageTabsComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="detail-container">
|
<div class="detail-container">
|
||||||
@@ -52,23 +58,12 @@ import {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<stella-page-tabs
|
||||||
<button
|
[tabs]="configDetailTabs"
|
||||||
class="tab"
|
[activeTab]="activeTab"
|
||||||
[class.active]="activeTab === 'config'"
|
(tabChange)="activeTab = $any($event)"
|
||||||
(click)="activeTab = 'config'">
|
ariaLabel="Integration detail tabs"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
@if (activeTab === 'config') {
|
@if (activeTab === 'config') {
|
||||||
@@ -603,7 +598,7 @@ import {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +618,7 @@ import {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
@@ -659,6 +654,7 @@ export class IntegrationDetailComponent {
|
|||||||
readonly runChecks = output<void>();
|
readonly runChecks = output<void>();
|
||||||
readonly removeIntegration = output<void>();
|
readonly removeIntegration = output<void>();
|
||||||
|
|
||||||
|
readonly configDetailTabs = CONFIG_DETAIL_TABS;
|
||||||
activeTab: 'config' | 'health' = 'config';
|
activeTab: 'config' | 'health' = 'config';
|
||||||
|
|
||||||
getStatusLabel(status: string): string {
|
getStatusLabel(status: string): string {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
|
|||||||
|
|
||||||
.btn-add {
|
.btn-add {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -174,7 +174,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-add:hover {
|
.btn-add:hover {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.integrations-list {
|
.integrations-list {
|
||||||
@@ -284,7 +284,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '.
|
|||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
text-transform: uppercase;
|
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) {
|
} @else if (clients.length === 0 && !isCreating) {
|
||||||
<div class="empty-state">No OAuth2 clients configured</div>
|
<div class="empty-state">No OAuth2 clients configured</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="admin-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Client ID</th>
|
<th>Client ID</th>
|
||||||
@@ -293,7 +293,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--theme-bg-secondary);
|
background: var(--theme-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -333,7 +332,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@@ -378,12 +377,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
|
|||||||
@@ -1,15 +1,73 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
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.
|
* Layout wrapper for Console Admin child routes.
|
||||||
* Provides the <router-outlet> needed for nested route rendering.
|
* Provides canonical stella-page-tabs navigation and <router-outlet> for nested route rendering.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-console-admin-layout',
|
selector: 'app-console-admin-layout',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, StellaPageTabsComponent],
|
||||||
template: `<router-outlet />`,
|
template: `
|
||||||
|
<stella-page-tabs
|
||||||
|
[tabs]="pageTabs"
|
||||||
|
[activeTab]="activeTab()"
|
||||||
|
ariaLabel="Console admin tabs"
|
||||||
|
(tabChange)="onTabChange($event)"
|
||||||
|
>
|
||||||
|
<router-outlet />
|
||||||
|
</stella-page-tabs>
|
||||||
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
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) {
|
} @else if (customRoles.length === 0 && !isCreating) {
|
||||||
<div class="empty-state">No custom roles defined</div>
|
<div class="empty-state">No custom roles defined</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="admin-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Role Name</th>
|
<th>Role Name</th>
|
||||||
@@ -436,7 +436,6 @@ interface RoleBundle {
|
|||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--theme-bg-secondary);
|
background: var(--theme-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -471,7 +470,7 @@ interface RoleBundle {
|
|||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@@ -494,12 +493,12 @@ interface RoleBundle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
} @else if (users.length === 0 && !isCreating) {
|
} @else if (users.length === 0 && !isCreating) {
|
||||||
<div class="empty-state">No users found</div>
|
<div class="empty-state">No users found</div>
|
||||||
} @else {
|
} @else {
|
||||||
<table class="admin-table">
|
<table class="stella-table stella-table--striped stella-table--hoverable admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
@@ -210,7 +210,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--theme-bg-secondary);
|
background: var(--theme-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -249,7 +248,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
@@ -290,12 +289,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--theme-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-btn-primary-text);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--theme-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
|
|||||||
@@ -23,9 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="console-profile__loading">
|
<app-loading-state size="md" message="Loading profile context..." />
|
||||||
Loading profile context...
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!loading()) {
|
@if (!loading()) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-brand-light);
|
background: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
|
|
||||||
.tenant-chip {
|
.tenant-chip {
|
||||||
background-color: var(--color-brand-light);
|
background-color: var(--color-brand-light);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tenant-count {
|
.tenant-count {
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||||
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
import { ConsoleSessionService } from '../../core/console/console-session.service';
|
||||||
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
import { ConsoleSessionStore } from '../../core/console/console-session.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-console-profile',
|
selector: 'app-console-profile',
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, LoadingStateComponent],
|
||||||
templateUrl: './console-profile.component.html',
|
templateUrl: './console-profile.component.html',
|
||||||
styleUrls: ['./console-profile.component.scss'],
|
styleUrls: ['./console-profile.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -863,12 +863,12 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary {
|
.btn--primary {
|
||||||
background: var(--so-brand);
|
background: var(--color-btn-primary-bg);
|
||||||
color: var(--color-surface-inverse);
|
color: var(--color-btn-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn--primary:hover {
|
.btn--primary:hover {
|
||||||
background: var(--so-brand-hover);
|
background: var(--color-btn-primary-bg-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
.cvss-tabs button.active {
|
.cvss-tabs button.active {
|
||||||
border-bottom: 2px solid var(--color-brand-primary);
|
border-bottom: 2px solid var(--color-brand-primary);
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cvss-panel {
|
.cvss-panel {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -256,7 +256,7 @@ export interface DashboardAiData {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-btn-primary-bg);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
@@ -293,7 +293,7 @@ export interface DashboardAiData {
|
|||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -16,10 +16,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-state">
|
<app-loading-state size="lg" [message]="'ui.sources_dashboard.loading_aoc' | translate" />
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>{{ 'ui.sources_dashboard.loading_aoc' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
|
|||||||
@@ -296,7 +296,7 @@
|
|||||||
|
|
||||||
summary {
|
summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import {
|
|||||||
AocViolationSummary,
|
AocViolationSummary,
|
||||||
AocVerificationResult,
|
AocVerificationResult,
|
||||||
} from '../../core/api/aoc.models';
|
} from '../../core/api/aoc.models';
|
||||||
|
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sources-dashboard',
|
selector: 'app-sources-dashboard',
|
||||||
imports: [CommonModule, TranslatePipe],
|
imports: [CommonModule, TranslatePipe, LoadingStateComponent],
|
||||||
templateUrl: './sources-dashboard.component.html',
|
templateUrl: './sources-dashboard.component.html',
|
||||||
styleUrls: ['./sources-dashboard.component.scss'],
|
styleUrls: ['./sources-dashboard.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
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:hover:not(:disabled) { background: var(--color-surface-tertiary); }
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
.btn-icon { padding: 0.5rem; }
|
.btn-icon { padding: 0.5rem; }
|
||||||
.spinning { animation: spin 1s linear infinite; }
|
.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:hover:not(:disabled) { background: var(--color-surface-tertiary); }
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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-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-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
.btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; }
|
.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:hover:not(:disabled) { background: var(--color-surface-tertiary); }
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||||
.btn-icon { padding: 0.5rem; }
|
.btn-icon { padding: 0.5rem; }
|
||||||
.spinning { animation: spin 1s linear infinite; }
|
.spinning { animation: spin 1s linear infinite; }
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ import {
|
|||||||
|
|
||||||
.vuln-link {
|
.vuln-link {
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-text-link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user