release control ui improvements

This commit is contained in:
master
2026-03-21 00:09:17 +02:00
parent f5b5f24d95
commit d2e542f77e
40 changed files with 2637 additions and 216 deletions

View File

@@ -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 `<table class="stella-table">` in `releases-unified-page.component.ts` with `<app-data-table>`.
- Define columns with `sortable: true` for: Release, Stage, Gates, Risk, Evidence, Status.
- Add sort state signal + computed sorted+filtered+paged data.
- Move `<app-pagination>` to right-aligned position below the table.
- Remove manual `<thead>/<tbody>` 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

View File

@@ -112,7 +112,11 @@ export const routes: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', 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, ...LEGACY_REDIRECT_ROUTES,
{ {

View File

@@ -25,7 +25,7 @@ export type ReleaseEventType =
| 'deployed' | 'deployed'
| 'failed' | 'failed'
| 'rolled_back'; | 'rolled_back';
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate'; export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate' | 'ab-release';
export interface ManagedRelease { export interface ManagedRelease {
id: string; id: string;
@@ -226,6 +226,7 @@ export function getStrategyLabel(strategy: DeploymentStrategy): string {
blue_green: 'Blue/Green', blue_green: 'Blue/Green',
canary: 'Canary', canary: 'Canary',
recreate: 'Recreate', recreate: 'Recreate',
'ab-release': 'A/B Release',
}; };
return labels[strategy] || strategy; return labels[strategy] || strategy;
} }

View File

@@ -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
}
}
}

View File

@@ -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);
}
}

View File

@@ -8,6 +8,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
OnInit, OnInit,
OnDestroy,
DestroyRef, DestroyRef,
computed, computed,
inject, inject,
@@ -19,6 +20,7 @@ import { filter } from 'rxjs';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client'; import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
import { PageActionService } from '../../../core/services/page-action.service';
import { import {
NotifierChannel, NotifierChannel,
NotifierRule, NotifierRule,
@@ -516,11 +518,12 @@ interface ConfigSubTab {
`], `],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class NotificationDashboardComponent implements OnInit { export class NotificationDashboardComponent implements OnInit, OnDestroy {
private readonly api = inject<NotifierApi>(NOTIFIER_API); private readonly api = inject<NotifierApi>(NOTIFIER_API);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly pageAction = inject(PageActionService);
readonly pageTabs: readonly StellaPageTab[] = NOTIFICATION_TABS; readonly pageTabs: readonly StellaPageTab[] = NOTIFICATION_TABS;
@@ -551,6 +554,7 @@ export class NotificationDashboardComponent implements OnInit {
}); });
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.pageAction.set({ label: 'Refresh', action: () => this.refreshStats() });
this.setActiveTabFromUrl(this.router.url); this.setActiveTabFromUrl(this.router.url);
this.router.events this.router.events
@@ -565,6 +569,10 @@ export class NotificationDashboardComponent implements OnInit {
await this.loadInitialData(); await this.loadInitialData();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
async loadInitialData(): Promise<void> { async loadInitialData(): Promise<void> {
this.loadingStats.set(true); this.loadingStats.set(true);
this.error.set(null); this.error.set(null);

View File

@@ -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 { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, Role } from '../services/console-admin-api.service'; 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 { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes'; import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { PageActionService } from '../../../core/services/page-action.service';
interface RoleBundle { interface RoleBundle {
module: string; module: string;
@@ -22,12 +23,6 @@ interface RoleBundle {
<div class="admin-panel"> <div class="admin-panel">
<header class="admin-header"> <header class="admin-header">
<h1>Roles & Scopes</h1> <h1>Roles & Scopes</h1>
<button
class="btn-primary"
(click)="showCreateForm()"
[disabled]="!canWrite || isCreating">
Create Custom Role
</button>
</header> </header>
<div class="tabs"> <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 api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly pageAction = inject(PageActionService);
activeTab: 'catalog' | 'custom' = 'catalog'; activeTab: 'catalog' | 'custom' = 'catalog';
catalogFilter = ''; catalogFilter = '';
@@ -659,11 +655,16 @@ export class RolesListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Add Role', action: () => this.showCreateForm() });
if (this.activeTab === 'custom') { if (this.activeTab === 'custom') {
this.loadCustomRoles(); this.loadCustomRoles();
} }
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
getBundlesForModule(module: string): RoleBundle[] { getBundlesForModule(module: string): RoleBundle[] {
return this.roleBundles.filter(b => b.module === module); return this.roleBundles.filter(b => b.module === module);
} }

View File

@@ -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 { CommonModule } from '@angular/common';
import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service'; import { ConsoleAdminApiService, Tenant } from '../services/console-admin-api.service';
import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { PageActionService } from '../../../core/services/page-action.service';
/** /**
* Tenants List Component * Tenants List Component
@@ -16,9 +17,6 @@ import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
<div class="admin-panel"> <div class="admin-panel">
<header class="admin-header"> <header class="admin-header">
<h1>Tenants</h1> <h1>Tenants</h1>
<button class="btn-primary" (click)="createTenant()" [disabled]="!canWrite">
Create Tenant
</button>
</header> </header>
<div class="admin-content"> <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 api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly pageAction = inject(PageActionService);
tenants: Tenant[] = []; tenants: Tenant[] = [];
loading = true; loading = true;
@@ -149,9 +148,14 @@ export class TenantsListComponent implements OnInit {
canWrite = false; // TODO: Check authority:tenants.write scope canWrite = false; // TODO: Check authority:tenants.write scope
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Add Tenant', action: () => this.createTenant() });
this.loadTenants(); this.loadTenants();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
private loadTenants(): void { private loadTenants(): void {
this.loading = true; this.loading = true;
this.error = null; this.error = null;

View File

@@ -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 { FormsModule } from '@angular/forms';
import { ConsoleAdminApiService, User } from '../services/console-admin-api.service'; 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 { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes'; import { StellaOpsScopes } from '../../../core/auth/scopes';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component';
import { PageActionService } from '../../../core/services/page-action.service';
@Component({ @Component({
selector: 'app-users-list', selector: 'app-users-list',
@@ -14,12 +15,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.
<div class="admin-panel"> <div class="admin-panel">
<header class="admin-header"> <header class="admin-header">
<h1>Users</h1> <h1>Users</h1>
<button
class="btn-primary"
(click)="showCreateForm()"
[disabled]="!canWrite || isCreating">
Create User
</button>
</header> </header>
@if (error) { @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 api = inject(ConsoleAdminApiService);
private readonly freshAuth = inject(FreshAuthService); private readonly freshAuth = inject(FreshAuthService);
private readonly auth = inject(AUTH_SERVICE); private readonly auth = inject(AUTH_SERVICE);
private readonly pageAction = inject(PageActionService);
users: User[] = []; users: User[] = [];
isLoading = false; isLoading = false;
@@ -385,9 +381,14 @@ export class UsersListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Add User', action: () => this.showCreateForm() });
this.loadUsers(); this.loadUsers();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
loadUsers(): void { loadUsers(): void {
this.isLoading = true; this.isLoading = true;
this.error = null; this.error = null;

View File

@@ -14,6 +14,7 @@ import {
signal, signal,
OnInit, OnInit,
AfterViewInit, AfterViewInit,
OnDestroy,
ViewChild, ViewChild,
ElementRef, ElementRef,
NgZone, NgZone,
@@ -43,6 +44,7 @@ import {
AUTH_SERVICE, AUTH_SERVICE,
type AuthService, type AuthService,
} from '../../core/auth/auth.service'; } from '../../core/auth/auth.service';
import { PageActionService } from '../../core/services/page-action.service';
interface EnvironmentCard { interface EnvironmentCard {
id: string; id: string;
@@ -90,13 +92,6 @@ interface PendingAction {
<h1 class="board-title">Mission Board</h1> <h1 class="board-title">Mission Board</h1>
<p class="board-subtitle">{{ tenantLabel() }}</p> <p class="board-subtitle">{{ tenantLabel() }}</p>
</div> </div>
<button class="refresh-btn" (click)="refresh()" [disabled]="refreshing()" type="button">
@if (refreshing()) {
Refreshing...
} @else {
Refresh
}
</button>
</header> </header>
@if (!contextReady()) { @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 context = inject(PlatformContextStore);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API); private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly sourceApi = inject(SourceManagementApi); private readonly sourceApi = inject(SourceManagementApi);
private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly ngZone = inject(NgZone); private readonly ngZone = inject(NgZone);
private readonly pageAction = inject(PageActionService);
// -- Scroll refs and signals ------------------------------------------------ // -- Scroll refs and signals ------------------------------------------------
@ViewChild('pipelineScroll') pipelineScrollRef?: ElementRef<HTMLDivElement>; @ViewChild('pipelineScroll') pipelineScrollRef?: ElementRef<HTMLDivElement>;
@@ -1434,10 +1430,15 @@ export class DashboardV3Component implements OnInit, AfterViewInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Refresh', action: () => this.refresh() });
this.loadVulnerabilityStats(); this.loadVulnerabilityStats();
this.loadFeedStatus(); this.loadFeedStatus();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
// Check scroll arrows at multiple intervals to catch async data rendering // Check scroll arrows at multiple intervals to catch async data rendering
const checkScroll = () => { const checkScroll = () => {

View File

@@ -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> <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 Quick
</button> </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 <button
class="btn btn-outline" class="btn btn-outline"
(click)="runFullCheck()" (click)="runFullCheck()"

View File

@@ -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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; 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 { CheckResultComponent } from './components/check-result/check-result.component';
import { ExportDialogComponent } from './components/export-dialog/export-dialog.component'; import { ExportDialogComponent } from './components/export-dialog/export-dialog.component';
import { AppConfigService } from '../../core/config/app-config.service'; 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'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
@@ -33,7 +34,8 @@ const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [
templateUrl: './doctor-dashboard.component.html', templateUrl: './doctor-dashboard.component.html',
styleUrl: './doctor-dashboard.component.scss' 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); readonly store = inject(DoctorStore);
private readonly configService = inject(AppConfigService); private readonly configService = inject(AppConfigService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
@@ -113,6 +115,8 @@ export class DoctorDashboardComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Run Check', action: () => this.runNormalCheck() });
// Load metadata on init // Load metadata on init
this.store.fetchPlugins(); this.store.fetchPlugins();
this.store.fetchChecks(); this.store.fetchChecks();
@@ -125,6 +129,10 @@ export class DoctorDashboardComponent implements OnInit {
} }
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
onFixInSetup(url: string): void { onFixInSetup(url: string): void {
this.router.navigateByUrl(url); this.router.navigateByUrl(url);
} }

View File

@@ -4,6 +4,7 @@ import {
Component, Component,
computed, computed,
inject, inject,
OnDestroy,
OnInit, OnInit,
signal, signal,
} from '@angular/core'; } from '@angular/core';
@@ -16,6 +17,7 @@ import {
AirGapBundle, AirGapBundle,
} from '../../core/api/feed-mirror.models'; } from '../../core/api/feed-mirror.models';
import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client'; 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 { MirrorListComponent } from './mirror-list.component';
import { OfflineSyncStatusComponent } from './offline-sync-status.component'; import { OfflineSyncStatusComponent } from './offline-sync-status.component';
import { FeedVersionLockComponent } from './feed-version-lock.component'; import { FeedVersionLockComponent } from './feed-version-lock.component';
@@ -594,10 +596,11 @@ const FEED_MIRROR_TABS: readonly StellaPageTab[] = [
`], `],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FeedMirrorDashboardComponent implements OnInit { export class FeedMirrorDashboardComponent implements OnInit, OnDestroy {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly feedMirrorApi = inject(FEED_MIRROR_API); private readonly feedMirrorApi = inject(FEED_MIRROR_API);
private readonly pageAction = inject(PageActionService);
readonly FEED_MIRROR_TABS = FEED_MIRROR_TABS; readonly FEED_MIRROR_TABS = FEED_MIRROR_TABS;
@@ -651,10 +654,15 @@ export class FeedMirrorDashboardComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Refresh', action: () => this.refreshMirrors() });
this.loadData(); this.loadData();
this.loadBundles(); this.loadBundles();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
private loadData(): void { private loadData(): void {
this.loading.set(true); this.loading.set(true);

View File

@@ -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 { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { PageActionService } from '../../core/services/page-action.service';
import { IntegrationService } from './integration.service'; import { IntegrationService } from './integration.service';
import { IntegrationType } from './integration.models'; import { IntegrationType } from './integration.models';
@@ -94,7 +95,6 @@ interface IntegrationHubStats {
</nav> </nav>
<section class="actions"> <section class="actions">
<button type="button" (click)="addIntegration()">+ Add Integration</button>
<a routerLink="activity">View Activity</a> <a routerLink="activity">View Activity</a>
</section> </section>
@@ -288,10 +288,11 @@ interface IntegrationHubStats {
} }
`], `],
}) })
export class IntegrationHubComponent { export class IntegrationHubComponent implements OnDestroy {
private readonly integrationService = inject(IntegrationService); private readonly integrationService = inject(IntegrationService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly pageAction = inject(PageActionService);
readonly stats = signal<IntegrationHubStats>({ readonly stats = signal<IntegrationHubStats>({
registries: 0, registries: 0,
@@ -309,6 +310,11 @@ export class IntegrationHubComponent {
constructor() { constructor() {
this.loadStats(); this.loadStats();
this.pageAction.set({ label: 'Add Integration', action: () => this.addIntegration() });
}
ngOnDestroy(): void {
this.pageAction.clear();
} }
private loadStats(): void { private loadStats(): void {

View File

@@ -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> <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 Setup
</a> </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> </div>
</header> </header>

View File

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
OnDestroy,
OnInit, OnInit,
computed, computed,
inject, inject,
@@ -15,6 +16,7 @@ import {
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { PageActionService } from '../../core/services/page-action.service';
import { import {
NOTIFY_API, NOTIFY_API,
NotifyApi, NotifyApi,
@@ -70,7 +72,8 @@ type DeliveryFilter =
styleUrls: ['./notify-panel.component.scss'], styleUrls: ['./notify-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush 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 api = inject<NotifyApi>(NOTIFY_API);
private readonly formBuilder = inject(NonNullableFormBuilder); private readonly formBuilder = inject(NonNullableFormBuilder);
@@ -166,9 +169,14 @@ export class NotifyPanelComponent implements OnInit {
}); });
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
this.pageAction.set({ label: 'Refresh', action: () => void this.refreshAll() });
await this.refreshAll(); await this.refreshAll();
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
async refreshAll(): Promise<void> { async refreshAll(): Promise<void> {
await Promise.all([ await Promise.all([
this.loadChannels(), this.loadChannels(),

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { import {
type OverviewCardGroup, type OverviewCardGroup,

View File

@@ -1,6 +1,6 @@
// Filter bar adoption: aligned with release-list (versions) page patterns // Filter bar adoption: aligned with release-list (versions) page patterns
import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core'; 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'; import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component';

View File

@@ -264,7 +264,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
styles: [` styles: [`
.approval-queue-container { .approval-queue-container {
padding: 24px; padding: 24px;
max-width: 1400px;
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -1,5 +1,5 @@
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) // 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; 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 { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component';
import { DateFormatService } from '../../../../core/i18n/date-format.service'; import { DateFormatService } from '../../../../core/i18n/date-format.service';
import { PageActionService } from '../../../../core/services/page-action.service';
@Component({ @Component({
selector: 'app-release-list', selector: 'app-release-list',
imports: [FormsModule, RouterModule, FilterBarComponent], imports: [FormsModule, RouterModule, FilterBarComponent],
@@ -27,16 +28,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
<h1>Release Versions</h1> <h1>Release Versions</h1>
<p class="subtitle">Digest-first release version catalog across standard and hotfix lanes</p> <p class="subtitle">Digest-first release version catalog across standard and hotfix lanes</p>
</div> </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> </header>
<app-filter-bar <app-filter-bar
@@ -262,7 +253,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
.release-list { .release-list {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
max-width: 1600px;
margin: 0 auto; 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 dateFmt = inject(DateFormatService);
private readonly pageAction = inject(PageActionService);
readonly store = inject(ReleaseManagementStore); readonly store = inject(ReleaseManagementStore);
readonly context = inject(PlatformContextStore); readonly context = inject(PlatformContextStore);
@@ -882,6 +873,7 @@ export class ReleaseListComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
this.context.initialize(); this.context.initialize();
this.route.queryParamMap.subscribe((params) => { this.route.queryParamMap.subscribe((params) => {
this.applyingFromQuery = true; this.applyingFromQuery = true;
@@ -899,6 +891,10 @@ export class ReleaseListComponent implements OnInit {
}); });
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
onReleaseSearch(value: string): void { onReleaseSearch(value: string): void {
this.searchTerm = value; this.searchTerm = value;
this.applyFilters(false); this.applyFilters(false);

View File

@@ -8,13 +8,14 @@
* Tab 2 "Approvals": embeds the existing ApprovalQueueComponent. * 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 { UpperCasePipe, SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; 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 { 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 ────────────────────────────────────────────────────────────── // ── Data model ──────────────────────────────────────────────────────────────
@@ -122,9 +123,8 @@ const MOCK_RELEASES: PipelineRelease[] = [
SlicePipe, SlicePipe,
RouterLink, RouterLink,
FormsModule, FormsModule,
StellaMetricCardComponent,
StellaMetricGridComponent,
FilterBarComponent, FilterBarComponent,
PaginationComponent,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
@@ -136,60 +136,21 @@ const MOCK_RELEASES: PipelineRelease[] = [
</div> </div>
</header> </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 --> <!-- Pipeline -->
<div class="rup__toolbar"> <div class="rup__toolbar">
<app-filter-bar <app-filter-bar
searchPlaceholder="Search releases..." searchPlaceholder="Search releases..."
[filters]="pipelineFilterOptions" [filters]="pipelineFilterOptions"
[activeFilters]="pipelineActiveFilters()" [activeFilters]="pipelineActiveFilters()"
(searchChange)="searchQuery.set($event)" (searchChange)="searchQuery.set($event); currentPage.set(1)"
(filterChange)="onPipelineFilterAdded($event)" (filterChange)="onPipelineFilterAdded($event)"
(filterRemove)="onPipelineFilterRemoved($event)" (filterRemove)="onPipelineFilterRemoved($event)"
(filtersCleared)="clearAllPipelineFilters()" (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> </div>
<!-- Releases table --> <!-- Releases table -->
@if (filteredReleases().length === 0) { @if (sortedReleases().length === 0) {
<div class="rup__empty"> <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"> <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"/> <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> </div>
} @else { } @else {
<div class="rup__table-wrap"> <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> <thead>
<tr> <tr>
<th>Release</th> @for (col of columns; track col.key) {
<th>Stage</th> <th
<th>Gates</th> [class.rup__th--sortable]="col.sortable"
<th>Risk</th> [class.rup__th--sorted]="sortState()?.column === col.key"
<th>Evidence</th> [attr.aria-sort]="getSortAria(col.key)"
<th>Status</th> (click)="col.sortable ? toggleSort(col.key) : null"
<th>Decisions</th> >
<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> </tr>
</thead> </thead>
<tbody> <tbody>
@for (r of filteredReleases(); track r.id) { @for (r of pagedReleases(); track r.id) {
<tr> <tr>
<!-- Release --> <!-- Release -->
<td> <td>
@@ -314,15 +300,25 @@ const MOCK_RELEASES: PipelineRelease[] = [
</tbody> </tbody>
</table> </table>
</div> </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> </div>
`, `,
styles: [` 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__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__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; } .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; } .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; } :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; } .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-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; } .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 { 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 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; } .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 ────────────────────────────────────────── // ── Filter-bar configuration ──────────────────────────────────────────
readonly pipelineFilterOptions: FilterOption[] = [ 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 ────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES); readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
@@ -456,6 +486,7 @@ export class ReleasesUnifiedPageComponent {
readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all'); readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all');
readonly statusFilter = signal<string>('all'); readonly statusFilter = signal<string>('all');
readonly gateFilter = signal<string>('all'); readonly gateFilter = signal<string>('all');
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
readonly pipelineActiveFilters = computed<ActiveFilter[]>(() => { readonly pipelineActiveFilters = computed<ActiveFilter[]>(() => {
const filters: ActiveFilter[] = []; const filters: ActiveFilter[] = [];
@@ -478,21 +509,10 @@ export class ReleasesUnifiedPageComponent {
return filters; return filters;
}); });
// ── Derived ──────────────────────────────────────────────────────────── // ── Pagination ────────────────────────────────────────────────────────
readonly totalReleases = computed(() => this.releases().length); readonly currentPage = signal(1);
readonly pageSize = signal(10);
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 filteredReleases = computed(() => { readonly filteredReleases = computed(() => {
let list = this.releases(); let list = this.releases();
@@ -521,9 +541,49 @@ export class ReleasesUnifiedPageComponent {
return list; 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 ──────────────────────────────────────────────── // ── Filter-bar handlers ────────────────────────────────────────────────
onPipelineFilterAdded(f: ActiveFilter): void { onPipelineFilterAdded(f: ActiveFilter): void {
this.currentPage.set(1);
switch (f.key) { switch (f.key) {
case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break; case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break;
case 'status': this.statusFilter.set(f.value); break; case 'status': this.statusFilter.set(f.value); break;
@@ -544,6 +604,29 @@ export class ReleasesUnifiedPageComponent {
this.statusFilter.set('all'); this.statusFilter.set('all');
this.gateFilter.set('all'); this.gateFilter.set('all');
this.searchQuery.set(''); 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 ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'; 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 { RouterLink } from '@angular/router';
import { PageActionService } from '../../core/services/page-action.service';
import { catchError, map, of, take } from 'rxjs'; import { catchError, map, of, take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store'; 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 http = inject(HttpClient);
private readonly context = inject(PlatformContextStore); private readonly context = inject(PlatformContextStore);
private readonly pageAction = inject(PageActionService);
readonly tabs = REPORT_TABS; readonly tabs = REPORT_TABS;
readonly activeTab = signal<ReportTab>('risk'); readonly activeTab = signal<ReportTab>('risk');
readonly riskExporting = signal(false); readonly riskExporting = signal(false);
readonly vexExporting = signal(false); readonly vexExporting = signal(false);
ngOnInit(): void {
this.pageAction.set({ label: 'Export Report', action: () => this.exportRiskCsv() });
}
ngOnDestroy(): void {
this.pageAction.clear();
}
exportRiskCsv(): void { exportRiskCsv(): void {
this.riskExporting.set(true); this.riskExporting.set(true);

View File

@@ -8,6 +8,7 @@ import { SidebarPreferenceService } from '../../../layout/app-sidebar/sidebar-pr
import { AiPreferencesComponent, type AiPreferences } from '../ai-preferences.component'; import { AiPreferencesComponent, type AiPreferences } from '../ai-preferences.component';
import { PlainLanguageToggleComponent } from '../../advisory-ai/plain-language-toggle.component'; import { PlainLanguageToggleComponent } from '../../advisory-ai/plain-language-toggle.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.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'; type PrefsTab = 'profile' | 'appearance' | 'language' | 'layout' | 'ai';
@@ -215,6 +216,29 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
</span> </span>
</button> </button>
</div> </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> </div>
} }
@@ -736,6 +760,42 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
background: var(--color-border-primary); 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 */ /* Responsive */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -777,6 +837,7 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
export class UserPreferencesPageComponent implements OnInit { export class UserPreferencesPageComponent implements OnInit {
protected readonly themeService = inject(ThemeService); protected readonly themeService = inject(ThemeService);
protected readonly sidebarPrefs = inject(SidebarPreferenceService); protected readonly sidebarPrefs = inject(SidebarPreferenceService);
protected readonly contentWidthService = inject(ContentWidthService);
readonly prefsTabs = PREFS_TABS; readonly prefsTabs = PREFS_TABS;
readonly activeTab = signal<PrefsTab>('profile'); readonly activeTab = signal<PrefsTab>('profile');

View File

@@ -4,13 +4,14 @@
* @description Main Trust Administration component with tabs for Keys, Issuers, Certificates, and Audit * @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 { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs'; import { filter } from 'rxjs';
import { TRUST_API, TrustApi } from '../../core/api/trust.client'; 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 { TrustAdministrationOverview } from '../../core/api/trust.models';
import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive'; import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; 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 trustApi = inject(TRUST_API);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly pageAction = inject(PageActionService);
// State // State
readonly loading = signal(true); readonly loading = signal(true);
@@ -295,6 +297,7 @@ export class TrustAdminComponent implements OnInit {
}); });
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'Refresh', action: () => this.refreshDashboard() });
this.loadDashboard(); this.loadDashboard();
this.setActiveTabFromUrl(this.router.url); this.setActiveTabFromUrl(this.router.url);
@@ -308,6 +311,10 @@ export class TrustAdminComponent implements OnInit {
}); });
} }
ngOnDestroy(): void {
this.pageAction.clear();
}
private loadDashboard(): void { private loadDashboard(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);

View File

@@ -7,6 +7,7 @@ import { AppSidebarComponent } from '../app-sidebar';
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component'; import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
import { OverlayHostComponent } from '../overlay-host/overlay-host.component'; import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service'; 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'; 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"> <div class="shell__content">
<app-breadcrumb class="shell__breadcrumb"></app-breadcrumb> <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 /> <router-outlet />
</main> </main>
</div> </div>
@@ -154,9 +158,20 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
flex: 1; flex: 1;
padding: var(--space-6, 1.5rem); padding: var(--space-6, 1.5rem);
outline: none; 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 { .shell__overlay {
display: none; display: none;
} }
@@ -217,6 +232,7 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
}) })
export class AppShellComponent { export class AppShellComponent {
readonly sidebarPrefs = inject(SidebarPreferenceService); readonly sidebarPrefs = inject(SidebarPreferenceService);
readonly contentWidth = inject(ContentWidthService);
/** Whether mobile menu is open */ /** Whether mobile menu is open */
readonly mobileMenuOpen = signal(false); readonly mobileMenuOpen = signal(false);

View File

@@ -678,19 +678,6 @@ export class AppSidebarComponent implements AfterViewInit {
*/ */
readonly navSections: NavSection[] = [ readonly navSections: NavSection[] = [
// ── Group 1: Release Control ───────────────────────────────────── // ── 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', id: 'releases',
label: 'Releases', label: 'Releases',
@@ -1132,7 +1119,7 @@ export class AppSidebarComponent implements AfterViewInit {
} }
groupRoute(group: NavSectionGroup): string { groupRoute(group: NavSectionGroup): string {
return group.sections[0]?.route ?? '/mission-control/board'; return group.sections[0]?.route ?? '/';
} }
private withDynamicChildState(item: NavItem): NavItem { private withDynamicChildState(item: NavItem): NavItem {

View File

@@ -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"/> <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> </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') { @case ('help-circle') {
<svg viewBox="0 0 24 24" width="18" height="18"> <svg viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>

View File

@@ -29,6 +29,8 @@ import { FeedSnapshotChipComponent } from '../context-chips/feed-snapshot-chip.c
import { PolicyBaselineChipComponent } from '../context-chips/policy-baseline-chip.component'; import { PolicyBaselineChipComponent } from '../context-chips/policy-baseline-chip.component';
import { EvidenceModeChipComponent } from '../context-chips/evidence-mode-chip.component'; import { EvidenceModeChipComponent } from '../context-chips/evidence-mode-chip.component';
import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream-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. * 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> <app-global-search></app-global-search>
</div> </div>
<!-- Right section: Create + User --> <!-- Right section: Page action + User -->
<div class="topbar__right"> <div class="topbar__right">
@if (primaryAction(); as action) { @if (pageActionService.action(); as pa) {
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a> @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 <button
@@ -181,6 +187,27 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
<app-context-chips></app-context-chips> <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()) { @if (isAuthenticated() && showViewModeSwitcher()) {
<stella-view-mode-switcher></stella-view-mode-switcher> <stella-view-mode-switcher></stella-view-mode-switcher>
} }
@@ -301,16 +328,17 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid var(--color-btn-primary-border); border: 1px solid var(--color-btn-primary-border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
padding: 0.35rem 0.58rem; padding: 0.375rem 0.75rem;
background: var(--color-btn-primary-bg); background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text); color: var(--color-btn-primary-text);
text-decoration: none; text-decoration: none;
font-size: 0.69rem; font-size: 0.75rem;
font-family: var(--font-family-mono); font-family: inherit;
letter-spacing: 0.02em; font-weight: var(--font-weight-semibold, 600);
white-space: nowrap; 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 { .topbar__primary-action:hover {
@@ -540,10 +568,40 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
margin-left: auto; 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) { @media (max-width: 575px) {
.topbar__status-chips { .topbar__status-chips {
display: none; display: none;
} }
.topbar__width-toggle {
display: none;
}
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -554,6 +612,8 @@ export class AppTopbarComponent {
private readonly consoleStore = inject(ConsoleSessionStore); private readonly consoleStore = inject(ConsoleSessionStore);
private readonly i18n = inject(I18nService); private readonly i18n = inject(I18nService);
private readonly localePreference = inject(UserLocalePreferenceService); private readonly localePreference = inject(UserLocalePreferenceService);
protected readonly contentWidth = inject(ContentWidthService);
readonly pageActionService = inject(PageActionService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject(ElementRef<HTMLElement>); private readonly elementRef = inject(ElementRef<HTMLElement>);
@@ -575,7 +635,7 @@ export class AppTopbarComponent {
readonly tenantSwitchInFlight = signal(false); readonly tenantSwitchInFlight = signal(false);
readonly tenantBootstrapAttempted = signal(false); readonly tenantBootstrapAttempted = signal(false);
readonly currentPath = signal(this.router.url); 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. * Routes where the Operator/Auditor view-mode switcher is relevant.
@@ -807,39 +867,6 @@ export class AppTopbarComponent {
trigger?.focus(); 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> { private async syncLocaleFromPreference(): Promise<void> {
const preferredLocale = await this.localePreference.getLocaleAsync(); const preferredLocale = await this.localePreference.getLocaleAsync();

View File

@@ -172,3 +172,22 @@
.mat-mdc-tab.mdc-tab--active { .mat-mdc-tab.mdc-tab--active {
font-weight: var(--font-weight-semibold, 600); 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;
}
}
}

View File

@@ -3875,7 +3875,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
ElkPositionedNode[] nodes, ElkPositionedNode[] nodes,
ElkLayoutDirection direction) ElkLayoutDirection direction)
{ {
if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0 || true) if (direction != ElkLayoutDirection.LeftToRight || nodes.Length == 0)
{ {
return edges; return edges;
} }

View File

@@ -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": []
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -0,0 +1,179 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1328" height="480" viewBox="0 0 1328 480">
<defs>
<marker id="arrow" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#51606f" />
</marker>
<marker id="arrow-failure" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#dc2626" />
</marker>
<marker id="arrow-timeout" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#d97706" />
</marker>
<marker id="arrow-repeat" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#2563eb" />
</marker>
<marker id="arrow-success" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#15803d" />
</marker>
<marker id="arrow-default-muted" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#475569" />
</marker>
<marker id="arrow-missing-condition" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#b91c1c" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
<feOffset in="blur" dx="0" dy="3" result="offsetBlur"/>
<feFlood flood-color="#0f172a" flood-opacity="0.16" result="shadowColor"/>
<feComposite in="shadowColor" in2="offsetBlur" operator="in" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect x="0" y="0" width="1328" height="480" fill="#f4f7fb" />
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">UserDataCheckConsistency [ElkJs]</text>
<g>
<rect x="24" y="34" rx="14" ry="14" width="1260" height="160" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
<text x="40" y="78" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
</g>
<rect x="118" y="62" rx="8" ry="8" width="28" height="22"
fill="#bbf7d0" stroke="#15803d" stroke-width="1.8" />
<text x="160" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Start</text>
<rect x="211.5" y="62" rx="8" ry="8" width="28" height="22"
fill="#fecaca" stroke="#b91c1c" stroke-width="1.8" />
<text x="253.5" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">End</text>
<rect x="290" y="62" rx="8" ry="8" width="28" height="22"
fill="#f7fbff" stroke="#2563eb" stroke-width="1.8" />
<rect x="303" y="64" rx="6" ry="6" width="13" height="18"
fill="#eaf2ff" stroke="none" />
<rect x="292" y="64" rx="5" ry="5" width="9" height="18"
fill="#bfdbfe" stroke="none" />
<text x="332" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Setter</text>
<rect x="391" y="62" rx="8" ry="8" width="28" height="22"
fill="#fdfbff" stroke="#6d28d9" stroke-width="1.8" />
<rect x="404" y="64" rx="6" ry="6" width="13" height="18"
fill="#f5eeff" stroke="none" />
<rect x="393" y="64" rx="5" ry="5" width="9" height="18"
fill="#ddd6fe" stroke="none" />
<text x="433" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Service Call</text>
<text x="40" y="132" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
<circle cx="102" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="118" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">set state</text>
<circle cx="96.2" cy="124" r="1.15" fill="#0f172a" />
<path d="M 98.6 124 L 107.8 124"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="128" r="1.15" fill="#0f172a" />
<path d="M 98.6 128 L 107.8 128"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="132" r="1.15" fill="#0f172a" />
<path d="M 98.6 132 L 107.8 132"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="215" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="231" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">service call</text>
<rect x="208.6" y="124.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.3" />
<path d="M 214.2 128 L 221.2 128 M 217.8 124.4 L 221.2 128 L 217.8 131.6"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M 296,286 L 356,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 564,286 L 624,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 832,286 L 892,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<rect x="32" y="220" rx="40" ry="40" width="264" height="132"
fill="#bbf7d0" stroke="#15803d" stroke-width="3" filter="url(#shadow)" />
<circle cx="74" cy="286" r="26" fill="#15803d" />
<polygon points="67,275 67,297 84,286"
fill="#ffffff" />
<text x="174.56" y="291"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">Start</text>
<rect x="892" y="220" rx="40" ry="40" width="264" height="132"
fill="#fecaca" stroke="#b91c1c" stroke-width="3" filter="url(#shadow)" />
<circle cx="934" cy="286" r="26" fill="#ffffff" stroke="#b91c1c" stroke-width="3" />
<rect x="925" y="277" width="18" height="18" rx="3" ry="3" fill="#b91c1c" />
<rect x="901" y="229" rx="34" ry="34" width="246" height="114"
fill="none" stroke="#b91c1c" stroke-width="2.5" />
<text x="1034.56" y="291"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">End</text>
<rect x="356" y="242" rx="16" ry="16" width="208" height="88"
fill="#fdfbff" stroke="#6d28d9" stroke-width="3.5" filter="url(#shadow)" />
<rect x="404" y="246" rx="12" ry="12" width="156" height="80"
fill="#f5eeff" stroke="none" />
<rect x="358" y="244" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="358.5" y="244.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#6d28d9" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="360" y="246" rx="12" ry="12" width="40" height="80"
fill="#ddd6fe" stroke="none" />
<path d="M 404 246 L 404 326"
fill="none" stroke="#6d28d9" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="TransportCall">
<circle cx="378" cy="264" r="13" fill="#f5f3ff" stroke="#1e293b" stroke-width="1.6" />
<rect x="371.6" y="260.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.5" />
<path d="M 377.2 264 L 384.2 264 M 380.8 260.4 L 384.2 264 L 380.8 267.6"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</g>
<text x="480" y="286"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="13"
font-weight="600"
fill="#0f172a">Check User Data</text>
<rect x="624" y="242" rx="16" ry="16" width="208" height="88"
fill="#f7fbff" stroke="#2563eb" stroke-width="3.5" filter="url(#shadow)" />
<rect x="672" y="246" rx="12" ry="12" width="156" height="80"
fill="#eaf2ff" stroke="none" />
<rect x="626" y="244" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="626.5" y="244.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#2563eb" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="628" y="246" rx="12" ry="12" width="40" height="80"
fill="#bfdbfe" stroke="none" />
<path d="M 672 246 L 672 326"
fill="none" stroke="#2563eb" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="SetState">
<circle cx="646" cy="264" r="13" fill="#eff6ff" stroke="#1e293b" stroke-width="1.6" />
<circle cx="640.2" cy="260" r="1.15" fill="#0f172a" />
<path d="M 642.6 260 L 651.8 260"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="640.2" cy="264" r="1.15" fill="#0f172a" />
<path d="M 642.6 264 L 651.8 264"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="640.2" cy="268" r="1.15" fill="#0f172a" />
<path d="M 642.6 268 L 651.8 268"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
</g>
<text x="748" y="286"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="12"
font-weight="600"
fill="#0f172a">Set isConsistent</text>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -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": []
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1,179 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1328" height="480" viewBox="0 0 1328 480">
<defs>
<marker id="arrow" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#51606f" />
</marker>
<marker id="arrow-failure" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#dc2626" />
</marker>
<marker id="arrow-timeout" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#d97706" />
</marker>
<marker id="arrow-repeat" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#2563eb" />
</marker>
<marker id="arrow-success" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#15803d" />
</marker>
<marker id="arrow-default-muted" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#475569" />
</marker>
<marker id="arrow-missing-condition" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#b91c1c" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
<feOffset in="blur" dx="0" dy="3" result="offsetBlur"/>
<feFlood flood-color="#0f172a" flood-opacity="0.16" result="shadowColor"/>
<feComposite in="shadowColor" in2="offsetBlur" operator="in" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect x="0" y="0" width="1328" height="480" fill="#f4f7fb" />
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">UserDataCheckConsistency [ElkSharp]</text>
<g>
<rect x="24" y="34" rx="14" ry="14" width="1260" height="160" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
<text x="40" y="78" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
</g>
<rect x="118" y="62" rx="8" ry="8" width="28" height="22"
fill="#bbf7d0" stroke="#15803d" stroke-width="1.8" />
<text x="160" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Start</text>
<rect x="211.5" y="62" rx="8" ry="8" width="28" height="22"
fill="#fecaca" stroke="#b91c1c" stroke-width="1.8" />
<text x="253.5" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">End</text>
<rect x="290" y="62" rx="8" ry="8" width="28" height="22"
fill="#f7fbff" stroke="#2563eb" stroke-width="1.8" />
<rect x="303" y="64" rx="6" ry="6" width="13" height="18"
fill="#eaf2ff" stroke="none" />
<rect x="292" y="64" rx="5" ry="5" width="9" height="18"
fill="#bfdbfe" stroke="none" />
<text x="332" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Setter</text>
<rect x="391" y="62" rx="8" ry="8" width="28" height="22"
fill="#fdfbff" stroke="#6d28d9" stroke-width="1.8" />
<rect x="404" y="64" rx="6" ry="6" width="13" height="18"
fill="#f5eeff" stroke="none" />
<rect x="393" y="64" rx="5" ry="5" width="9" height="18"
fill="#ddd6fe" stroke="none" />
<text x="433" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Service Call</text>
<text x="40" y="132" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
<circle cx="102" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="118" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">set state</text>
<circle cx="96.2" cy="124" r="1.15" fill="#0f172a" />
<path d="M 98.6 124 L 107.8 124"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="128" r="1.15" fill="#0f172a" />
<path d="M 98.6 128 L 107.8 128"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="132" r="1.15" fill="#0f172a" />
<path d="M 98.6 132 L 107.8 132"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="215" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="231" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">service call</text>
<rect x="208.6" y="124.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.3" />
<path d="M 214.2 128 L 221.2 128 M 217.8 124.4 L 221.2 128 L 217.8 131.6"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M 296,286 L 351.2,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 559.2,286 L 614.4,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 822.4,286 L 877.6,286"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<rect x="32" y="220" rx="40" ry="40" width="264" height="132"
fill="#bbf7d0" stroke="#15803d" stroke-width="3" filter="url(#shadow)" />
<circle cx="74" cy="286" r="26" fill="#15803d" />
<polygon points="67,275 67,297 84,286"
fill="#ffffff" />
<text x="174.56" y="291"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">Start</text>
<rect x="351.2" y="242" rx="16" ry="16" width="208" height="88"
fill="#fdfbff" stroke="#6d28d9" stroke-width="3.5" filter="url(#shadow)" />
<rect x="399.2" y="246" rx="12" ry="12" width="156" height="80"
fill="#f5eeff" stroke="none" />
<rect x="353.2" y="244" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="353.7" y="244.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#6d28d9" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="355.2" y="246" rx="12" ry="12" width="40" height="80"
fill="#ddd6fe" stroke="none" />
<path d="M 399.2 246 L 399.2 326"
fill="none" stroke="#6d28d9" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="TransportCall">
<circle cx="373.2" cy="264" r="13" fill="#f5f3ff" stroke="#1e293b" stroke-width="1.6" />
<rect x="366.8" y="260.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.5" />
<path d="M 372.4 264 L 379.4 264 M 376 260.4 L 379.4 264 L 376 267.6"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</g>
<text x="475.2" y="286"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="13"
font-weight="600"
fill="#0f172a">Check User Data</text>
<rect x="614.4" y="242" rx="16" ry="16" width="208" height="88"
fill="#f7fbff" stroke="#2563eb" stroke-width="3.5" filter="url(#shadow)" />
<rect x="662.4" y="246" rx="12" ry="12" width="156" height="80"
fill="#eaf2ff" stroke="none" />
<rect x="616.4" y="244" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="616.9" y="244.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#2563eb" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="618.4" y="246" rx="12" ry="12" width="40" height="80"
fill="#bfdbfe" stroke="none" />
<path d="M 662.4 246 L 662.4 326"
fill="none" stroke="#2563eb" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="SetState">
<circle cx="636.4" cy="264" r="13" fill="#eff6ff" stroke="#1e293b" stroke-width="1.6" />
<circle cx="630.6" cy="260" r="1.15" fill="#0f172a" />
<path d="M 633 260 L 642.2 260"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="630.6" cy="264" r="1.15" fill="#0f172a" />
<path d="M 633 264 L 642.2 264"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="630.6" cy="268" r="1.15" fill="#0f172a" />
<path d="M 633 268 L 642.2 268"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
</g>
<text x="738.4" y="286"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="12"
font-weight="600"
fill="#0f172a">Set isConsistent</text>
<rect x="877.6" y="220" rx="40" ry="40" width="264" height="132"
fill="#fecaca" stroke="#b91c1c" stroke-width="3" filter="url(#shadow)" />
<circle cx="919.6" cy="286" r="26" fill="#ffffff" stroke="#b91c1c" stroke-width="3" />
<rect x="910.6" y="277" width="18" height="18" rx="3" ry="3" fill="#b91c1c" />
<rect x="886.6" y="229" rx="34" ry="34" width="246" height="114"
fill="none" stroke="#b91c1c" stroke-width="2.5" />
<text x="1020.16" y="291"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">End</text>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -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": []
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1,179 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1362" height="612" viewBox="0 0 1362 612">
<defs>
<marker id="arrow" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#51606f" />
</marker>
<marker id="arrow-failure" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#dc2626" />
</marker>
<marker id="arrow-timeout" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#d97706" />
</marker>
<marker id="arrow-repeat" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#2563eb" />
</marker>
<marker id="arrow-success" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#15803d" />
</marker>
<marker id="arrow-default-muted" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#475569" />
</marker>
<marker id="arrow-missing-condition" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M 0 0 L 5 2.5 L 0 5 z" fill="#b91c1c" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="160%" height="160%">
<feGaussianBlur in="SourceAlpha" stdDeviation="3" result="blur"/>
<feOffset in="blur" dx="0" dy="3" result="offsetBlur"/>
<feFlood flood-color="#0f172a" flood-opacity="0.16" result="shadowColor"/>
<feComposite in="shadowColor" in2="offsetBlur" operator="in" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect x="0" y="0" width="1362" height="612" fill="#f4f7fb" />
<text x="24" y="24" font-family="'Segoe UI', sans-serif" font-size="16" font-weight="700" fill="#0f172a">UserDataCheckConsistency [Msagl]</text>
<g>
<rect x="24" y="34" rx="14" ry="14" width="1260" height="160" fill="#ffffff" fill-opacity="0.97" stroke="#cbd5e1" stroke-width="1" />
<text x="40" y="56" font-family="'Segoe UI', sans-serif" font-size="12" font-weight="800" fill="#334155">Legend</text>
<text x="40" y="78" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Node Shapes:</text>
</g>
<rect x="118" y="62" rx="8" ry="8" width="28" height="22"
fill="#bbf7d0" stroke="#15803d" stroke-width="1.8" />
<text x="160" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Start</text>
<rect x="211.5" y="62" rx="8" ry="8" width="28" height="22"
fill="#fecaca" stroke="#b91c1c" stroke-width="1.8" />
<text x="253.5" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">End</text>
<rect x="290" y="62" rx="8" ry="8" width="28" height="22"
fill="#f7fbff" stroke="#2563eb" stroke-width="1.8" />
<rect x="303" y="64" rx="6" ry="6" width="13" height="18"
fill="#eaf2ff" stroke="none" />
<rect x="292" y="64" rx="5" ry="5" width="9" height="18"
fill="#bfdbfe" stroke="none" />
<text x="332" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Setter</text>
<rect x="391" y="62" rx="8" ry="8" width="28" height="22"
fill="#fdfbff" stroke="#6d28d9" stroke-width="1.8" />
<rect x="404" y="64" rx="6" ry="6" width="13" height="18"
fill="#f5eeff" stroke="none" />
<rect x="393" y="64" rx="5" ry="5" width="9" height="18"
fill="#ddd6fe" stroke="none" />
<text x="433" y="77" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">Service Call</text>
<text x="40" y="132" font-family="'Segoe UI', sans-serif" font-size="11" font-weight="700" fill="#334155">Badges:</text>
<circle cx="102" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="118" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">set state</text>
<circle cx="96.2" cy="124" r="1.15" fill="#0f172a" />
<path d="M 98.6 124 L 107.8 124"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="128" r="1.15" fill="#0f172a" />
<path d="M 98.6 128 L 107.8 128"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="96.2" cy="132" r="1.15" fill="#0f172a" />
<path d="M 98.6 132 L 107.8 132"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" />
<circle cx="215" cy="128" r="10.5" fill="#ffffff" stroke="#64748b" stroke-width="1.2" />
<text x="231" y="132" font-family="'Segoe UI', sans-serif" font-size="11" fill="#334155">service call</text>
<rect x="208.6" y="124.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.3" />
<path d="M 214.2 128 L 221.2 128 M 217.8 124.4 L 221.2 128 L 217.8 131.6"
fill="none" stroke="#0f172a" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
<path d="M 534,352 L 564,352"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 772,352 L 802,352"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<path d="M 296,352 L 326,352"
fill="none"
stroke="#64748b"
stroke-width="1.95"
stroke-opacity="1"
stroke-linecap="round"
stroke-linejoin="round" marker-end="url(#arrow)" />
<rect x="802" y="220" rx="40" ry="40" width="528" height="264"
fill="#fecaca" stroke="#b91c1c" stroke-width="3" filter="url(#shadow)" />
<circle cx="844" cy="352" r="26" fill="#ffffff" stroke="#b91c1c" stroke-width="3" />
<rect x="835" y="343" width="18" height="18" rx="3" ry="3" fill="#b91c1c" />
<rect x="811" y="229" rx="34" ry="34" width="510" height="246"
fill="none" stroke="#b91c1c" stroke-width="2.5" />
<text x="1087.12" y="357"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">End</text>
<rect x="326" y="308" rx="16" ry="16" width="208" height="88"
fill="#fdfbff" stroke="#6d28d9" stroke-width="3.5" filter="url(#shadow)" />
<rect x="374" y="312" rx="12" ry="12" width="156" height="80"
fill="#f5eeff" stroke="none" />
<rect x="328" y="310" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="328.5" y="310.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#6d28d9" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="330" y="312" rx="12" ry="12" width="40" height="80"
fill="#ddd6fe" stroke="none" />
<path d="M 374 312 L 374 392"
fill="none" stroke="#6d28d9" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="TransportCall">
<circle cx="348" cy="330" r="13" fill="#f5f3ff" stroke="#1e293b" stroke-width="1.6" />
<rect x="341.6" y="326.2" rx="1.2" ry="1.2" width="4.8" height="7.6"
fill="none" stroke="#0f172a" stroke-width="1.5" />
<path d="M 347.2 330 L 354.2 330 M 350.8 326.4 L 354.2 330 L 350.8 333.6"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</g>
<text x="450" y="352"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="13"
font-weight="600"
fill="#0f172a">Check User Data</text>
<rect x="564" y="308" rx="16" ry="16" width="208" height="88"
fill="#f7fbff" stroke="#2563eb" stroke-width="3.5" filter="url(#shadow)" />
<rect x="612" y="312" rx="12" ry="12" width="156" height="80"
fill="#eaf2ff" stroke="none" />
<rect x="566" y="310" rx="14" ry="14" width="204" height="84"
fill="none" stroke="#ffffff" stroke-opacity="0.46" stroke-width="1.2" />
<rect x="566.5" y="310.5" rx="13.5" ry="13.5" width="203" height="83"
fill="none" stroke="#2563eb" stroke-opacity="0.18" stroke-width="1.2" />
<rect x="568" y="312" rx="12" ry="12" width="40" height="80"
fill="#bfdbfe" stroke="none" />
<path d="M 612 312 L 612 392"
fill="none" stroke="#2563eb" stroke-opacity="0.42" stroke-width="2.5" />
<g data-badge-kind="SetState">
<circle cx="586" cy="330" r="13" fill="#eff6ff" stroke="#1e293b" stroke-width="1.6" />
<circle cx="580.2" cy="326" r="1.15" fill="#0f172a" />
<path d="M 582.6 326 L 591.8 326"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="580.2" cy="330" r="1.15" fill="#0f172a" />
<path d="M 582.6 330 L 591.8 330"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
<circle cx="580.2" cy="334" r="1.15" fill="#0f172a" />
<path d="M 582.6 334 L 591.8 334"
fill="none" stroke="#0f172a" stroke-width="1.5" stroke-linecap="round" />
</g>
<text x="688" y="352"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="12"
font-weight="600"
fill="#0f172a">Set isConsistent</text>
<rect x="32" y="220" rx="40" ry="40" width="264" height="264"
fill="#bbf7d0" stroke="#15803d" stroke-width="3" filter="url(#shadow)" />
<circle cx="74" cy="352" r="26" fill="#15803d" />
<polygon points="67,341 67,363 84,352"
fill="#ffffff" />
<text x="174.56" y="357"
text-anchor="middle"
font-family="'Segoe UI', sans-serif"
font-size="15"
font-weight="700"
fill="#0f172a">Start</text>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB