diff --git a/docs/implplan/SPRINT_20260320_001_FE_releases_table_and_wizard.md b/docs/implplan/SPRINT_20260320_001_FE_releases_table_and_wizard.md new file mode 100644 index 000000000..421129fcb --- /dev/null +++ b/docs/implplan/SPRINT_20260320_001_FE_releases_table_and_wizard.md @@ -0,0 +1,114 @@ +# Sprint 20260320-001 — Releases Table + Create Release Wizard Enhancement + +## Topic & Scope +- Migrate releases pipeline table to use `app-data-table` with column sorting + right-aligned `app-pagination`. +- Enhance the Create Release wizard with target selection, strategy detail configuration, and progressive delivery support. +- Working directory: `src/Web/StellaOps.Web/src/app/features/` +- Expected evidence: build passes, pages render correctly, wizard steps complete. + +## Dependencies & Concurrency +- No upstream sprint dependencies. +- Task 1 (table) and Task 2 (wizard) are independent and can be done in parallel. + +## Documentation Prerequisites +- `docs/modules/release-orchestrator/deployment/strategies.md` — strategy configs +- `docs/modules/release-orchestrator/progressive-delivery/ab-releases.md` — A/B models +- `docs/modules/release-orchestrator/progressive-delivery/canary.md` — canary controller +- `docs/modules/release-orchestrator/deployment/overview.md` — execution flow + +## Delivery Tracker + +### TASK-001 - Releases pipeline table: use app-data-table + sorting + right-aligned pager +Status: DONE +Dependency: none +Owners: FE + +Task description: +- Replace the raw `` in `releases-unified-page.component.ts` with ``. +- Define columns with `sortable: true` for: Release, Stage, Gates, Risk, Evidence, Status. +- Add sort state signal + computed sorted+filtered+paged data. +- Move `` to right-aligned position below the table. +- Remove manual `/` markup — use data-table's column definitions and cell templates. + +Completion criteria: +- [ ] Table renders using `app-data-table` +- [ ] Clicking column headers sorts ascending/descending +- [ ] Pagination below table, right-aligned +- [ ] Decision capsules still render in the last column +- [ ] Build passes + +### TASK-002 - Create Release wizard: target selection step +Status: DONE +Dependency: none +Owners: FE + +Task description: +- Add a new step between current Step 1 (Identity) and Step 2 (Components) for target selection. +- New step allows selecting: regions, environments, specific targets/hosts. +- Use existing `PlatformContextStore` for available regions/environments. +- Support multi-select with chips for selected items. +- Wire selected targets into the release draft payload. + +Completion criteria: +- [ ] Wizard shows 5 steps (Identity → Targets → Components → Config → Review) +- [ ] Target selection step shows available regions/environments from context +- [ ] Selected targets appear as chips +- [ ] Selected targets included in review step summary +- [ ] Build passes + +### TASK-003 - Create Release wizard: strategy detail configuration +Status: DONE +Dependency: none +Owners: FE + +Task description: +- Enhance Step 3 (Config) to show strategy-specific configuration when a strategy is selected. +- **Rolling**: batch size (count or %), batch delay, stabilization time, max failed batches. +- **Canary**: canary stages (% + duration), health thresholds (success rate, error rate, latency), manual vs auto progression. +- **Blue-Green**: switchover mode (instant vs gradual with stages), warmup period, blue keepalive duration. +- **All-at-Once**: max concurrency, failure behavior (rollback/continue/pause). +- Use a collapsible "Advanced" section that expands when the user selects a strategy. +- Pre-fill with sensible defaults from docs. + +Completion criteria: +- [ ] Selecting a strategy shows its specific config fields +- [ ] All 4 strategies have their documented fields +- [ ] Defaults match documentation +- [ ] Config values included in review step +- [ ] Build passes + +### TASK-004 - Create Release wizard: A/B and progressive delivery support +Status: DONE +Dependency: TASK-003 +Owners: FE + +Task description: +- Add "A/B Release" as a 5th deployment strategy option. +- When selected, show sub-type selector: Target-Group A/B vs Router-Based A/B. +- **Target-Group A/B**: stage editor (canary → expand → shift → complete) with % and duration. +- **Router-Based A/B**: routing strategy (weight/header/cookie/tenant), traffic % stages, health thresholds. +- Add rollback configuration: auto-rollback thresholds, manual override option. +- Reference: `docs/modules/release-orchestrator/progressive-delivery/ab-releases.md` + +Completion criteria: +- [ ] A/B option available in strategy dropdown +- [ ] Sub-type selection works +- [ ] Stage editor allows adding/removing stages +- [ ] Config included in review step +- [ ] Build passes + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-20 | Sprint created; tasks 1-3 starting immediately. | Planning | +| 2026-03-20 | All 4 tasks DONE. Table sorting + pager, wizard targets step, strategy config, A/B release. Deployed. | FE | + +## Decisions & Risks +- TASK-004 depends on TASK-003 (strategy config infrastructure must exist first). +- Custom script hooks deferred to a follow-up sprint (requires backend ScriptRunner integration). +- A/B release UI is complex — may need iteration after initial implementation. + +## Next Checkpoints +- TASK-001: table + sorting + pager — should be quick (1 agent) +- TASK-002 + TASK-003: wizard enhancements — can run in parallel +- TASK-004: A/B support — after TASK-003 completes diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 18fa268cc..05382d6f2 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -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, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts index e600818e1..9990d118d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.models.ts @@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/core/services/content-width.service.ts b/src/Web/StellaOps.Web/src/app/core/services/content-width.service.ts new file mode 100644 index 000000000..0624b844e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/services/content-width.service.ts @@ -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(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 + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/services/page-action.service.ts b/src/Web/StellaOps.Web/src/app/core/services/page-action.service.ts new file mode 100644 index 000000000..409e5760e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/services/page-action.service.ts @@ -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(null); + readonly action = this._action.asReadonly(); + + set(action: PageAction): void { + this._action.set(action); + } + + clear(): void { + this._action.set(null); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts index f601f3d96..5f6465537 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/components/notification-dashboard.component.ts @@ -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(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 { + 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 { this.loadingStats.set(true); this.error.set(null); diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts index a0e664a3f..e95248cbb 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts @@ -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 {

Roles & Scopes

-
@@ -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); } diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/tenants/tenants-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/tenants/tenants-list.component.ts index 556de442a..6415b28c7 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/tenants/tenants-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/tenants/tenants-list.component.ts @@ -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';

Tenants

-
@@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts index 680f5ed6c..90e02e9ee 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts @@ -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.

Users

-
@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; diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index 9f1383dc2..34f94618e 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -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 {

Mission Board

{{ tenantLabel() }}

- @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(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; @@ -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 = () => { diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html index 387966bc4..0870c2b75 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html @@ -19,13 +19,6 @@ Quick - View Activity @@ -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({ 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 { diff --git a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html index 70b647429..16aeebfe9 100644 --- a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html +++ b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.html @@ -10,15 +10,6 @@ Setup -
diff --git a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.ts index 1845d38e2..ca8ee19ef 100644 --- a/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/notify/notify-panel.component.ts @@ -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(NOTIFY_API); private readonly formBuilder = inject(NonNullableFormBuilder); @@ -166,9 +169,14 @@ export class NotifyPanelComponent implements OnInit { }); async ngOnInit(): Promise { + this.pageAction.set({ label: 'Refresh', action: () => void this.refreshAll() }); await this.refreshAll(); } + ngOnDestroy(): void { + this.pageAction.clear(); + } + async refreshAll(): Promise { await Promise.all([ this.loadChannels(), diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts index c913f470d..b8d18a71a 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts index 5a1410126..ee86ebc5e 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts @@ -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'; diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts index 2752175b1..953c4508d 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts @@ -264,7 +264,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service'; styles: [` .approval-queue-container { padding: 24px; - max-width: 1400px; margin: 0 auto; } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts index 97b84066b..89a334c71 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; @@ -10,6 +10,7 @@ import { BundleOrganizerApi, type ReleaseControlBundleVersionDetailDto, } from '../../../bundles/bundle-organizer.api'; +import { PlatformContextStore } from '../../../../core/context/platform-context.store'; @Component({ selector: 'app-create-release', @@ -19,7 +20,7 @@ import {

Create Release Version

-

Define identity, attach components, set the config contract, and seal.

+

Define identity, choose deployment targets, attach components, set the config contract, and seal.

@@ -29,7 +30,7 @@ import {
{ 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); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts index a287f98c3..4b2b14f75 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts @@ -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[] = [
- - - - - - - - - @if (filteredReleases().length === 0) { + @if (sortedReleases().length === 0) {
} @else {
-
+
- - - - - - - + @for (col of columns; track col.key) { + + } - @for (r of filteredReleases(); track r.id) { + @for (r of pagedReleases(); track r.id) {
ReleaseStageGatesRiskEvidenceStatusDecisions +
+ {{ col.label }} + @if (col.sortable) { + + @if (sortState()?.column === col.key) { + @if (sortState()?.direction === 'asc') { + + } @else { + + } + } @else { + + } + + } +
+
@@ -314,15 +300,25 @@ const MOCK_RELEASES: PipelineRelease[] = [
+ + +
+ +
} `, 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[] = [ + { 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(MOCK_RELEASES); @@ -456,6 +486,7 @@ export class ReleasesUnifiedPageComponent { readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all'); readonly statusFilter = signal('all'); readonly gateFilter = signal('all'); + readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null); readonly pipelineActiveFilters = computed(() => { 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)[column]; + const bVal = (b as unknown as Record)[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 ──────────────────────────────────────────────────────────── diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts index 8b9e0f13c..f9f1eef30 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts @@ -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('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); diff --git a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts index a0926abe7..ff712cbf3 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts @@ -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'; + +
+
+ Content width + Choose between centered content or full-width layout +
+
+ + +
+
} @@ -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('profile'); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index dbbbea012..22348a490 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -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); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts index 7d4d540d3..40e7b7d63 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts @@ -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
-
+
@@ -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); diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index b0e290616..30dfbfe93 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts index 6fc465e48..799b017c1 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts @@ -301,6 +301,13 @@ export interface NavItem { } + @case ('layers') { + + + + + + } @case ('help-circle') { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index 68dce5eb0..7ff4195c1 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -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 - +
- @if (primaryAction(); as action) { - {{ action.label }} + @if (pageActionService.action(); as pa) { + @if (pa.route) { + {{ pa.label }} + } @else if (pa.action) { + + } } + @if (isAuthenticated() && showViewModeSwitcher()) { } @@ -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); @@ -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 { const preferredLocale = await this.localePreference.getLocaleAsync(); diff --git a/src/Web/StellaOps.Web/src/styles.scss b/src/Web/StellaOps.Web/src/styles.scss index 6a020af5c..cd1fab759 100644 --- a/src/Web/StellaOps.Web/src/styles.scss +++ b/src/Web/StellaOps.Web/src/styles.scss @@ -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; + } + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index 14c3fa67d..e80000a14 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -3875,7 +3875,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine ElkPositionedNode[] nodes, ElkLayoutDirection direction) { - if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0 || true) + if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0) { return edges; } diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.json b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.json new file mode 100644 index 000000000..08c806354 --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.json @@ -0,0 +1,137 @@ +{ + "graphId": "UserDataCheckConsistency:1.0.0", + "nodes": [ + { + "id": "start", + "label": "Start", + "kind": "Start", + "iconKey": "start", + "semanticType": "Start", + "semanticKey": "start", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 12, + "y": 12, + "width": 264, + "height": 132, + "ports": [] + }, + { + "id": "end", + "label": "End", + "kind": "End", + "iconKey": "end", + "semanticType": "End", + "semanticKey": "end", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 872, + "y": 12, + "width": 264, + "height": 132, + "ports": [] + }, + { + "id": "start/1", + "label": "Check User Data", + "kind": "TransportCall", + "iconKey": "transport", + "semanticType": "TransportCall", + "semanticKey": "Check User Data", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 336, + "y": 34, + "width": 208, + "height": 88, + "ports": [] + }, + { + "id": "start/2", + "label": "Set isConsistent", + "kind": "SetState", + "iconKey": "state", + "semanticType": "State", + "semanticKey": "isConsistent", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 604, + "y": 34, + "width": 208, + "height": 88, + "ports": [] + } + ], + "edges": [ + { + "id": "edge/1", + "sourceNodeId": "start", + "targetNodeId": "start/1", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 276, + "y": 78 + }, + "endPoint": { + "x": 336, + "y": 78 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/2", + "sourceNodeId": "start/1", + "targetNodeId": "start/2", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 544, + "y": 78 + }, + "endPoint": { + "x": 604, + "y": 78 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/3", + "sourceNodeId": "start/2", + "targetNodeId": "end", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 812, + "y": 78 + }, + "endPoint": { + "x": 872, + "y": 78 + }, + "bendPoints": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.png b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.png new file mode 100644 index 000000000..a36c10bfa Binary files /dev/null and b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.png differ diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.svg b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.svg new file mode 100644 index 000000000..43d8a8a0a --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elkjs.svg @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UserDataCheckConsistency [ElkJs] + + + Legend + Node Shapes: + + + Start + + End + + + + Setter + + + + Service Call + Badges: + + set state + + + + + + + + service call + + + + + + + + + Start + + + + + End + + + + + + + + + + + + Check User Data + + + + + + + + + + + + + + + + Set isConsistent + diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.json b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.json new file mode 100644 index 000000000..6c7fe713f --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.json @@ -0,0 +1,137 @@ +{ + "graphId": "UserDataCheckConsistency:1.0.0", + "nodes": [ + { + "id": "start", + "label": "Start", + "kind": "Start", + "iconKey": "start", + "semanticType": "Start", + "semanticKey": "start", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 0, + "y": 0, + "width": 264, + "height": 132, + "ports": [] + }, + { + "id": "start/1", + "label": "Check User Data", + "kind": "TransportCall", + "iconKey": "transport", + "semanticType": "TransportCall", + "semanticKey": "Check User Data", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 319.2, + "y": 22, + "width": 208, + "height": 88, + "ports": [] + }, + { + "id": "start/2", + "label": "Set isConsistent", + "kind": "SetState", + "iconKey": "state", + "semanticType": "State", + "semanticKey": "isConsistent", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 582.4, + "y": 22, + "width": 208, + "height": 88, + "ports": [] + }, + { + "id": "end", + "label": "End", + "kind": "End", + "iconKey": "end", + "semanticType": "End", + "semanticKey": "end", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": 845.5999999999999, + "y": 0, + "width": 264, + "height": 132, + "ports": [] + } + ], + "edges": [ + { + "id": "edge/1", + "sourceNodeId": "start", + "targetNodeId": "start/1", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 264, + "y": 66 + }, + "endPoint": { + "x": 319.2, + "y": 66 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/2", + "sourceNodeId": "start/1", + "targetNodeId": "start/2", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 527.2, + "y": 66 + }, + "endPoint": { + "x": 582.4, + "y": 66 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/3", + "sourceNodeId": "start/2", + "targetNodeId": "end", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": 790.4, + "y": 66 + }, + "endPoint": { + "x": 845.5999999999999, + "y": 66 + }, + "bendPoints": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.png b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.png new file mode 100644 index 000000000..4b67f1ee3 Binary files /dev/null and b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.png differ diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.svg b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.svg new file mode 100644 index 000000000..1d4c3c483 --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/elksharp.svg @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UserDataCheckConsistency [ElkSharp] + + + Legend + Node Shapes: + + + Start + + End + + + + Setter + + + + Service Call + Badges: + + set state + + + + + + + + service call + + + + + + + + + Start + + + + + + + + + + + + Check User Data + + + + + + + + + + + + + + + + Set isConsistent + + + + + End + diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.json b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.json new file mode 100644 index 000000000..e1c2dc4b2 --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.json @@ -0,0 +1,137 @@ +{ + "graphId": "UserDataCheckConsistency:1.0.0", + "nodes": [ + { + "id": "end", + "label": "End", + "kind": "End", + "iconKey": "end", + "semanticType": "End", + "semanticKey": "end", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": -1038, + "y": -131.99999999999994, + "width": 528, + "height": 264, + "ports": [] + }, + { + "id": "start/1", + "label": "Check User Data", + "kind": "TransportCall", + "iconKey": "transport", + "semanticType": "TransportCall", + "semanticKey": "Check User Data", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": -1514, + "y": -43.9999999999999, + "width": 208, + "height": 87.99999999999997, + "ports": [] + }, + { + "id": "start/2", + "label": "Set isConsistent", + "kind": "SetState", + "iconKey": "state", + "semanticType": "State", + "semanticKey": "isConsistent", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": -1276, + "y": -43.999999999999915, + "width": 208, + "height": 87.99999999999997, + "ports": [] + }, + { + "id": "start", + "label": "Start", + "kind": "Start", + "iconKey": "start", + "semanticType": "Start", + "semanticKey": "start", + "route": null, + "taskType": null, + "parentNodeId": null, + "x": -1808, + "y": -131.9999999999999, + "width": 264, + "height": 264, + "ports": [] + } + ], + "edges": [ + { + "id": "edge/2", + "sourceNodeId": "start/1", + "targetNodeId": "start/2", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": -1306, + "y": 7.105427357601002E-14 + }, + "endPoint": { + "x": -1276, + "y": 8.526512829121202E-14 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/3", + "sourceNodeId": "start/2", + "targetNodeId": "end", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": -1068, + "y": 5.684341886080802E-14 + }, + "endPoint": { + "x": -1038, + "y": 7.105427357601002E-14 + }, + "bendPoints": [] + } + ] + }, + { + "id": "edge/1", + "sourceNodeId": "start", + "targetNodeId": "start/1", + "sourcePortId": null, + "targetPortId": null, + "kind": null, + "label": null, + "sections": [ + { + "startPoint": { + "x": -1544, + "y": 8.526512829121202E-14 + }, + "endPoint": { + "x": -1514, + "y": 1.1368683772161603E-13 + }, + "bendPoints": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.png b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.png new file mode 100644 index 000000000..e7ff63dc3 Binary files /dev/null and b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.png differ diff --git a/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.svg b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.svg new file mode 100644 index 000000000..df4be1491 --- /dev/null +++ b/src/src/Workflow/__Tests/docs/renderings/20260320/UserDataCheckConsistency/msagl.svg @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UserDataCheckConsistency [Msagl] + + + Legend + Node Shapes: + + + Start + + End + + + + Setter + + + + Service Call + Badges: + + set state + + + + + + + + service call + + + + + + + + + + End + + + + + + + + + + + + Check User Data + + + + + + + + + + + + + + + + Set isConsistent + + + + Start +