release control ui improvements
This commit is contained in:
@@ -112,7 +112,11 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'mission-control/board',
|
||||
title: 'Dashboard',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Dashboard' },
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard-v3/dashboard-v3.component').then((m) => m.DashboardV3Component),
|
||||
},
|
||||
...LEGACY_REDIRECT_ROUTES,
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ export type ReleaseEventType =
|
||||
| 'deployed'
|
||||
| 'failed'
|
||||
| 'rolled_back';
|
||||
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate';
|
||||
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate' | 'ab-release';
|
||||
|
||||
export interface ManagedRelease {
|
||||
id: string;
|
||||
@@ -226,6 +226,7 @@ export function getStrategyLabel(strategy: DeploymentStrategy): string {
|
||||
blue_green: 'Blue/Green',
|
||||
canary: 'Canary',
|
||||
recreate: 'Recreate',
|
||||
'ab-release': 'A/B Release',
|
||||
};
|
||||
return labels[strategy] || strategy;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
|
||||
export type ContentWidthMode = 'centered' | 'full';
|
||||
|
||||
const STORAGE_KEY = 'stellaops.content-width';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ContentWidthService {
|
||||
private readonly _mode = signal<ContentWidthMode>(this.loadFromStorage());
|
||||
|
||||
readonly mode = this._mode.asReadonly();
|
||||
readonly isCentered = computed(() => this._mode() === 'centered');
|
||||
readonly isFull = computed(() => this._mode() === 'full');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.saveToStorage(this._mode());
|
||||
});
|
||||
}
|
||||
|
||||
setMode(mode: ContentWidthMode): void {
|
||||
this._mode.set(mode);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
this._mode.update(m => m === 'centered' ? 'full' : 'centered');
|
||||
}
|
||||
|
||||
private loadFromStorage(): ContentWidthMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'centered' || stored === 'full') {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Graceful fallback for private browsing or quota errors
|
||||
}
|
||||
return 'centered';
|
||||
}
|
||||
|
||||
private saveToStorage(mode: ContentWidthMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export interface PageAction {
|
||||
label: string;
|
||||
icon?: string;
|
||||
route?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for pages to register their primary action button in the topbar.
|
||||
* Pages set the action on init and clear it on destroy.
|
||||
*
|
||||
* Usage in a page component:
|
||||
* readonly pageAction = inject(PageActionService);
|
||||
*
|
||||
* ngOnInit() {
|
||||
* this.pageAction.set({ label: 'Refresh', action: () => this.refresh() });
|
||||
* }
|
||||
*
|
||||
* ngOnDestroy() {
|
||||
* this.pageAction.clear();
|
||||
* }
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PageActionService {
|
||||
private readonly _action = signal<PageAction | null>(null);
|
||||
readonly action = this._action.asReadonly();
|
||||
|
||||
set(action: PageAction): void {
|
||||
this._action.set(action);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._action.set(null);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
@@ -19,6 +20,7 @@ import { filter } from 'rxjs';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
import {
|
||||
NotifierChannel,
|
||||
NotifierRule,
|
||||
@@ -516,11 +518,12 @@ interface ConfigSubTab {
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotificationDashboardComponent implements OnInit {
|
||||
export class NotificationDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject<NotifierApi>(NOTIFIER_API);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
readonly pageTabs: readonly StellaPageTab[] = NOTIFICATION_TABS;
|
||||
|
||||
@@ -551,6 +554,7 @@ export class NotificationDashboardComponent implements OnInit {
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.pageAction.set({ label: 'Refresh', action: () => this.refreshStats() });
|
||||
this.setActiveTabFromUrl(this.router.url);
|
||||
|
||||
this.router.events
|
||||
@@ -565,6 +569,10 @@ export class NotificationDashboardComponent implements OnInit {
|
||||
await this.loadInitialData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
async loadInitialData(): Promise<void> {
|
||||
this.loadingStats.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, Role } from '../services/console-admin-api.service';
|
||||
@@ -6,6 +6,7 @@ import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
|
||||
interface RoleBundle {
|
||||
module: string;
|
||||
@@ -22,12 +23,6 @@ interface RoleBundle {
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>Roles & Scopes</h1>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="showCreateForm()"
|
||||
[disabled]="!canWrite || isCreating">
|
||||
Create Custom Role
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -556,10 +551,11 @@ interface RoleBundle {
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RolesListComponent implements OnInit {
|
||||
export class RolesListComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
activeTab: 'catalog' | 'custom' = 'catalog';
|
||||
catalogFilter = '';
|
||||
@@ -659,11 +655,16 @@ export class RolesListComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Add Role', action: () => this.showCreateForm() });
|
||||
if (this.activeTab === 'custom') {
|
||||
this.loadCustomRoles();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
getBundlesForModule(module: string): RoleBundle[] {
|
||||
return this.roleBundles.filter(b => b.module === module);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service';
|
||||
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
|
||||
/**
|
||||
* Tenants List Component
|
||||
@@ -16,9 +17,6 @@ import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>Tenants</h1>
|
||||
<button class="btn-primary" (click)="createTenant()" [disabled]="!canWrite">
|
||||
Create Tenant
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-content">
|
||||
@@ -139,9 +137,10 @@ import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TenantsListComponent implements OnInit {
|
||||
export class TenantsListComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
tenants: Tenant[] = [];
|
||||
loading = true;
|
||||
@@ -149,9 +148,14 @@ export class TenantsListComponent implements OnInit {
|
||||
canWrite = false; // TODO: Check authority:tenants.write scope
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Add Tenant', action: () => this.createTenant() });
|
||||
this.loadTenants();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
private loadTenants(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ConsoleAdminApiService, User } from '../services/console-admin-api.service';
|
||||
@@ -6,6 +6,7 @@ import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
|
||||
import { StellaOpsScopes } from '../../../core/auth/scopes';
|
||||
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
|
||||
import { PageActionService } from '../../../core/services/page-action.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-users-list',
|
||||
@@ -14,12 +15,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>Users</h1>
|
||||
<button
|
||||
class="btn-primary"
|
||||
(click)="showCreateForm()"
|
||||
[disabled]="!canWrite || isCreating">
|
||||
Create User
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
@@ -360,10 +355,11 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class UsersListComponent implements OnInit {
|
||||
export class UsersListComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(ConsoleAdminApiService);
|
||||
private readonly freshAuth = inject(FreshAuthService);
|
||||
private readonly auth = inject(AUTH_SERVICE);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
users: User[] = [];
|
||||
isLoading = false;
|
||||
@@ -385,9 +381,14 @@ export class UsersListComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Add User', action: () => this.showCreateForm() });
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
loadUsers(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
signal,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
AUTH_SERVICE,
|
||||
type AuthService,
|
||||
} from '../../core/auth/auth.service';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
|
||||
interface EnvironmentCard {
|
||||
id: string;
|
||||
@@ -90,13 +92,6 @@ interface PendingAction {
|
||||
<h1 class="board-title">Mission Board</h1>
|
||||
<p class="board-subtitle">{{ tenantLabel() }}</p>
|
||||
</div>
|
||||
<button class="refresh-btn" (click)="refresh()" [disabled]="refreshing()" type="button">
|
||||
@if (refreshing()) {
|
||||
Refreshing...
|
||||
} @else {
|
||||
Refresh
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (!contextReady()) {
|
||||
@@ -1358,12 +1353,13 @@ interface PendingAction {
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class DashboardV3Component implements OnInit, AfterViewInit {
|
||||
export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||
private readonly sourceApi = inject(SourceManagementApi);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
// -- Scroll refs and signals ------------------------------------------------
|
||||
@ViewChild('pipelineScroll') pipelineScrollRef?: ElementRef<HTMLDivElement>;
|
||||
@@ -1434,10 +1430,15 @@ export class DashboardV3Component implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Refresh', action: () => this.refresh() });
|
||||
this.loadVulnerabilityStats();
|
||||
this.loadFeedStatus();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Check scroll arrows at multiple intervals to catch async data rendering
|
||||
const checkScroll = () => {
|
||||
|
||||
@@ -19,13 +19,6 @@
|
||||
<span class="btn-icon"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
||||
Quick
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
(click)="runNormalCheck()"
|
||||
[disabled]="store.isRunning()">
|
||||
<span class="btn-icon"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
(click)="runFullCheck()"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { Component, OnInit, inject, signal, computed, effect } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject, signal, computed, effect } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SummaryStripComponent } from './components/summary-strip/summary-strip.
|
||||
import { CheckResultComponent } from './components/check-result/check-result.component';
|
||||
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
|
||||
import { AppConfigService } from '../../core/config/app-config.service';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||
|
||||
const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
|
||||
@@ -33,7 +34,8 @@ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
|
||||
templateUrl: './doctor-dashboard.component.html',
|
||||
styleUrl: './doctor-dashboard.component.scss'
|
||||
})
|
||||
export class DoctorDashboardComponent implements OnInit {
|
||||
export class DoctorDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
readonly store = inject(DoctorStore);
|
||||
private readonly configService = inject(AppConfigService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
@@ -113,6 +115,8 @@ export class DoctorDashboardComponent implements OnInit {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Run Check', action: () => this.runNormalCheck() });
|
||||
|
||||
// Load metadata on init
|
||||
this.store.fetchPlugins();
|
||||
this.store.fetchChecks();
|
||||
@@ -125,6 +129,10 @@ export class DoctorDashboardComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
onFixInSetup(url: string): void {
|
||||
this.router.navigateByUrl(url);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
AirGapBundle,
|
||||
} from '../../core/api/feed-mirror.models';
|
||||
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { MirrorListComponent } from './mirror-list.component';
|
||||
import { OfflineSyncStatusComponent } from './offline-sync-status.component';
|
||||
import { FeedVersionLockComponent } from './feed-version-lock.component';
|
||||
@@ -594,10 +596,11 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FeedMirrorDashboardComponent implements OnInit {
|
||||
export class FeedMirrorDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
readonly FEED_MIRROR_TABS = FEED_MIRROR_TABS;
|
||||
|
||||
@@ -651,10 +654,15 @@ export class FeedMirrorDashboardComponent implements OnInit {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Refresh', action: () => this.refreshMirrors() });
|
||||
this.loadData();
|
||||
this.loadBundles();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, OnDestroy, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationType } from './integration.models';
|
||||
|
||||
@@ -94,7 +95,6 @@ interface IntegrationHubStats {
|
||||
</nav>
|
||||
|
||||
<section class="actions">
|
||||
<button type="button" (click)="addIntegration()">+ Add Integration</button>
|
||||
<a routerLink="activity">View Activity</a>
|
||||
</section>
|
||||
|
||||
@@ -288,10 +288,11 @@ interface IntegrationHubStats {
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class IntegrationHubComponent {
|
||||
export class IntegrationHubComponent implements OnDestroy {
|
||||
private readonly integrationService = inject(IntegrationService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
readonly stats = signal<IntegrationHubStats>({
|
||||
registries: 0,
|
||||
@@ -309,6 +310,11 @@ export class IntegrationHubComponent {
|
||||
|
||||
constructor() {
|
||||
this.loadStats();
|
||||
this.pageAction.set({ label: 'Add Integration', action: () => this.addIntegration() });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
private loadStats(): void {
|
||||
|
||||
@@ -10,15 +10,6 @@
|
||||
<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="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"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Setup
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="np__btn np__btn--secondary"
|
||||
(click)="refreshAll()"
|
||||
[disabled]="channelLoading() || ruleLoading() || deliveriesLoading()"
|
||||
>
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import {
|
||||
NOTIFY_API,
|
||||
NotifyApi,
|
||||
@@ -70,7 +72,8 @@ type DeliveryFilter =
|
||||
styleUrls: ['./notify-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class NotifyPanelComponent implements OnInit {
|
||||
export class NotifyPanelComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly api = inject<NotifyApi>(NOTIFY_API);
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
|
||||
@@ -166,9 +169,14 @@ export class NotifyPanelComponent implements OnInit {
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.pageAction.set({ label: 'Refresh', action: () => void this.refreshAll() });
|
||||
await this.refreshAll();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
async refreshAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.loadChannels(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import {
|
||||
type OverviewCardGroup,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Filter bar adoption: aligned with release-list (versions) page patterns
|
||||
import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core';
|
||||
import { RouterLink, Router } from '@angular/router';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
|
||||
@@ -264,7 +264,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
styles: [`
|
||||
.approval-queue-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003)
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component';
|
||||
|
||||
import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
import { PageActionService } from '../../../../core/services/page-action.service';
|
||||
@Component({
|
||||
selector: 'app-release-list',
|
||||
imports: [FormsModule, RouterModule, FilterBarComponent],
|
||||
@@ -27,16 +28,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
<h1>Release Versions</h1>
|
||||
<p class="subtitle">Digest-first release version catalog across standard and hotfix lanes</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn-secondary" (click)="createStandard()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Version
|
||||
</button>
|
||||
<button type="button" class="btn-primary" (click)="createHotfix()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
Hotfix Run
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<app-filter-bar
|
||||
@@ -262,7 +253,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
.release-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -819,8 +809,9 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ReleaseListComponent implements OnInit {
|
||||
export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
private readonly dateFmt = inject(DateFormatService);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
readonly store = inject(ReleaseManagementStore);
|
||||
readonly context = inject(PlatformContextStore);
|
||||
@@ -882,6 +873,7 @@ export class ReleaseListComponent implements OnInit {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
|
||||
this.context.initialize();
|
||||
this.route.queryParamMap.subscribe((params) => {
|
||||
this.applyingFromQuery = true;
|
||||
@@ -899,6 +891,10 @@ export class ReleaseListComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
onReleaseSearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
this.applyFilters(false);
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* Tab 2 "Approvals": embeds the existing ApprovalQueueComponent.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { UpperCasePipe, SlicePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||
import { TableColumn } from '../../shared/components/data-table/data-table.component';
|
||||
|
||||
// ── Data model ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,9 +123,8 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
SlicePipe,
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
StellaMetricCardComponent,
|
||||
StellaMetricGridComponent,
|
||||
FilterBarComponent,
|
||||
PaginationComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@@ -136,60 +136,21 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Metric cards -->
|
||||
<stella-metric-grid [columns]="4">
|
||||
<stella-metric-card
|
||||
label="Total Releases"
|
||||
[value]="'' + totalReleases()"
|
||||
subtitle="All versions in pipeline"
|
||||
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"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Active Deployments"
|
||||
[value]="'' + activeDeployments()"
|
||||
subtitle="Currently deploying"
|
||||
icon="M12 19V5|||M5 12l7-7 7 7"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Gates Blocked"
|
||||
[value]="'' + gatesBlocked()"
|
||||
subtitle="Require attention"
|
||||
icon="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"
|
||||
/>
|
||||
<stella-metric-card
|
||||
label="Pending Approvals"
|
||||
[value]="'' + pendingApprovals()"
|
||||
subtitle="Awaiting review"
|
||||
icon="M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"
|
||||
route="/releases/approvals"
|
||||
/>
|
||||
</stella-metric-grid>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<div class="rup__toolbar">
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search releases..."
|
||||
[filters]="pipelineFilterOptions"
|
||||
[activeFilters]="pipelineActiveFilters()"
|
||||
(searchChange)="searchQuery.set($event)"
|
||||
(searchChange)="searchQuery.set($event); currentPage.set(1)"
|
||||
(filterChange)="onPipelineFilterAdded($event)"
|
||||
(filterRemove)="onPipelineFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllPipelineFilters()"
|
||||
/>
|
||||
<div class="rup__toolbar-actions">
|
||||
<a routerLink="/releases/versions/new" class="btn btn--primary btn--sm">
|
||||
<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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Release
|
||||
</a>
|
||||
<a routerLink="/releases/versions/new" [queryParams]="{ type: 'hotfix', hotfixLane: 'true' }" class="btn btn--warning btn--sm">
|
||||
<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="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
Hotfix
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Releases table -->
|
||||
@if (filteredReleases().length === 0) {
|
||||
@if (sortedReleases().length === 0) {
|
||||
<div class="rup__empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="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"/>
|
||||
@@ -201,20 +162,45 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rup__table-wrap">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Release</th>
|
||||
<th>Stage</th>
|
||||
<th>Gates</th>
|
||||
<th>Risk</th>
|
||||
<th>Evidence</th>
|
||||
<th>Status</th>
|
||||
<th>Decisions</th>
|
||||
@for (col of columns; track col.key) {
|
||||
<th
|
||||
[class.rup__th--sortable]="col.sortable"
|
||||
[class.rup__th--sorted]="sortState()?.column === col.key"
|
||||
[attr.aria-sort]="getSortAria(col.key)"
|
||||
(click)="col.sortable ? toggleSort(col.key) : null"
|
||||
>
|
||||
<div class="rup__th-content">
|
||||
<span>{{ col.label }}</span>
|
||||
@if (col.sortable) {
|
||||
<span class="rup__sort-icon" [class.rup__sort-icon--active]="sortState()?.column === col.key">
|
||||
@if (sortState()?.column === col.key) {
|
||||
@if (sortState()?.direction === 'asc') {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="18 15 12 9 6 15" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="rup__sort-icon--inactive" aria-hidden="true">
|
||||
<polyline points="8 10 12 6 16 10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<polyline points="8 14 12 18 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredReleases(); track r.id) {
|
||||
@for (r of pagedReleases(); track r.id) {
|
||||
<tr>
|
||||
<!-- Release -->
|
||||
<td>
|
||||
@@ -314,15 +300,25 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (right-aligned) -->
|
||||
<div class="rup__pager">
|
||||
<app-pagination
|
||||
[total]="sortedReleases().length"
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageSizes]="[5, 10, 25, 50]"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.rup { padding: 1.5rem; max-width: 1440px; }
|
||||
.rup { padding: 1.5rem; }
|
||||
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
|
||||
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
:host ::ng-deep stella-metric-grid { margin-bottom: 1.25rem; }
|
||||
.rup__toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
:host ::ng-deep app-filter-bar { flex: 1 1 0; min-width: 0; }
|
||||
.rup__toolbar-actions { display: flex; gap: 0.375rem; margin-left: auto; padding-top: 0.5rem; }
|
||||
@@ -414,6 +410,18 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
.decision-capsule__progress-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-brand-primary, #4F46E5); transition: width 300ms ease; }
|
||||
.decision-capsule__progress-text { font-size: 0.625rem; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Sortable column headers */
|
||||
.rup__th--sortable { cursor: pointer; user-select: none; transition: color 150ms ease; }
|
||||
.rup__th--sortable:hover { color: var(--color-text-primary); }
|
||||
.rup__th--sorted { color: var(--color-text-link); }
|
||||
.rup__th-content { display: flex; align-items: center; gap: 0.375rem; }
|
||||
.rup__sort-icon { display: flex; align-items: center; opacity: 0.5; transition: opacity 150ms ease; }
|
||||
.rup__sort-icon--active { opacity: 1; color: var(--color-text-link); }
|
||||
.rup__sort-icon--inactive { opacity: 0.3; }
|
||||
|
||||
/* Right-aligned pagination */
|
||||
.rup__pager { display: flex; justify-content: flex-end; padding-top: 0.75rem; }
|
||||
|
||||
.rup__empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem 1rem; color: var(--color-text-muted); text-align: center; }
|
||||
.rup__empty svg { margin-bottom: 1rem; opacity: 0.4; }
|
||||
.rup__empty-title { font-size: 1rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-secondary); margin: 0 0 0.25rem; }
|
||||
@@ -426,7 +434,17 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ReleasesUnifiedPageComponent {
|
||||
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'New Release', route: '/releases/versions/new' });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
// ── Filter-bar configuration ──────────────────────────────────────────
|
||||
|
||||
readonly pipelineFilterOptions: FilterOption[] = [
|
||||
@@ -449,6 +467,18 @@ export class ReleasesUnifiedPageComponent {
|
||||
]},
|
||||
];
|
||||
|
||||
// ── Columns definition ───────────────────────────────────────────────
|
||||
|
||||
readonly columns: TableColumn<PipelineRelease>[] = [
|
||||
{ key: 'name', label: 'Release', sortable: true },
|
||||
{ key: 'environment', label: 'Stage', sortable: true },
|
||||
{ key: 'gateStatus', label: 'Gates', sortable: true },
|
||||
{ key: 'riskTier', label: 'Risk', sortable: true },
|
||||
{ key: 'evidencePosture', label: 'Evidence', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: true },
|
||||
{ key: 'decisions', label: 'Decisions', sortable: false },
|
||||
];
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
|
||||
@@ -456,6 +486,7 @@ export class ReleasesUnifiedPageComponent {
|
||||
readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all');
|
||||
readonly statusFilter = signal<string>('all');
|
||||
readonly gateFilter = signal<string>('all');
|
||||
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
readonly pipelineActiveFilters = computed<ActiveFilter[]>(() => {
|
||||
const filters: ActiveFilter[] = [];
|
||||
@@ -478,21 +509,10 @@ export class ReleasesUnifiedPageComponent {
|
||||
return filters;
|
||||
});
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────
|
||||
// ── Pagination ────────────────────────────────────────────────────────
|
||||
|
||||
readonly totalReleases = computed(() => this.releases().length);
|
||||
|
||||
readonly activeDeployments = computed(
|
||||
() => this.releases().filter((r) => r.status === 'deploying').length,
|
||||
);
|
||||
|
||||
readonly gatesBlocked = computed(
|
||||
() => this.releases().filter((r) => r.gateStatus === 'block').length,
|
||||
);
|
||||
|
||||
readonly pendingApprovals = computed(() =>
|
||||
this.releases().reduce((sum, r) => sum + r.gatePendingApprovals, 0),
|
||||
);
|
||||
readonly currentPage = signal(1);
|
||||
readonly pageSize = signal(10);
|
||||
|
||||
readonly filteredReleases = computed(() => {
|
||||
let list = this.releases();
|
||||
@@ -521,9 +541,49 @@ export class ReleasesUnifiedPageComponent {
|
||||
return list;
|
||||
});
|
||||
|
||||
readonly sortedReleases = computed(() => {
|
||||
const list = [...this.filteredReleases()];
|
||||
const sort = this.sortState();
|
||||
if (!sort) return list;
|
||||
|
||||
const { column, direction } = sort;
|
||||
const dir = direction === 'asc' ? 1 : -1;
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const aVal = (a as unknown as Record<string, unknown>)[column];
|
||||
const bVal = (b as unknown as Record<string, unknown>)[column];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return aVal.localeCompare(bVal) * dir;
|
||||
}
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return (aVal - bVal) * dir;
|
||||
}
|
||||
return String(aVal).localeCompare(String(bVal)) * dir;
|
||||
});
|
||||
});
|
||||
|
||||
readonly pagedReleases = computed(() => {
|
||||
const all = this.sortedReleases();
|
||||
const page = this.currentPage();
|
||||
const size = this.pageSize();
|
||||
const start = (page - 1) * size;
|
||||
return all.slice(start, start + size);
|
||||
});
|
||||
|
||||
onPageChange(event: PageChangeEvent): void {
|
||||
this.currentPage.set(event.page);
|
||||
this.pageSize.set(event.pageSize);
|
||||
}
|
||||
|
||||
// ── Filter-bar handlers ────────────────────────────────────────────────
|
||||
|
||||
onPipelineFilterAdded(f: ActiveFilter): void {
|
||||
this.currentPage.set(1);
|
||||
switch (f.key) {
|
||||
case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break;
|
||||
case 'status': this.statusFilter.set(f.value); break;
|
||||
@@ -544,6 +604,29 @@ export class ReleasesUnifiedPageComponent {
|
||||
this.statusFilter.set('all');
|
||||
this.gateFilter.set('all');
|
||||
this.searchQuery.set('');
|
||||
this.currentPage.set(1);
|
||||
}
|
||||
|
||||
// ── Sort handlers ────────────────────────────────────────────────────
|
||||
|
||||
toggleSort(columnKey: string): void {
|
||||
const current = this.sortState();
|
||||
if (current?.column === columnKey) {
|
||||
if (current.direction === 'asc') {
|
||||
this.sortState.set({ column: columnKey, direction: 'desc' });
|
||||
} else {
|
||||
// Third click clears sort
|
||||
this.sortState.set(null);
|
||||
}
|
||||
} else {
|
||||
this.sortState.set({ column: columnKey, direction: 'asc' });
|
||||
}
|
||||
}
|
||||
|
||||
getSortAria(columnKey: string): string | null {
|
||||
const sort = this.sortState();
|
||||
if (sort?.column !== columnKey) return null;
|
||||
return sort.direction === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { catchError, map, of, take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
@@ -274,15 +275,24 @@ const REPORT_TABS: readonly StellaPageTab[] = [
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SecurityReportsPageComponent {
|
||||
export class SecurityReportsPageComponent implements OnInit, OnDestroy {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
readonly tabs = REPORT_TABS;
|
||||
readonly activeTab = signal<ReportTab>('risk');
|
||||
readonly riskExporting = signal(false);
|
||||
readonly vexExporting = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Export Report', action: () => this.exportRiskCsv() });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
exportRiskCsv(): void {
|
||||
this.riskExporting.set(true);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SidebarPreferenceService } from '../../../layout/app-sidebar/sidebar-pr
|
||||
import { AiPreferencesComponent, type AiPreferences } from '../ai-preferences.component';
|
||||
import { PlainLanguageToggleComponent } from '../../advisory-ai/plain-language-toggle.component';
|
||||
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||
import { ContentWidthService } from '../../../core/services/content-width.service';
|
||||
|
||||
type PrefsTab = 'profile' | 'appearance' | 'language' | 'layout' | 'ai';
|
||||
|
||||
@@ -215,6 +216,29 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-row__info">
|
||||
<span class="toggle-row__label">Content width</span>
|
||||
<span class="toggle-row__hint">Choose between centered content or full-width layout</span>
|
||||
</div>
|
||||
<div class="width-mode-selector" role="radiogroup" aria-label="Content width mode">
|
||||
<button type="button" role="radio"
|
||||
class="width-mode-btn"
|
||||
[class.width-mode-btn--active]="contentWidthService.isCentered()"
|
||||
[attr.aria-checked]="contentWidthService.isCentered()"
|
||||
(click)="contentWidthService.setMode('centered')">
|
||||
Centered
|
||||
</button>
|
||||
<button type="button" role="radio"
|
||||
class="width-mode-btn"
|
||||
[class.width-mode-btn--active]="contentWidthService.isFull()"
|
||||
[attr.aria-checked]="contentWidthService.isFull()"
|
||||
(click)="contentWidthService.setMode('full')">
|
||||
Full Width
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -736,6 +760,42 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
|
||||
background: var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Content width selector */
|
||||
/* ------------------------------------------------------------------ */
|
||||
.width-mode-selector {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.width-mode-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.width-mode-btn:first-child {
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.width-mode-btn--active {
|
||||
background: var(--color-btn-primary-bg);
|
||||
color: var(--color-btn-primary-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.width-mode-btn:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Responsive */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -777,6 +837,7 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
|
||||
export class UserPreferencesPageComponent implements OnInit {
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
protected readonly sidebarPrefs = inject(SidebarPreferenceService);
|
||||
protected readonly contentWidthService = inject(ContentWidthService);
|
||||
|
||||
readonly prefsTabs = PREFS_TABS;
|
||||
readonly activeTab = signal<PrefsTab>('profile');
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* @description Main Trust Administration component with tabs for Keys, Issuers, Certificates, and Audit
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, DestroyRef } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { filter } from 'rxjs';
|
||||
|
||||
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { TrustAdministrationOverview } from '../../core/api/trust.models';
|
||||
import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive';
|
||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||
@@ -259,11 +260,12 @@ const TRUST_PAGE_TABS: readonly StellaPageTab[] = [
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TrustAdminComponent implements OnInit {
|
||||
export class TrustAdminComponent implements OnInit, OnDestroy {
|
||||
private readonly trustApi = inject(TRUST_API);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
// State
|
||||
readonly loading = signal(true);
|
||||
@@ -295,6 +297,7 @@ export class TrustAdminComponent implements OnInit {
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Refresh', action: () => this.refreshDashboard() });
|
||||
this.loadDashboard();
|
||||
this.setActiveTabFromUrl(this.router.url);
|
||||
|
||||
@@ -308,6 +311,10 @@ export class TrustAdminComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
private loadDashboard(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppSidebarComponent } from '../app-sidebar';
|
||||
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
|
||||
import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service';
|
||||
import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
import { SearchAssistantHostComponent } from '../search-assistant-host/search-assistant-host.component';
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,10 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
|
||||
<div class="shell__content">
|
||||
<app-breadcrumb class="shell__breadcrumb"></app-breadcrumb>
|
||||
|
||||
<main id="main-content" class="shell__outlet" tabindex="-1">
|
||||
<main id="main-content" class="shell__outlet"
|
||||
[class.shell__outlet--centered]="contentWidth.isCentered()"
|
||||
[attr.data-width]="contentWidth.mode()"
|
||||
tabindex="-1">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
@@ -154,9 +158,20 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
|
||||
flex: 1;
|
||||
padding: var(--space-6, 1.5rem);
|
||||
outline: none;
|
||||
background: var(--color-surface-secondary);
|
||||
width: 100%;
|
||||
max-width: 5000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box;
|
||||
transition: max-width 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Centered mode: constrain the outlet width */
|
||||
.shell__outlet--centered {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
|
||||
.shell__overlay {
|
||||
display: none;
|
||||
}
|
||||
@@ -217,6 +232,7 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
|
||||
})
|
||||
export class AppShellComponent {
|
||||
readonly sidebarPrefs = inject(SidebarPreferenceService);
|
||||
readonly contentWidth = inject(ContentWidthService);
|
||||
|
||||
/** Whether mobile menu is open */
|
||||
readonly mobileMenuOpen = signal(false);
|
||||
|
||||
@@ -678,19 +678,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
*/
|
||||
readonly navSections: NavSection[] = [
|
||||
// ── Group 1: Release Control ─────────────────────────────────────
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
icon: 'dashboard',
|
||||
route: '/mission-control/board',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.UI_READ,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'releases',
|
||||
label: 'Releases',
|
||||
@@ -1132,7 +1119,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
groupRoute(group: NavSectionGroup): string {
|
||||
return group.sections[0]?.route ?? '/mission-control/board';
|
||||
return group.sections[0]?.route ?? '/';
|
||||
}
|
||||
|
||||
private withDynamicChildState(item: NavItem): NavItem {
|
||||
|
||||
@@ -301,6 +301,13 @@ export interface NavItem {
|
||||
<path d="M20.39 18.06A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('layers') {
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="2 17 12 22 22 17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="2 12 12 17 22 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
}
|
||||
@case ('help-circle') {
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
|
||||
@@ -29,6 +29,8 @@ import { FeedSnapshotChipComponent } from '../context-chips/feed-snapshot-chip.c
|
||||
import { PolicyBaselineChipComponent } from '../context-chips/policy-baseline-chip.component';
|
||||
import { EvidenceModeChipComponent } from '../context-chips/evidence-mode-chip.component';
|
||||
import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream-chip.component';
|
||||
import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
|
||||
/**
|
||||
* AppTopbarComponent - Top bar with global search, context chips, tenant, and user menu.
|
||||
@@ -76,10 +78,14 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
|
||||
<app-global-search></app-global-search>
|
||||
</div>
|
||||
|
||||
<!-- Right section: Create + User -->
|
||||
<!-- Right section: Page action + User -->
|
||||
<div class="topbar__right">
|
||||
@if (primaryAction(); as action) {
|
||||
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a>
|
||||
@if (pageActionService.action(); as pa) {
|
||||
@if (pa.route) {
|
||||
<a class="topbar__primary-action" [routerLink]="pa.route">{{ pa.label }}</a>
|
||||
} @else if (pa.action) {
|
||||
<button type="button" class="topbar__primary-action" (click)="pa.action!()">{{ pa.label }}</button>
|
||||
}
|
||||
}
|
||||
|
||||
<button
|
||||
@@ -181,6 +187,27 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
|
||||
|
||||
<app-context-chips></app-context-chips>
|
||||
|
||||
<!-- Content width toggle (right after stage filter) -->
|
||||
<button
|
||||
type="button"
|
||||
class="topbar__width-toggle"
|
||||
[attr.aria-label]="contentWidth.isCentered() ? 'Switch to full width' : 'Switch to centered'"
|
||||
[attr.title]="contentWidth.isCentered() ? 'Full width' : 'Centered'"
|
||||
(click)="contentWidth.toggle()"
|
||||
>
|
||||
@if (contentWidth.isCentered()) {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>
|
||||
<line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (isAuthenticated() && showViewModeSwitcher()) {
|
||||
<stella-view-mode-switcher></stella-view-mode-switcher>
|
||||
}
|
||||
@@ -301,16 +328,17 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-btn-primary-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.35rem 0.58rem;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-btn-primary-bg);
|
||||
color: var(--color-btn-primary-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.69rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.topbar__primary-action:hover {
|
||||
@@ -540,10 +568,40 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Content width toggle ---- */
|
||||
.topbar__width-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.topbar__width-toggle:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.topbar__width-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring, rgba(245, 166, 35, 0.4));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.topbar__status-chips {
|
||||
display: none;
|
||||
}
|
||||
.topbar__width-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -554,6 +612,8 @@ export class AppTopbarComponent {
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly i18n = inject(I18nService);
|
||||
private readonly localePreference = inject(UserLocalePreferenceService);
|
||||
protected readonly contentWidth = inject(ContentWidthService);
|
||||
readonly pageActionService = inject(PageActionService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
@@ -575,7 +635,7 @@ export class AppTopbarComponent {
|
||||
readonly tenantSwitchInFlight = signal(false);
|
||||
readonly tenantBootstrapAttempted = signal(false);
|
||||
readonly currentPath = signal(this.router.url);
|
||||
readonly primaryAction = computed(() => this.resolvePrimaryAction(this.currentPath()));
|
||||
// Primary action is now entirely managed by PageActionService — no fallback logic.
|
||||
|
||||
/**
|
||||
* Routes where the Operator/Auditor view-mode switcher is relevant.
|
||||
@@ -807,39 +867,6 @@ export class AppTopbarComponent {
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
private resolvePrimaryAction(path: string): { label: string; route: string } | null {
|
||||
const normalizedPath = path.split('?')[0].toLowerCase();
|
||||
|
||||
if (normalizedPath.startsWith('/releases/hotfixes')) {
|
||||
return { label: 'Create Hotfix', route: '/releases/hotfixes/new' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/releases')) {
|
||||
return { label: 'Create Release', route: '/releases/versions/new' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/security')) {
|
||||
return { label: 'Export Report', route: '/security/reports' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/evidence')) {
|
||||
return { label: 'Verify', route: '/evidence/verify-replay' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/ops')) {
|
||||
return { label: 'Add Integration', route: '/ops/integrations/onboarding' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/setup')) {
|
||||
return { label: 'Add Target', route: '/setup/topology/targets' };
|
||||
}
|
||||
|
||||
if (normalizedPath === '/' || normalizedPath.startsWith('/mission-control')) {
|
||||
return { label: 'Create Release', route: '/releases/versions/new' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async syncLocaleFromPreference(): Promise<void> {
|
||||
const preferredLocale = await this.localePreference.getLocaleAsync();
|
||||
|
||||
@@ -172,3 +172,22 @@
|
||||
.mat-mdc-tab.mdc-tab--active {
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Content Width Toggle
|
||||
// Global (unencapsulated) so it overrides all component-scoped max-widths.
|
||||
// =============================================================================
|
||||
|
||||
// When full-width mode is active, remove max-width from ALL page containers.
|
||||
// Uses attribute selector on the shell outlet set by ContentWidthService.
|
||||
#main-content[data-width="full"] {
|
||||
// Direct child (the Angular component host element)
|
||||
> * {
|
||||
max-width: none !important;
|
||||
|
||||
// Any nested element that looks like a page container
|
||||
> *:not(router-outlet) {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user