diff --git a/src/Web/StellaOps.Web/src/app/features/operations/operations.routes.ts b/src/Web/StellaOps.Web/src/app/features/operations/operations.routes.ts index 02f0e0dc0..192e03ee2 100644 --- a/src/Web/StellaOps.Web/src/app/features/operations/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/operations/operations.routes.ts @@ -1,6 +1,6 @@ import { Routes } from '@angular/router'; -import { requireOrchOperatorGuard, requireOrchViewerGuard } from '../../core/auth'; +import { requireOrchQuotaGuard, requireOrchViewerGuard } from '../../core/auth'; export const OPERATIONS_ROUTES: Routes = [ { @@ -37,7 +37,7 @@ export const OPERATIONS_ROUTES: Routes = [ }, { path: 'jobengine/quotas', - canMatch: [requireOrchOperatorGuard], + canMatch: [requireOrchQuotaGuard], loadComponent: () => import('../jobengine/jobengine-quotas.component').then( (m) => m.JobEngineQuotasComponent diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-dashboard.component.ts index 8a9168ca8..6199ba24d 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-dashboard.component.ts @@ -1,19 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, computed, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { finalize } from 'rxjs/operators'; - -import { - POLICY_SIMULATION_API, - PolicySimulationApi, -} from '../../core/api/policy-simulation.client'; -import { - ShadowModeConfig, - ShadowModeResults, - ShadowFindingComparison, -} from '../../core/api/policy-simulation.models'; import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component'; +import { ShadowModeStateService } from './shadow-mode-state.service'; /** * Shadow mode results dashboard with divergence highlighting. @@ -544,12 +534,12 @@ import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component' ] }) export class ShadowModeDashboardComponent implements OnInit { - private readonly api = inject(POLICY_SIMULATION_API); private readonly fb = inject(FormBuilder); + private readonly shadowModeState = inject(ShadowModeStateService); - readonly loading = signal(false); - readonly config = signal(undefined); - readonly results = signal(undefined); + readonly loading = this.shadowModeState.loading; + readonly config = this.shadowModeState.config; + readonly results = this.shadowModeState.results; // Template-safe computed signals for summary properties readonly summaryTotalEvaluations = computed(() => this.results()?.summary?.totalEvaluations ?? 0); @@ -601,71 +591,27 @@ export class ShadowModeDashboardComponent implements OnInit { } loadConfig(): void { - this.loading.set(true); - this.api.getShadowModeConfig().pipe( - finalize(() => this.loading.set(false)) - ).subscribe({ - next: (config) => { - this.config.set(config); - if (config.enabled) { - this.loadResults(); - } - }, - error: () => { - this.config.set(undefined); - }, + this.shadowModeState.loadConfig({ + loadResultsQuery: this.buildResultsQuery(), }); } loadResults(): void { - this.loading.set(true); - const timeRange = this.filterForm.value.timeRange ?? '24h'; - const fromTime = this.calculateFromTime(timeRange); - - this.api.getShadowModeResults({ - tenantId: 'default', - fromTime, - toTime: new Date().toISOString(), - divergedOnly: this.filterForm.value.showOnly === 'diverged', - }).pipe( - finalize(() => this.loading.set(false)) - ).subscribe({ - next: (results) => { - this.results.set(results); - this.config.set(results.config); - }, - error: () => { - this.results.set(undefined); - }, - }); + this.shadowModeState.loadResults(this.buildResultsQuery()); } onEnableShadowMode(): void { - this.loading.set(true); - this.api.enableShadowMode({ + this.shadowModeState.enable({ shadowPackId: 'policy-pack-shadow-001', shadowVersion: 1, trafficPercentage: 25, - }).pipe( - finalize(() => this.loading.set(false)) - ).subscribe({ - next: (config) => { - this.config.set(config); - this.loadResults(); - }, + }, { + loadResultsQuery: this.buildResultsQuery(), }); } onDisableShadowMode(): void { - this.loading.set(true); - this.api.disableShadowMode().pipe( - finalize(() => this.loading.set(false)) - ).subscribe({ - next: () => { - this.config.set({ ...this.config()!, enabled: false, status: 'disabled' }); - this.results.set(undefined); - }, - }); + this.shadowModeState.disable(); } formatDivergenceReason(reason: string): string { @@ -684,5 +630,15 @@ export class ShadowModeDashboardComponent implements OnInit { }; return new Date(now - (durations[range] ?? 86400000)).toISOString(); } + + private buildResultsQuery() { + const timeRange = this.filterForm.value.timeRange ?? '24h'; + return { + tenantId: 'default', + fromTime: this.calculateFromTime(timeRange), + toTime: new Date().toISOString(), + divergedOnly: this.filterForm.value.showOnly === 'diverged', + }; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-state.service.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-state.service.ts new file mode 100644 index 000000000..b07919d8e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/shadow-mode-state.service.ts @@ -0,0 +1,86 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { finalize } from 'rxjs/operators'; + +import { POLICY_SIMULATION_API } from '../../core/api/policy-simulation.client'; +import { ShadowModeConfig, ShadowModeQueryOptions, ShadowModeResults } from '../../core/api/policy-simulation.models'; + +@Injectable({ providedIn: 'root' }) +export class ShadowModeStateService { + private readonly api = inject(POLICY_SIMULATION_API); + + readonly loading = signal(false); + readonly config = signal(undefined); + readonly results = signal(undefined); + + loadConfig(options: { fallback?: ShadowModeConfig; loadResultsQuery?: ShadowModeQueryOptions } = {}): void { + this.loading.set(true); + this.api.getShadowModeConfig().pipe( + finalize(() => this.loading.set(false)) + ).subscribe({ + next: (config) => { + this.config.set(config); + if (config.enabled && options.loadResultsQuery) { + this.loadResults(options.loadResultsQuery); + } else if (!config.enabled) { + this.results.set(undefined); + } + }, + error: () => { + this.config.set(options.fallback); + if (!options.fallback) { + this.results.set(undefined); + } + }, + }); + } + + loadResults(query: ShadowModeQueryOptions): void { + this.loading.set(true); + this.api.getShadowModeResults(query).pipe( + finalize(() => this.loading.set(false)) + ).subscribe({ + next: (results) => { + this.results.set(results); + this.config.set(results.config); + }, + error: () => { + this.results.set(undefined); + }, + }); + } + + enable(config: Partial, options: { loadResultsQuery?: ShadowModeQueryOptions } = {}): void { + this.loading.set(true); + this.api.enableShadowMode(config).pipe( + finalize(() => this.loading.set(false)) + ).subscribe({ + next: (nextConfig) => { + this.config.set(nextConfig); + if (options.loadResultsQuery) { + this.loadResults(options.loadResultsQuery); + } + }, + }); + } + + disable(): void { + this.loading.set(true); + this.api.disableShadowMode().pipe( + finalize(() => this.loading.set(false)) + ).subscribe({ + next: () => { + const current = this.config(); + this.config.set(current ? { ...current, enabled: false, status: 'disabled' } : { + enabled: false, + status: 'disabled', + shadowPackId: '', + shadowVersion: 0, + activePackId: '', + activeVersion: 0, + trafficPercentage: 0, + }); + this.results.set(undefined); + }, + }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts index 6c7cc7c56..415cde443 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts @@ -1,13 +1,9 @@  import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core'; import { RouterModule, Router } from '@angular/router'; -import { finalize } from 'rxjs/operators'; import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component'; -import { - POLICY_SIMULATION_API, -} from '../../core/api/policy-simulation.client'; -import { ShadowModeConfig } from '../../core/api/policy-simulation.models'; +import { ShadowModeStateService } from './shadow-mode-state.service'; /** * Main Policy Simulation Studio dashboard component with tabbed navigation. @@ -420,12 +416,12 @@ import { ShadowModeConfig } from '../../core/api/policy-simulation.models'; `] }) export class SimulationDashboardComponent implements OnInit { - private readonly api = inject(POLICY_SIMULATION_API); private readonly router = inject(Router); + private readonly shadowModeState = inject(ShadowModeStateService); protected readonly activeTab = signal('shadow'); - protected readonly shadowConfig = signal(undefined); - protected readonly shadowLoading = signal(false); + protected readonly shadowConfig = this.shadowModeState.config; + protected readonly shadowLoading = this.shadowModeState.loading; // Promotion status (mocked - would be computed from actual API data) protected readonly promotionStatus = signal({ @@ -553,32 +549,22 @@ export class SimulationDashboardComponent implements OnInit { } protected loadShadowStatus(): void { - this.shadowLoading.set(true); - this.api.getShadowModeConfig().pipe( - finalize(() => this.shadowLoading.set(false)) - ).subscribe({ - next: (config) => { - this.shadowConfig.set(config); - }, - error: () => { - // Mock fallback for development - this.shadowConfig.set({ - enabled: true, - status: 'enabled', - shadowPackId: 'policy-pack-shadow-001', - shadowVersion: 2, - activePackId: 'policy-pack-001', - activeVersion: 1, - trafficPercentage: 10, - enabledAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), - }); + this.shadowModeState.loadConfig({ + fallback: { + enabled: true, + status: 'enabled', + shadowPackId: 'policy-pack-shadow-001', + shadowVersion: 2, + activePackId: 'policy-pack-001', + activeVersion: 1, + trafficPercentage: 10, + enabledAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), }, }); } protected enableShadowMode(): void { - this.shadowLoading.set(true); - this.api.enableShadowMode({ + this.shadowModeState.enable({ enabled: true, status: 'enabled', shadowPackId: 'policy-pack-shadow-001', @@ -586,32 +572,11 @@ export class SimulationDashboardComponent implements OnInit { activePackId: 'policy-pack-001', activeVersion: 1, trafficPercentage: 10, - }).pipe( - finalize(() => this.shadowLoading.set(false)) - ).subscribe({ - next: (config) => { - this.shadowConfig.set(config); - }, }); } protected disableShadowMode(): void { - this.shadowLoading.set(true); - this.api.disableShadowMode().pipe( - finalize(() => this.shadowLoading.set(false)) - ).subscribe({ - next: () => { - this.shadowConfig.set({ - enabled: false, - status: 'disabled', - shadowPackId: '', - shadowVersion: 0, - activePackId: '', - activeVersion: 0, - trafficPercentage: 0, - }); - }, - }); + this.shadowModeState.disable(); } protected navigateToHistory(): void { diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 3d5b9d2d8..b8b602595 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -1,5 +1,5 @@ import { Routes } from '@angular/router'; -import { requireOrchOperatorGuard, requireOrchViewerGuard } from '../core/auth'; +import { requireOrchQuotaGuard, requireOrchViewerGuard } from '../core/auth'; export const OPERATIONS_ROUTES: Routes = [ { @@ -111,7 +111,7 @@ export const OPERATIONS_ROUTES: Routes = [ path: 'jobengine/quotas', title: 'JobEngine Quotas', data: { breadcrumb: 'JobEngine Quotas' }, - canMatch: [requireOrchOperatorGuard], + canMatch: [requireOrchQuotaGuard], loadComponent: () => import('../features/jobengine/jobengine-quotas.component').then( (m) => m.JobEngineQuotasComponent,