diff --git a/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md b/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md new file mode 100644 index 000000000..54da573e3 --- /dev/null +++ b/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md @@ -0,0 +1,67 @@ +# Sprint 20260322-001 — Split Create Wizard into Version / Hotfix / Release + +## Topic & Scope +- Split the monolithic "Create Release" wizard into 3 distinct wizards matching DevOps concepts. +- **Version**: artifact definition (name, version, images, scripts). No deployment info. +- **Hotfix**: single emergency package (one image + tag). Minimal. +- **Release**: deployment plan. Picks a Version or Hotfix, then configures WHERE (regions, envs) and HOW (stages, strategy). If hotfix → no stages, just target env. If version → requires promotion stages. +- If Version/Hotfix doesn't exist during Release creation → inline creation within the same page. +- Working directory: `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/` + +## Dependencies & Concurrency +- Tasks are sequential (shared component first, then 3 wizards, then routes). + +## Delivery Tracker + +### TASK-001 - Create Version wizard +Status: TODO +Owners: FE +Task description: +- New component: `create-version.component.ts` +- Steps: 1) Name + Version + Description 2) Components (images + scripts) with autocomplete 3) Review & Seal +- Autocomplete: name defaults to last used or generic, version auto-increments +- Component search uses existing registry API +- No regions, no stages, no strategy, no deployment config +- Route: `/releases/versions/new` + +### TASK-002 - Create Hotfix wizard +Status: TODO +Owners: FE +Task description: +- New component: `create-hotfix.component.ts` +- Single step or 2 steps: 1) Pick one Docker image + tag 2) Confirm +- No name (derives from image), no version (uses digest) +- Minimal, fast-track flow +- Route: `/releases/hotfixes/new` + +### TASK-003 - Create Release wizard +Status: TODO +Owners: FE +Task description: +- New component: `create-release.component.ts` (replaces old wizard) +- Steps: 1) Pick Version or Hotfix (with inline create option) 2) Target (regions, envs, stages) 3) Strategy config 4) Review & Create +- If Version selected → stages required (Dev → Stage → Prod) +- If Hotfix selected → just target env, no stages +- Inline create: if version/hotfix doesn't exist, expand an inline creation form +- Route: `/releases/new` + +### TASK-004 - Update routes and navigation +Status: TODO +Owners: FE +Task description: +- `/releases/versions/new` → CreateVersionComponent +- `/releases/hotfixes/new` → CreateHotfixComponent +- `/releases/new` → CreateReleaseComponent +- Update sidebar "New Version" page action to point to `/releases/versions/new` +- Update pipeline page "New Release" to point to `/releases/new` +- Remove old `create-release.component.ts` or rename + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-22 | Sprint created. | Planning | + +## Decisions & Risks +- Old create-release component will be replaced, not refactored (too intertwined). +- Inline version/hotfix creation within release wizard is complex — may use dialog or expandable section. +- Custom scripts support deferred to follow-up sprint. diff --git a/src/Web/StellaOps.Web/AGENTS.md b/src/Web/StellaOps.Web/AGENTS.md index 3ba74ccc3..8014ad144 100644 --- a/src/Web/StellaOps.Web/AGENTS.md +++ b/src/Web/StellaOps.Web/AGENTS.md @@ -162,10 +162,114 @@ const MY_TABS: readonly StellaPageTab[] = [ - Do NOT duplicate the tab bar styling — the component owns all tab CSS - The component provides: keyboard navigation, ARIA roles, active background, bottom border, icon opacity transitions, panel border-radius, enter animation -## Table Styling Convention -All HTML tables must use the `stella-table` CSS class for consistent styling. -Never define custom table padding, borders, or header styles inline. -Use the shared data-table component when possible, or the stella-table class for simple static tables. +## Data Table Convention (MANDATORY) + +All data tables **must** use `` for interactive tables or `.stella-table` CSS classes for simple static tables. +Do NOT create custom table styling, sort headers, or selection checkboxes. + +**Components:** +- `shared/components/data-table/data-table.component.ts` — interactive table (sorting, selection, templates) +- `shared/components/pagination/pagination.component.ts` — page navigation +- `src/styles/_tables.scss` — global `.stella-table` CSS classes + +**Usage (interactive table with sorting + pagination):** +```html + +
+ +
+
+``` + +**Column definition with custom cell templates:** +```typescript +readonly columns: TableColumn[] = [ + { key: 'name', label: 'Name', sortable: true }, + { key: 'status', label: 'Status', sortable: true, template: statusTemplate }, + { key: 'actions', label: 'Actions', sortable: false, align: 'right' }, +]; +``` + +**Usage (simple static table):** +```html + + + +
NameValue
......
+``` + +**Design rules:** +- Pagination must be right-aligned below the table +- Page size options must include 5: `[5, 10, 25, 50]` +- All list/catalog pages must be sortable by at least the primary column +- Use `stella-table--bordered` for tables that are the main page content +- Use `stella-table--striped` for tables with more than 5 rows +- Loading state must show skeleton rows, not a spinner + +## Filter Convention (MANDATORY) + +Three filter component types: +1. `stella-filter-chip` — Single-select dropdown (Region, Env, Stage, Type, Gate, Risk) +2. `stella-filter-multi` — Multi-select with checkboxes + All/None (Severity, Status) +3. `stella-view-mode-switcher` — Binary toggle (Operator/Auditor, view modes) + +**Components:** +- `shared/components/stella-filter-chip/stella-filter-chip.component.ts` — single-select +- `shared/components/stella-filter-multi/stella-filter-multi.component.ts` — multi-select +- `shared/components/view-mode-switcher/view-mode-switcher.component.ts` — binary toggle +- `shared/ui/filter-bar/filter-bar.component.ts` — combined search + dropdown filters + active chips + +**Usage (filter chips for page-level filters):** +```html + + +``` + +**Usage (filter bar with search + dropdowns):** +```html + +``` + +**Design rules:** +- Global filters (Region, Env, Window, Stage, Operator/Auditor) live in the header bar only +- Pages must NOT duplicate global filters — read from PlatformContextStore +- Page-level filters use `stella-filter-chip` or `stella-filter-multi` inline above the table +- Use `app-filter-bar` when search + multiple dropdowns + active chips are needed +- Compact inline chips: 28px height, no border default, dropdown on click ## Filter Convention (MANDATORY) diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts index 953c4508d..20a658f0a 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts @@ -2,7 +2,7 @@ * Approval Queue Component * Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui */ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal, computed } from '@angular/core'; import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -16,11 +16,13 @@ import { formatTimeRemaining, } from '../../../../core/api/approval.models'; import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { PaginationComponent, type PageChangeEvent } from '../../../../shared/components/pagination/pagination.component'; +import { StellaFilterChipComponent, type FilterChipOption } from '../../../../shared/components/stella-filter-chip/stella-filter-chip.component'; import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-approval-queue', - imports: [RouterLink, FormsModule, LoadingStateComponent], + imports: [RouterLink, FormsModule, LoadingStateComponent, PaginationComponent, StellaFilterChipComponent], template: `
- -
- - - - -
- -
-
- -
- @for (urgency of urgencyOptions; track urgency.value) { - - } -
+
+ + +
@@ -113,8 +78,8 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service'; @if (!store.loading() && !store.error()) { -
- +
+
- @for (approval of store.filteredApprovals(); track approval.id) { + @for (approval of pagedApprovals(); track approval.id) { @@ -213,6 +175,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
@@ -134,7 +99,7 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';

No approval requests found

- @if (hasActiveFilters()) { - - }
+ +
+ +
} @@ -282,106 +254,17 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service'; color: var(--color-text-secondary); } - .status-switcher { - display: inline-flex; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - overflow: hidden; - background: var(--color-surface-secondary); - margin-bottom: 0.5rem; - } - - .status-segment { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.375rem 0.75rem; - border: none; - background: var(--color-surface-secondary); - color: var(--color-text-muted); - font-size: var(--font-size-sm, 0.75rem); - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: background-color 150ms ease, color 150ms ease; - white-space: nowrap; - } - - .status-segment:not(:last-child) { - border-right: 1px solid var(--color-border-primary); - } - - .status-segment:hover:not(.status-segment--active) { - background: var(--color-surface-tertiary); - color: var(--color-text-secondary); - } - - .status-segment:focus-visible { - outline: 2px solid var(--color-brand-primary); - outline-offset: -2px; - } - - .status-segment--active { - background: var(--color-text-heading); - color: var(--color-surface-primary); - font-weight: var(--font-weight-semibold); - } - - .status-segment--active:hover { - background: var(--color-text-primary); - } - - .status-segment__count { - font-size: 0.625rem; - font-weight: var(--font-weight-bold); - opacity: 0.7; - } - - .filters-bar { - display: flex; - gap: 24px; - margin-bottom: 16px; - padding: 16px; - background: var(--color-surface-primary); - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-primary); - } - - .filter-group { - display: flex; - align-items: center; - gap: 12px; - } - - .filter-group label { - font-size: var(--font-size-base); - font-weight: var(--font-weight-medium); - color: var(--color-text-primary); - } - - .filter-chips { - display: flex; - gap: 8px; - } - - .filter-chip { - padding: 6px 12px; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-2xl); - background: var(--color-surface-primary); - font-size: var(--font-size-sm); - cursor: pointer; - transition: all 0.2s; - } - - .filter-chip:hover { - border-color: var(--color-status-info); - } - - .filter-chip.active { - background: var(--color-status-info); - color: var(--color-surface-primary); - border-color: var(--color-status-info); + .filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } + .filter-search { position: relative; flex: 0 1 240px; min-width: 160px; } + .filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; } + .filter-search__input { + width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); + background: transparent; color: var(--color-text-primary); + font-size: 0.75rem; outline: none; transition: border-color 150ms ease; } + .filter-search__input:focus { border-color: var(--color-brand-primary); } + .filter-search__input::placeholder { color: var(--color-text-muted); } .batch-actions { display: flex; @@ -398,48 +281,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service'; color: var(--color-status-warning-text); } - .approval-table { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); + .approval-table-wrap { border-radius: var(--radius-lg); overflow: hidden; } - .approval-table table { - width: 100%; - border-collapse: collapse; - } - - .approval-table th { - text-align: left; - padding: 12px 16px; - background: var(--color-surface-primary); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - text-transform: uppercase; - border-bottom: 1px solid var(--color-border-primary); - } - - .approval-table td { - padding: 16px; - border-bottom: 1px solid var(--color-surface-secondary); - vertical-align: middle; - } - - .approval-table tr:last-child td { - border-bottom: none; - } - - .approval-table tr:hover { - background: var(--color-surface-primary); - } - - .approval-table tr.selected { + .approval-table-wrap tr.selected { background: var(--color-status-info-bg); } - .approval-table tr.expiring-soon { + .approval-table-wrap tr.expiring-soon { background: var(--color-status-error-bg); } @@ -667,6 +518,11 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service'; gap: 12px; margin-top: 20px; } + + @media (max-width: 768px) { + .filters { gap: 0.375rem; } + .filter-search { flex: 1 1 100%; } + } `] }) export class ApprovalQueueComponent implements OnInit { @@ -681,13 +537,56 @@ export class ApprovalQueueComponent implements OnInit { readonly getUrgencyColor = getUrgencyColor; readonly formatTimeRemaining = formatTimeRemaining; - readonly urgencyOptions = [ - { label: 'Low', value: 'low' as ApprovalUrgency }, - { label: 'Normal', value: 'normal' as ApprovalUrgency }, - { label: 'High', value: 'high' as ApprovalUrgency }, - { label: 'Critical', value: 'critical' as ApprovalUrgency }, + // ── Filter-chip options ────────────────────────────────────────────── + + readonly statusOptions: FilterChipOption[] = [ + { id: '', label: 'All Status' }, + { id: 'pending', label: 'Pending' }, + { id: 'approved', label: 'Approved' }, + { id: 'rejected', label: 'Rejected' }, ]; + readonly urgencyChipOptions: FilterChipOption[] = [ + { id: '', label: 'All Urgency' }, + { id: 'low', label: 'Low' }, + { id: 'normal', label: 'Normal' }, + { id: 'high', label: 'High' }, + { id: 'critical', label: 'Critical' }, + ]; + + // ── State ────────────────────────────────────────────────────────────── + + readonly searchQuery = signal(''); + readonly currentPage = signal(1); + readonly pageSize = signal(10); + + readonly statusChipValue = computed(() => { + const f = this.store.statusFilter(); + return f.length === 1 ? f[0] : ''; + }); + + readonly urgencyChipValue = computed(() => { + const f = this.store.urgencyFilter(); + return f.length === 1 ? f[0] : ''; + }); + + readonly pagedApprovals = computed(() => { + let all = this.store.filteredApprovals(); + const q = this.searchQuery().toLowerCase().trim(); + if (q) { + all = all.filter( + (a) => + a.releaseName.toLowerCase().includes(q) || + a.releaseVersion.toLowerCase().includes(q) || + a.sourceEnvironment.toLowerCase().includes(q) || + a.targetEnvironment.toLowerCase().includes(q) || + a.requestedBy.toLowerCase().includes(q), + ); + } + const start = (this.currentPage() - 1) * this.pageSize(); + return all.slice(start, start + this.pageSize()); + }); + readonly showBatchApproveDialog = signal(false); readonly showBatchRejectDialog = signal(false); batchComment = ''; @@ -696,42 +595,21 @@ export class ApprovalQueueComponent implements OnInit { this.store.loadApprovals(); } - filterByStatus(statuses: ApprovalStatus[]): void { - this.store.setStatusFilter(statuses); + onStatusChipChange(value: string): void { + this.store.setStatusFilter(value ? [value as ApprovalStatus] : []); + this.currentPage.set(1); this.store.loadApprovals(); } - isStatusActive(statuses: ApprovalStatus[]): boolean { - const current = this.store.statusFilter(); - if (statuses.length === 0 && current.length === 0) return true; - return JSON.stringify(current.sort()) === JSON.stringify(statuses.sort()); - } - - toggleUrgencyFilter(urgency: ApprovalUrgency): void { - const current = [...this.store.urgencyFilter()]; - const index = current.indexOf(urgency); - if (index >= 0) { - current.splice(index, 1); - } else { - current.push(urgency); - } - this.store.setUrgencyFilter(current); + onUrgencyChipChange(value: string): void { + this.store.setUrgencyFilter(value ? [value as ApprovalUrgency] : []); + this.currentPage.set(1); this.store.loadApprovals(); } - isUrgencySelected(urgency: ApprovalUrgency): boolean { - return this.store.urgencyFilter().includes(urgency); - } - - hasActiveFilters(): boolean { - return this.store.urgencyFilter().length > 0 || - this.store.statusFilter().length !== 1 || - this.store.statusFilter()[0] !== 'pending'; - } - - clearFilters(): void { - this.store.clearFilters(); - this.store.loadApprovals(); + onPageChange(event: PageChangeEvent): void { + this.currentPage.set(event.page); + this.pageSize.set(event.pageSize); } isSelected(id: string): boolean { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts new file mode 100644 index 000000000..62e91d473 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts @@ -0,0 +1,1608 @@ +import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SlicePipe } from '@angular/common'; +import { Router } from '@angular/router'; + +import { ReleaseManagementStore } from '../release.store'; +import { + formatDigest, + type DeploymentStrategy, + type RegistryImage, + getStrategyLabel, +} from '../../../../core/api/release-management.models'; +import { PlatformContextStore } from '../../../../core/context/platform-context.store'; + +/* ─── Local mock types ─── */ +interface MockVersion { + id: string; + name: string; + version: string; + componentCount: number; + sealedAt: string; +} + +interface MockHotfix { + id: string; + name: string; + image: string; + tag: string; + sealedAt: string; +} + +interface PromotionStage { + name: string; + environmentId: string; +} + +/* ─── Mock data ─── */ +const MOCK_VERSIONS: MockVersion[] = [ + { id: 'v1', name: 'api-gateway', version: 'v2.14.0', componentCount: 3, sealedAt: '2026-03-18T10:30:00Z' }, + { id: 'v2', name: 'payment-svc', version: 'v3.2.1', componentCount: 2, sealedAt: '2026-03-17T14:15:00Z' }, + { id: 'v3', name: 'auth-service', version: 'v1.8.0', componentCount: 1, sealedAt: '2026-03-16T09:00:00Z' }, + { id: 'v4', name: 'checkout-api', version: 'v4.0.0-rc1', componentCount: 4, sealedAt: '2026-03-15T16:45:00Z' }, + { id: 'v5', name: 'notification-svc', version: 'v2.1.3', componentCount: 1, sealedAt: '2026-03-14T11:20:00Z' }, + { id: 'v6', name: 'inventory-api', version: 'v5.0.0', componentCount: 5, sealedAt: '2026-03-13T08:00:00Z' }, +]; + +const MOCK_HOTFIXES: MockHotfix[] = [ + { id: 'h1', name: 'api-gateway-hotfix', image: 'registry.local/api-gateway', tag: 'v2.13.1-hf.20260320', sealedAt: '2026-03-20T08:00:00Z' }, + { id: 'h2', name: 'payment-svc-hotfix', image: 'registry.local/payment-svc', tag: 'v3.2.0-hf.20260319', sealedAt: '2026-03-19T12:30:00Z' }, + { id: 'h3', name: 'auth-service-hotfix', image: 'registry.local/auth-service', tag: 'v1.7.4-hf.20260318', sealedAt: '2026-03-18T15:00:00Z' }, +]; + +@Component({ + selector: 'app-create-deployment', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, SlicePipe], + template: ` +
+
+
+

Create Deployment

+

Build a deployment plan: pick a package, choose targets, and configure how to deploy.

+
+ + + Back to Releases + +
+ + + + + +
+ @switch (step()) { + + + @case (1) { +
+
+

Select Package

+

Choose a sealed Version or Hotfix to deploy, or create one inline.

+
+ + +
+ Package type +
+ + +
+
+ + + @if (packageType() === 'version') { + + + @if (!selectedVersion() && getFilteredVersions().length > 0) { +
+ @for (v of getFilteredVersions(); track v.id) { + + } +
+ } + + @if (selectedVersion(); as v) { +
+
+
+

{{ v.name }}

+ {{ v.version }} · {{ v.componentCount }} component(s) +
+ +
+
+
Sealed
{{ v.sealedAt | slice:0:10 }}
+
Components
{{ v.componentCount }}
+
+
+ } + + @if (!selectedVersion() && !showInlineVersion()) { + + } + + @if (showInlineVersion()) { +
+
+

New Version (inline)

+ +
+
+ + +
+ + + + + @if (store.searchResults().length > 0 && !inlineSelectedImage()) { +
+ @for (img of store.searchResults(); track img.repository) { + + } +
+ } + + @if (inlineSelectedImage(); as img) { +
+ @for (d of img.digests; track d.digest) { + + } +
+ + } + + @if (inlineComponents.length > 0) { +
+ @for (c of inlineComponents; track c.name + c.digest; let i = $index) { +
+ {{ c.name }} + {{ fmtDigest(c.digest) }} + +
+ } +
+ } + + +
+ } + } + + + @if (packageType() === 'hotfix') { + + + @if (!selectedHotfix() && getFilteredHotfixes().length > 0) { +
+ @for (h of getFilteredHotfixes(); track h.id) { + + } +
+ } + + @if (selectedHotfix(); as h) { +
+
+
+ HOTFIX +

{{ h.name }}

+ {{ h.tag }} +
+ +
+
+
Image
{{ h.image }}
+
Sealed
{{ h.sealedAt | slice:0:10 }}
+
+
+ } + + @if (!selectedHotfix() && !showInlineHotfix()) { + + } + + @if (showInlineHotfix()) { +
+
+

New Hotfix (inline)

+ +
+ + + + @if (store.searchResults().length > 0 && !inlineHotfixImage()) { +
+ @for (img of store.searchResults(); track img.repository) { + + } +
+ } + + @if (inlineHotfixImage(); as img) { +
+ @for (d of img.digests; track d.digest) { + + } +
+ + + } +
+ } + } +
+ } + + + @case (2) { +
+
+

Deployment Targets

+

+ @if (packageType() === 'version') { + Select regions, environments, and configure promotion stages. + } @else { + Select the target region and environment for this hotfix. + } +

+
+ + +
+ {{ packageType() === 'hotfix' ? 'Region' : 'Regions' }} +
+ @for (region of platformCtx.regions(); track region.regionId) { + + } + @if (platformCtx.regions().length === 0) { + No regions configured in platform context + } +
+
+ + +
+ {{ packageType() === 'hotfix' ? 'Target environment' : 'Environments' }} +
+ @for (env of getFilteredEnvironments(); track env.environmentId) { + + } + @if (getFilteredEnvironments().length === 0) { + + {{ targetRegions().length > 0 ? 'No environments found for selected regions' : 'Select a region to see available environments' }} + + } +
+
+ + + @if (packageType() === 'version') { +
+
+ Promotion stages * + +
+
+ @for (stage of promotionStages; track $index; let i = $index) { +
+ {{ i + 1 }} + + + + @if (promotionStages.length > 1) { + + } +
+ } +
+
+ } + + + @if (packageType() === 'hotfix') { +
+ + Hotfix will deploy directly to the selected environment, bypassing promotion stages. +
+ } + + + @if (targetRegions().length > 0 || targetEnvironments().length > 0) { +
+ Selected targets +
+ @for (regionId of targetRegions(); track regionId) { + {{ regionDisplayName(regionId) }} + } + @for (envId of targetEnvironments(); track envId) { + {{ environmentDisplayName(envId) }} + } +
+
+ } +
+ } + + + @case (3) { +
+
+

Deployment Strategy

+

Configure how the deployment will roll out to target environments.

+
+ + + + +
+ + + Strategy Configuration + +
+ @switch (deploymentStrategy) { + @case ('rolling') { +
+ + +
+
+ + +
+
+ + +
+ } + @case ('canary') { +
+
+ Canary stages + +
+ @for (stage of strategyConfig.canary.stages; track $index; let i = $index) { +
+ {{ i + 1 }} + + + + @if (strategyConfig.canary.stages.length > 1) { + + } +
+ } +
+
+ + +
+ } + @case ('blue_green') { +
+ + +
+
+ + +
+ } + @case ('recreate') { +
+ + +
+ + } + @case ('ab-release') { + + + @if (strategyConfig.ab.subType === 'target-group') { +
+
+ Rollout stages + +
+ @for (stage of strategyConfig.ab.targetGroupStages; track $index; let i = $index) { +
+ {{ i + 1 }} + + + + + @if (strategyConfig.ab.targetGroupStages.length > 1) { + + } +
+ } +
+ } @else { + +
+ + +
+ } + } + } +
+
+
+ } + + + @case (4) { +
+
+

Review & Create

+

Verify all deployment parameters before creating.

+
+ +
+ +
+
+ +

Package

+
+
+
Type
{{ packageType() }}
+ @if (packageType() === 'version' && selectedVersion(); as v) { +
Name
{{ v.name }}
+
Version
{{ v.version }}
+
Components
{{ v.componentCount }}
+ } + @if (packageType() === 'hotfix' && selectedHotfix(); as h) { +
Name
{{ h.name }}
+
Image
{{ h.image }}
+
Tag
{{ h.tag }}
+ } +
+
+ + +
+
+ +

Targets

+
+
+
Regions
{{ getTargetRegionNames().length > 0 ? getTargetRegionNames().join(', ') : 'none selected' }}
+
Environments
{{ getTargetEnvironmentNames().length > 0 ? getTargetEnvironmentNames().join(', ') : 'none selected' }}
+ @if (packageType() === 'version') { +
Stages
{{ promotionStages.length }} stage(s): {{ promotionStageNames() }}
+ } + @if (packageType() === 'hotfix') { +
Mode
Direct deployment (no promotion)
+ } +
+
+ + +
+
+ +

Strategy

+
+
+
Strategy
{{ getStrategyLabel() }}
+ @switch (deploymentStrategy) { + @case ('rolling') { +
Batch size
{{ strategyConfig.rolling.batchSize }} ({{ strategyConfig.rolling.batchSizeType }})
+
Batch delay
{{ strategyConfig.rolling.batchDelay }}s
+
Stabilization
{{ strategyConfig.rolling.stabilizationTime }}s
+
Max failed
{{ strategyConfig.rolling.maxFailedBatches === 0 ? 'fail on first' : strategyConfig.rolling.maxFailedBatches }}
+ } + @case ('canary') { +
Stages
{{ strategyConfig.canary.stages.length }} stage(s)
+
Error threshold
{{ strategyConfig.canary.errorRateThreshold }}%
+
Latency limit
{{ strategyConfig.canary.latencyThreshold }}ms
+ } + @case ('blue_green') { +
Switchover
{{ strategyConfig.blueGreen.switchoverMode }}
+
Warmup
{{ strategyConfig.blueGreen.warmupPeriod }}s
+
Keepalive
{{ strategyConfig.blueGreen.blueKeepalive }}min
+ } + @case ('recreate') { +
Concurrency
{{ strategyConfig.recreate.maxConcurrency === 0 ? 'unlimited' : strategyConfig.recreate.maxConcurrency }}
+
On failure
{{ strategyConfig.recreate.failureBehavior }}
+ } + @case ('ab-release') { +
Sub-type
{{ strategyConfig.ab.subType }}
+ @if (strategyConfig.ab.subType === 'target-group') { +
Stages
{{ strategyConfig.ab.targetGroupStages.length }} stage(s)
+ } @else { +
Routing
{{ strategyConfig.ab.routerBasedConfig.routingStrategy }}
+ } + } + } +
+
+
+ + +
+ } + } +
+ + @if (submitError(); as err) { + + } + +
+ +
+ Step {{ step() }} of 4 + + @if (step() < 4) { + + } @else { + + } +
+
+ `, + styles: [` + .create-deployment { display: grid; gap: 0.75rem; max-width: 820px; margin: 0 auto; } + + /* Header */ + .wizard-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } + .wizard-header h1 { margin: 0; font-size: var(--font-size-xl, 1.25rem); font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight, 1.25); } + .wizard-header__sub { margin: 0.2rem 0 0; color: var(--color-text-secondary); font-size: var(--font-size-sm, 0.75rem); } + .btn-back { + display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-primary); color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); text-decoration: none; white-space: nowrap; + transition: color 140ms ease, border-color 140ms ease; + } + .btn-back:hover { color: var(--color-text-primary); border-color: var(--color-border-secondary); } + + /* Stepper */ + .stepper { display: flex; align-items: center; gap: 0; padding: 0.5rem 0; } + .stepper__line { flex: 1; height: 2px; background: var(--color-border-primary); transition: background 200ms ease; } + .stepper__line.done { background: var(--color-status-success-text); } + .stepper__step { + display: flex; flex-direction: column; align-items: center; gap: 0.35rem; + background: none; border: none; padding: 0 0.25rem; cursor: pointer; transition: opacity 140ms ease; + } + .stepper__step:disabled { cursor: default; opacity: 0.5; } + .stepper__circle { + display: flex; align-items: center; justify-content: center; + width: 32px; height: 32px; border-radius: 50%; + border: 2px solid var(--color-border-primary); background: var(--color-surface-primary); + font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); transition: border-color 140ms ease, background 140ms ease, color 140ms ease; + } + .stepper__step.active .stepper__circle { + border-color: var(--color-brand-primary); background: var(--color-brand-primary); + color: var(--color-btn-primary-text, #fff); box-shadow: 0 0 0 3px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); + } + .stepper__step.done .stepper__circle { border-color: var(--color-status-success-text); background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .stepper__label { font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium); color: var(--color-text-muted); white-space: nowrap; } + .stepper__step.active .stepper__label { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } + .stepper__step.done .stepper__label { color: var(--color-status-success-text); } + + /* Wizard body */ + .wizard-body { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); padding: 1.25rem; } + .step-panel { display: grid; gap: 0.85rem; } + .step-intro { margin-bottom: 0.25rem; } + .step-intro h2 { margin: 0; font-size: var(--font-size-md, 1rem); font-weight: var(--font-weight-semibold); } + .step-intro p { margin: 0.2rem 0 0; font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); line-height: var(--line-height-relaxed, 1.625); } + + /* Form fields */ + .form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } + .field { display: grid; gap: 0.3rem; } + .field__label { font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); color: var(--color-text-primary); } + .field__label abbr { color: var(--color-status-error-text); text-decoration: none; } + .field__hint { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); } + input, select, textarea { + width: 100%; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-secondary); color: var(--color-text-primary); + padding: 0.45rem 0.6rem; font-size: var(--font-size-base, 0.8125rem); font-family: inherit; + transition: border-color 140ms ease, box-shadow 140ms ease; + } + input:focus, select:focus, textarea:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); background: var(--color-surface-primary); } + input::placeholder, textarea::placeholder { color: var(--color-text-muted); } + + /* Type toggle */ + .type-toggle-row { display: flex; align-items: center; gap: 0.75rem; } + .toggle-pair { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); overflow: hidden; } + .toggle-pair__btn { + padding: 0.35rem 0.65rem; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); + border: none; background: var(--color-surface-primary); color: var(--color-text-secondary); cursor: pointer; transition: background 140ms ease, color 140ms ease; + } + .toggle-pair__btn + .toggle-pair__btn { border-left: 1px solid var(--color-border-primary); } + .toggle-pair__btn--active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .toggle-pair__btn:hover:not(.toggle-pair__btn--active) { background: var(--color-surface-elevated); } + + /* Search */ + .search-input-wrap { position: relative; display: flex; align-items: center; } + .search-input-wrap__icon { position: absolute; left: 0.6rem; color: var(--color-text-muted); pointer-events: none; } + .search-input-wrap__input { padding-left: 2rem; } + .search-results { display: grid; gap: 0.3rem; max-height: 200px; overflow: auto; } + .search-results--compact { max-height: 150px; } + .search-item { + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; + display: grid; gap: 0.15rem; text-align: left; cursor: pointer; + background: var(--color-surface-primary); color: var(--color-text-primary); font-size: var(--font-size-sm, 0.75rem); + transition: border-color 140ms ease, background 140ms ease; + } + .search-item:hover { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); } + .search-item span, .search-item code { color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } + .search-item__row { display: flex; align-items: center; gap: 0.5rem; } + .search-item__meta { color: var(--color-text-muted); font-size: var(--font-size-xs, 0.6875rem); } + + /* Selected card */ + .selected-card { + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); border-radius: var(--radius-lg); + padding: 0.75rem; background: var(--color-brand-primary-10, var(--color-surface-subtle)); + } + .selected-card--hotfix { border-color: var(--color-status-warning-border, rgba(245, 158, 11, 0.35)); background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.06)); } + .selected-card__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; } + .selected-card__header h3 { margin: 0; font-size: var(--font-size-base, 0.8125rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } + .selected-card__sub { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } + .selected-card__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; margin: 0; font-size: var(--font-size-sm, 0.75rem); } + .selected-card__dl dt { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } + .selected-card__dl dd { margin: 0; color: var(--color-text-primary); } + .hotfix-badge { + display: inline-block; padding: 0.1rem 0.4rem; margin-bottom: 0.25rem; + font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; border-radius: var(--radius-sm); + background: var(--color-status-warning, #e67e22); color: #fff; + } + + /* Inline create */ + .inline-create { + display: grid; gap: 0.65rem; padding: 0.85rem; + border: 1px dashed var(--color-border-secondary, var(--color-border-primary)); border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + } + .inline-create--hotfix { border-color: var(--color-status-warning-border, rgba(245, 158, 11, 0.35)); } + .inline-create__header { display: flex; justify-content: space-between; align-items: center; } + .inline-create__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } + + /* Digest options */ + .digest-options { display: grid; gap: 0.3rem; } + .digest-option { + display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.4rem 0.6rem; + font-size: var(--font-size-sm, 0.75rem); cursor: pointer; + background: var(--color-surface-primary); color: var(--color-text-primary); transition: border-color 140ms ease, background 140ms ease; + } + .digest-option:hover { border-color: var(--color-brand-primary); } + .digest-option--selected { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); box-shadow: 0 0 0 2px var(--color-focus-ring); } + .digest-option__tag { font-weight: var(--font-weight-semibold); min-width: 70px; } + .digest-option code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } + + /* Inline component list */ + .inline-component-list { display: grid; gap: 0.25rem; } + .inline-component-item { + display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.5rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + font-size: var(--font-size-sm, 0.75rem); + } + .inline-component-item code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-muted); font-size: var(--font-size-xs, 0.6875rem); flex: 1; } + + /* Link button */ + .btn-link { + background: none; border: none; padding: 0; color: var(--color-text-link, var(--color-brand-primary)); + font-size: var(--font-size-sm, 0.75rem); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; + } + .btn-link:hover { opacity: 0.8; } + + /* Target section */ + .target-section { display: grid; gap: 0.35rem; } + .chip-selector { display: flex; flex-wrap: wrap; gap: 0.35rem; } + .chip-toggle { + display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.65rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-full); + background: var(--color-surface-primary); color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); cursor: pointer; + transition: border-color 140ms ease, background 140ms ease, color 140ms ease, box-shadow 140ms ease; + } + .chip-toggle:hover { border-color: var(--color-brand-primary); color: var(--color-text-primary); } + .chip-toggle--active { + border-color: var(--color-brand-primary); background: var(--color-brand-primary); + color: var(--color-btn-primary-text, #fff); box-shadow: 0 0 0 2px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); + } + .chip-toggle--active:hover { color: var(--color-btn-primary-text, #fff); } + .chip-toggle__region-hint { font-size: var(--font-size-xs, 0.6875rem); opacity: 0.7; } + .chip-empty-hint { font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-muted); font-style: italic; padding: 0.25rem 0; } + + /* Promotion stages */ + .stages-header { display: flex; align-items: center; justify-content: space-between; } + .stages-list { display: grid; gap: 0.4rem; } + .stage-row { + display: grid; grid-template-columns: 24px 16px 1fr 1fr auto; gap: 0.5rem; align-items: end; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem; background: var(--color-surface-primary); + } + .stage-row__num { + display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: var(--radius-full); + background: var(--color-surface-secondary); color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-semibold); align-self: center; + } + .stage-row__arrow { color: var(--color-text-muted); align-self: center; } + + /* Warning banner */ + .warning-banner { + display: flex; align-items: center; gap: 0.6rem; padding: 0.65rem 0.85rem; + background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.08)); + border: 1px solid var(--color-status-warning-border, rgba(245, 158, 11, 0.25)); + border-radius: var(--radius-md); font-size: var(--font-size-sm, 0.75rem); + color: var(--color-status-warning-text, #f59e0b); + } + .warning-banner svg { flex-shrink: 0; } + + /* Target summary */ + .target-summary { + display: grid; gap: 0.35rem; padding: 0.65rem 0.75rem; + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); + border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); + } + .target-summary__chips { display: flex; flex-wrap: wrap; gap: 0.3rem; } + .target-chip { + display: inline-flex; align-items: center; padding: 0.2rem 0.5rem; + border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium); + } + .target-chip--region { background: var(--color-status-info-bg, rgba(59, 130, 246, 0.1)); color: var(--color-status-info-text, #3b82f6); border: 1px solid var(--color-status-info-border, rgba(59, 130, 246, 0.25)); } + .target-chip--env { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border, rgba(34, 197, 94, 0.25)); } + + /* Strategy config */ + .strategy-config { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-secondary); } + .strategy-config__summary { + display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.75rem; + font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); cursor: pointer; user-select: none; list-style: none; + } + .strategy-config__summary::-webkit-details-marker { display: none; } + .strategy-config__summary::before { + content: ''; display: inline-block; width: 6px; height: 6px; + border-right: 1.5px solid var(--color-text-muted); border-bottom: 1.5px solid var(--color-text-muted); + transform: rotate(-45deg); transition: transform 0.15s ease; flex-shrink: 0; + } + details[open] > .strategy-config__summary::before { transform: rotate(45deg); } + .strategy-config__summary svg { color: var(--color-text-secondary); } + .strategy-config__body { display: grid; gap: 0.75rem; padding: 0 0.75rem 0.75rem; border-top: 1px solid var(--color-border-primary); padding-top: 0.75rem; } + + /* Canary stages */ + .canary-stages { display: grid; gap: 0.5rem; } + .canary-stages__header { display: flex; align-items: center; justify-content: space-between; } + .canary-stage-row { + display: grid; grid-template-columns: 24px 1fr 1fr 1fr auto; gap: 0.5rem; align-items: end; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem; background: var(--color-surface-primary); + } + .canary-stage-row--ab { grid-template-columns: 24px 1.2fr 0.6fr 0.6fr 0.8fr auto; } + .canary-stage-row__num { + display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: var(--radius-full); + background: var(--color-surface-secondary); color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-semibold); align-self: center; + } + + /* Review cards */ + .review-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } + .review-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem; background: var(--color-surface-primary); } + .review-card--wide { grid-column: 1 / -1; } + .review-card__header { + display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.6rem; padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border-primary); color: var(--color-text-secondary); + } + .review-card__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); text-transform: none; letter-spacing: normal; } + .review-card__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.75rem; margin: 0; font-size: var(--font-size-sm, 0.75rem); } + .review-card__dl dt { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } + .review-card__dl dd { margin: 0; color: var(--color-text-primary); } + .review-card__dl code { font-family: var(--font-family-mono, ui-monospace, monospace); font-size: var(--font-size-xs, 0.6875rem); background: var(--color-surface-secondary); padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); } + .review-badge { + display: inline-block; padding: 0.05rem 0.4rem; border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); text-transform: uppercase; letter-spacing: 0.03em; + } + + /* Seal confirm */ + .seal-confirm { + display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.75rem; + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); + border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); cursor: pointer; + } + .seal-confirm input { width: auto; margin-top: 0.15rem; } + .seal-confirm__text { display: grid; gap: 0.15rem; } + .seal-confirm__text strong { font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-primary); } + .seal-confirm__text span { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } + + /* Error */ + .wizard-error { + display: flex; align-items: center; gap: 0.5rem; padding: 0.55rem 0.75rem; + border: 1px solid var(--color-status-error-border); border-radius: var(--radius-md); + background: var(--color-status-error-bg); color: var(--color-status-error-text); + font-size: var(--font-size-sm, 0.75rem); + } + .wizard-error svg { flex-shrink: 0; } + + /* Footer */ + .wizard-actions { display: flex; align-items: center; gap: 0.5rem; } + .wizard-actions__spacer { flex: 1; } + .wizard-actions__step-label { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); margin-right: 0.25rem; } + + /* Buttons */ + .btn-primary, .btn-secondary, .btn-ghost, .btn-seal { + display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; + border-radius: var(--radius-md); padding: 0.45rem 0.85rem; + font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); + cursor: pointer; white-space: nowrap; transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; + } + .btn-primary { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn-primary:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } + .btn-secondary { border: 1px solid var(--color-btn-secondary-border); background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); } + .btn-secondary:hover:not(:disabled) { background: var(--color-btn-secondary-hover-bg); border-color: var(--color-btn-secondary-hover-border); } + .btn-ghost { border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); } + .btn-ghost:hover:not(:disabled) { background: var(--color-surface-secondary); color: var(--color-text-primary); } + .btn-seal { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); padding: 0.45rem 1.1rem; } + .btn-seal:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } + .btn-seal__spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .btn-primary:disabled, .btn-secondary:disabled, .btn-ghost:disabled, .btn-seal:disabled { opacity: 0.45; cursor: not-allowed; } + .btn-sm { padding: 0.25rem 0.5rem; font-size: var(--font-size-xs, 0.6875rem); } + .btn-remove { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); + background: transparent; color: var(--color-text-muted); cursor: pointer; transition: color 140ms ease, background 140ms ease; + } + .btn-remove:hover { color: var(--color-status-error-text); background: var(--color-status-error-bg); } + .btn-remove--sm { width: 22px; height: 22px; align-self: center; } + + /* Responsive */ + @media (max-width: 720px) { + .form-row-2 { grid-template-columns: 1fr; } + .review-cards { grid-template-columns: 1fr; } + .stepper__label { display: none; } + .wizard-header { flex-direction: column; gap: 0.5rem; } + .stage-row { grid-template-columns: 24px 1fr auto; } + .stage-row__arrow { display: none; } + } + `], +}) +export class CreateDeploymentComponent { + private readonly router = inject(Router); + readonly store = inject(ReleaseManagementStore); + readonly platformCtx = inject(PlatformContextStore); + + readonly step = signal(1); + readonly submitError = signal(null); + readonly submitting = signal(false); + createConfirmed = false; + + readonly fmtDigest = formatDigest; + + constructor() { + this.platformCtx.initialize(); + } + + readonly steps = [ + { n: 1, label: 'Package' }, + { n: 2, label: 'Target' }, + { n: 3, label: 'Strategy' }, + { n: 4, label: 'Review & Create' }, + ]; + + // ─── Step 1: Package selection ─── + readonly packageType = signal<'version' | 'hotfix'>('version'); + readonly selectedVersion = signal(null); + readonly selectedHotfix = signal(null); + readonly showInlineVersion = signal(false); + readonly showInlineHotfix = signal(false); + + versionSearch = ''; + hotfixSearch = ''; + + // Inline version creation + readonly inlineVersion = { name: '', version: '' }; + inlineImageSearch = ''; + readonly inlineSelectedImage = signal(null); + readonly inlineSelectedDigest = signal(''); + inlineComponents: Array<{ name: string; digest: string; tag: string }> = []; + + // Inline hotfix creation + inlineHotfixImageSearch = ''; + readonly inlineHotfixImage = signal(null); + readonly inlineHotfixDigest = signal(''); + + getFilteredVersions(): MockVersion[] { + const q = this.versionSearch.trim().toLowerCase(); + if (!q) return MOCK_VERSIONS; + return MOCK_VERSIONS.filter( + (v) => v.name.toLowerCase().includes(q) || v.version.toLowerCase().includes(q), + ); + } + + getFilteredHotfixes(): MockHotfix[] { + const q = this.hotfixSearch.trim().toLowerCase(); + if (!q) return MOCK_HOTFIXES; + return MOCK_HOTFIXES.filter( + (h) => h.name.toLowerCase().includes(q) || h.tag.toLowerCase().includes(q), + ); + } + + // ─── Step 2: Targets ─── + readonly targetRegions = signal([]); + readonly targetEnvironments = signal([]); + + promotionStages: PromotionStage[] = [ + { name: 'Development', environmentId: '' }, + { name: 'Staging', environmentId: '' }, + { name: 'Production', environmentId: '' }, + ]; + + getFilteredEnvironments() { + const allEnvs = this.platformCtx.environments(); + const selectedRegions = this.targetRegions(); + if (selectedRegions.length === 0) return allEnvs; + const regionSet = new Set(selectedRegions.map((r: string) => r.toLowerCase())); + return allEnvs.filter((env: any) => regionSet.has(env.regionId?.toLowerCase())); + } + + getAllEnvironments() { + return this.platformCtx.environments(); + } + + getTargetRegionNames() { + const regions = this.platformCtx.regions(); + return this.targetRegions().map((id: string) => { + const region = regions.find((r: any) => r.regionId === id); + return region?.displayName ?? id; + }); + } + + getTargetEnvironmentNames() { + const envs = this.platformCtx.environments(); + return this.targetEnvironments().map((id: string) => { + const env = envs.find((e: any) => e.environmentId === id); + return env?.displayName ?? id; + }); + } + + // ─── Step 3: Strategy ─── + deploymentStrategy: DeploymentStrategy = 'rolling'; + + readonly strategyConfig = { + rolling: { + batchSize: 1, + batchSizeType: 'count' as 'count' | 'percentage', + batchDelay: 0, + stabilizationTime: 30, + maxFailedBatches: 0, + healthCheckType: 'http' as 'http' | 'tcp' | 'command', + }, + canary: { + stages: [ + { trafficPercent: 10, durationMinutes: 5, healthThreshold: 99 }, + { trafficPercent: 50, durationMinutes: 10, healthThreshold: 99 }, + ] as Array<{ trafficPercent: number; durationMinutes: number; healthThreshold: number }>, + errorRateThreshold: 1, + latencyThreshold: 500, + }, + blueGreen: { + switchoverMode: 'instant' as 'instant' | 'gradual', + warmupPeriod: 60, + blueKeepalive: 30, + validationCommand: '', + }, + recreate: { + maxConcurrency: 0, + failureBehavior: 'rollback' as 'rollback' | 'continue' | 'pause', + healthCheckTimeout: 120, + }, + ab: { + subType: 'target-group' as 'target-group' | 'router-based', + targetGroupStages: [ + { name: 'Canary', aPercent: 100, bPercent: 10, durationMinutes: 15, healthThreshold: 99 }, + { name: 'Complete', aPercent: 0, bPercent: 100, durationMinutes: 0, healthThreshold: 99 }, + ] as Array<{ name: string; aPercent: number; bPercent: number; durationMinutes: number; healthThreshold: number }>, + routerBasedConfig: { + routingStrategy: 'weight-based' as string, + errorRateThreshold: 1, + latencyThreshold: 500, + }, + }, + }; + + getStrategyLabel(): string { return getStrategyLabel(this.deploymentStrategy); } + + // ─── Step navigation ─── + + setPackageType(type: 'version' | 'hotfix'): void { + this.packageType.set(type); + if (type === 'hotfix') { + this.deploymentStrategy = 'rolling'; + this.strategyConfig.rolling.batchSize = 1; + } + } + + canContinue(): boolean { + if (this.step() === 1) { + if (this.packageType() === 'version') return !!this.selectedVersion(); + return !!this.selectedHotfix(); + } + return true; + } + + canCreate(): boolean { + const hasPackage = this.packageType() === 'version' + ? !!this.selectedVersion() + : !!this.selectedHotfix(); + return hasPackage && this.createConfirmed && !this.submitting(); + } + + nextStep(): void { + if (!this.canContinue() || this.step() >= 4) return; + this.step.update((v) => v + 1); + } + + prevStep(): void { + if (this.step() > 1) this.step.update((v) => v - 1); + } + + // ─── Package selection ─── + + selectVersion(v: MockVersion): void { + this.selectedVersion.set(v); + this.showInlineVersion.set(false); + this.versionSearch = ''; + } + + clearVersion(): void { + this.selectedVersion.set(null); + this.versionSearch = ''; + } + + selectHotfix(h: MockHotfix): void { + this.selectedHotfix.set(h); + this.showInlineHotfix.set(false); + this.hotfixSearch = ''; + } + + clearHotfix(): void { + this.selectedHotfix.set(null); + this.hotfixSearch = ''; + } + + // ─── Inline version creation ─── + + onInlineImageSearch(query: string): void { + this.store.searchImages(query); + } + + selectInlineImage(img: RegistryImage): void { + this.inlineSelectedImage.set(img); + this.inlineSelectedDigest.set(''); + this.store.clearSearchResults(); + this.inlineImageSearch = ''; + } + + pickInlineDigest(d: { tag: string; digest: string }): void { + this.inlineSelectedDigest.set(d.digest); + } + + addInlineComponent(): void { + const img = this.inlineSelectedImage(); + const digest = this.inlineSelectedDigest(); + if (!img || !digest) return; + const digestEntry = img.digests.find((d) => d.digest === digest); + this.inlineComponents.push({ name: img.name, digest, tag: digestEntry?.tag ?? '' }); + this.inlineSelectedImage.set(null); + this.inlineSelectedDigest.set(''); + } + + sealInlineVersion(): void { + if (!this.inlineVersion.name.trim() || !this.inlineVersion.version.trim() || this.inlineComponents.length === 0) return; + const mockVersion: MockVersion = { + id: `inline-${Date.now()}`, + name: this.inlineVersion.name, + version: this.inlineVersion.version, + componentCount: this.inlineComponents.length, + sealedAt: new Date().toISOString(), + }; + this.selectedVersion.set(mockVersion); + this.showInlineVersion.set(false); + this.inlineVersion.name = ''; + this.inlineVersion.version = ''; + this.inlineComponents = []; + } + + // ─── Inline hotfix creation ─── + + onInlineHotfixImageSearch(query: string): void { + this.store.searchImages(query); + } + + selectInlineHotfixImage(img: RegistryImage): void { + this.inlineHotfixImage.set(img); + this.inlineHotfixDigest.set(''); + this.store.clearSearchResults(); + this.inlineHotfixImageSearch = ''; + } + + pickInlineHotfixDigest(d: { tag: string; digest: string }): void { + this.inlineHotfixDigest.set(d.digest); + } + + sealInlineHotfix(): void { + const img = this.inlineHotfixImage(); + const digest = this.inlineHotfixDigest(); + if (!img || !digest) return; + const digestEntry = img.digests.find((d) => d.digest === digest); + const tag = digestEntry?.tag ?? ''; + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; + const mockHotfix: MockHotfix = { + id: `inline-hf-${Date.now()}`, + name: `${img.name}-hotfix`, + image: img.repository, + tag: tag ? `${tag}-hf.${ts}` : `hf.${ts}`, + sealedAt: now.toISOString(), + }; + this.selectedHotfix.set(mockHotfix); + this.showInlineHotfix.set(false); + this.inlineHotfixImage.set(null); + this.inlineHotfixDigest.set(''); + } + + // ─── Target helpers ─── + + isRegionSelected(regionId: string): boolean { + return this.targetRegions().includes(regionId); + } + + isEnvironmentSelected(envId: string): boolean { + return this.targetEnvironments().includes(envId); + } + + toggleRegion(regionId: string): void { + const current = this.targetRegions(); + if (this.packageType() === 'hotfix') { + this.targetRegions.set(current.includes(regionId) ? [] : [regionId]); + this.targetEnvironments.set([]); + return; + } + if (current.includes(regionId)) { + this.targetRegions.set(current.filter((id) => id !== regionId)); + const remainingRegions = new Set(this.targetRegions().map((r) => r.toLowerCase())); + if (remainingRegions.size > 0) { + const allEnvs = this.platformCtx.environments(); + this.targetEnvironments.update((envIds) => + envIds.filter((envId) => { + const env = allEnvs.find((e) => e.environmentId === envId); + return env ? remainingRegions.has(env.regionId.toLowerCase()) : false; + }), + ); + } + } else { + this.targetRegions.set([...current, regionId]); + } + } + + toggleEnvironment(envId: string): void { + const current = this.targetEnvironments(); + if (this.packageType() === 'hotfix') { + this.targetEnvironments.set(current.includes(envId) ? [] : [envId]); + return; + } + if (current.includes(envId)) { + this.targetEnvironments.set(current.filter((id) => id !== envId)); + } else { + this.targetEnvironments.set([...current, envId]); + } + } + + regionDisplayName(regionId: string): string { + const region = this.platformCtx.regions().find((r) => r.regionId === regionId); + return region?.displayName ?? regionId; + } + + environmentDisplayName(envId: string): string { + const env = this.platformCtx.environments().find((e) => e.environmentId === envId); + return env?.displayName ?? envId; + } + + // ─── Promotion stages ─── + + addStage(): void { + this.promotionStages.push({ name: '', environmentId: '' }); + } + + removeStage(index: number): void { + if (this.promotionStages.length > 1) { + this.promotionStages.splice(index, 1); + } + } + + promotionStageNames(): string { + return this.promotionStages + .filter((s) => s.name.trim()) + .map((s) => s.name) + .join(' -> ') || 'none configured'; + } + + // ─── Strategy helpers ─── + + addCanaryStage(): void { + this.strategyConfig.canary.stages.push({ trafficPercent: 100, durationMinutes: 5, healthThreshold: 99 }); + } + + removeCanaryStage(index: number): void { + if (this.strategyConfig.canary.stages.length > 1) { + this.strategyConfig.canary.stages.splice(index, 1); + } + } + + addAbTargetGroupStage(): void { + this.strategyConfig.ab.targetGroupStages.push({ + name: `Stage ${this.strategyConfig.ab.targetGroupStages.length + 1}`, + aPercent: 0, bPercent: 100, durationMinutes: 15, healthThreshold: 99, + }); + } + + removeAbTargetGroupStage(index: number): void { + if (this.strategyConfig.ab.targetGroupStages.length > 1) { + this.strategyConfig.ab.targetGroupStages.splice(index, 1); + } + } + + // ─── Create deployment ─── + + createDeployment(): void { + if (!this.canCreate()) return; + + this.submitError.set(null); + this.submitting.set(true); + + const payload = { + packageType: this.packageType(), + package: this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix(), + targets: { + regions: this.targetRegions(), + environments: this.targetEnvironments(), + promotionStages: this.packageType() === 'version' ? this.promotionStages : [], + }, + strategy: { + type: this.deploymentStrategy, + config: this.getActiveStrategyConfig(), + }, + }; + + console.log('[CreateDeployment] Payload:', JSON.stringify(payload, null, 2)); + + setTimeout(() => { + this.submitting.set(false); + void this.router.navigate(['/releases'], { queryParams: { created: 'true' } }); + }, 800); + } + + private getActiveStrategyConfig(): unknown { + switch (this.deploymentStrategy) { + case 'rolling': return this.strategyConfig.rolling; + case 'canary': return this.strategyConfig.canary; + case 'blue_green': return this.strategyConfig.blueGreen; + case 'recreate': return this.strategyConfig.recreate; + case 'ab-release': return this.strategyConfig.ab; + default: return {}; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts new file mode 100644 index 000000000..b59c76567 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts @@ -0,0 +1,597 @@ +import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs'; + +import { ReleaseManagementStore } from '../release.store'; +import { formatDigest, type RegistryImage } from '../../../../core/api/release-management.models'; +import { AUTH_SERVICE, type AuthService, StellaOpsScopes } from '../../../../core/auth/auth.service'; +import { BundleOrganizerApi, type ReleaseControlBundleVersionDetailDto } from '../../../bundles/bundle-organizer.api'; + +@Component({ + selector: 'app-create-hotfix', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, RouterModule], + template: ` +
+
+
+ HOTFIX +
+

Create Hotfix

+

Single emergency image patch — fast-track deployment.

+
+
+ + + Back + +
+ + + @if (!confirmed()) { +
+
+ 1 + Select Package +
+ + + + @if (store.searchResults().length > 0 && !selectedImage()) { +
    + @for (image of store.searchResults(); track image.repository) { +
  • + +
  • + } +
+ } + + @if (selectedImage(); as img) { +
+
+
+

{{ img.name }}

+ {{ img.repository }} +
+ +
+ +
+ @for (d of img.digests; track d.digest) { + + } +
+ + @if (selectedDigest()) { +
+
+
Repository
{{ img.repository }}
+
Tag
{{ selectedTag() || 'untagged' }}
+
Digest
{{ selectedDigest() }}
+
Pushed
{{ selectedPushedAt() | slice:0:10 }}
+
Hotfix name
{{ derivedName() }}
+
Hotfix version
{{ derivedVersion() }}
+
+
+ } +
+ + + } + +
+ +
+
+ } + + + @if (confirmed()) { +
+
+ 2 + Confirm & Seal +
+ +
+
+
Image
{{ selectedImage()!.name }}
+
Repository
{{ selectedImage()!.repository }}
+
Tag
{{ selectedTag() || 'untagged' }}
+
Digest
{{ selectedDigest() }}
+
Hotfix name
{{ derivedName() }}
+
Hotfix version
{{ derivedVersion() }}
+
+
+ +
+ + Hotfixes bypass the standard promotion pipeline and deploy directly. +
+ + + + @if (submitError()) { +
{{ submitError() }}
+ } + +
+ + +
+
+ } +
+ `, + styles: [` + :host { display: block; } + + .hotfix-wizard { + max-width: 680px; + margin: 0 auto; + padding: 2rem 1rem; + } + + /* ─── Header ─── */ + .hotfix-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 1.5rem; + } + .hotfix-header__brand { display: flex; gap: 0.75rem; align-items: center; } + .hotfix-header__badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + border-radius: 4px; + background: var(--color-status-warning, #e67e22); + color: #fff; + } + .hotfix-header h1 { margin: 0; font-size: 1.35rem; font-weight: 600; color: var(--color-text-primary, #e5e7eb); } + .hotfix-header__sub { margin: 0.15rem 0 0; font-size: 0.8rem; color: var(--color-text-secondary, #9ca3af); } + .btn-back { + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.8rem; color: var(--color-text-secondary, #9ca3af); + text-decoration: none; white-space: nowrap; + } + .btn-back:hover { color: var(--color-text-primary, #e5e7eb); } + + /* ─── Card ─── */ + .hotfix-card { + background: var(--color-surface-elevated, #1e1e2e); + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + } + .hotfix-card--confirm { + border-color: var(--color-status-warning, #e67e22); + border-width: 2px; + } + .hotfix-card__step-label { + display: flex; align-items: center; gap: 0.5rem; + font-size: 0.85rem; font-weight: 600; + color: var(--color-text-primary, #e5e7eb); + margin-bottom: 1.25rem; + } + .step-num { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; border-radius: 50%; + background: var(--color-status-warning, #e67e22); + color: #fff; font-size: 0.7rem; font-weight: 700; + } + .hotfix-card__actions { display: flex; justify-content: flex-end; margin-top: 1.25rem; } + .hotfix-card__actions--confirm { justify-content: space-between; } + + /* ─── Search ─── */ + .field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.75rem; } + .field__label { font-size: 0.75rem; font-weight: 500; color: var(--color-text-secondary, #9ca3af); } + .field--desc { margin-top: 0.5rem; } + .search-wrap { position: relative; } + .search-wrap__icon { + position: absolute; left: 10px; top: 50%; transform: translateY(-50%); + color: var(--color-text-secondary, #9ca3af); pointer-events: none; + } + .search-wrap__input { + width: 100%; padding: 0.55rem 0.75rem 0.55rem 2rem; + background: var(--color-surface, #141420); + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 6px; color: var(--color-text-primary, #e5e7eb); + font-size: 0.85rem; + } + .search-wrap__input:focus { outline: none; border-color: var(--color-status-warning, #e67e22); } + textarea { + width: 100%; padding: 0.55rem 0.75rem; resize: vertical; + background: var(--color-surface, #141420); + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 6px; color: var(--color-text-primary, #e5e7eb); + font-size: 0.85rem; font-family: inherit; + } + textarea:focus { outline: none; border-color: var(--color-status-warning, #e67e22); } + + /* ─── Search Results ─── */ + .search-results { + list-style: none; margin: 0.5rem 0 0; padding: 0; + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 6px; overflow: hidden; + max-height: 220px; overflow-y: auto; + } + .search-item { + display: flex; align-items: center; gap: 0.75rem; + width: 100%; padding: 0.6rem 0.75rem; + background: transparent; border: none; border-bottom: 1px solid var(--color-border, #2e2e3e); + color: var(--color-text-primary, #e5e7eb); cursor: pointer; + font-size: 0.82rem; text-align: left; + } + .search-item:last-child { border-bottom: none; } + .search-item:hover { background: var(--color-surface, #141420); } + .search-item__repo { color: var(--color-text-secondary, #9ca3af); font-size: 0.75rem; } + .search-item__tags { margin-left: auto; color: var(--color-text-secondary, #9ca3af); font-size: 0.7rem; } + + /* ─── Selected Image ─── */ + .selected-image { + margin-top: 0.75rem; + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 6px; padding: 1rem; + background: var(--color-surface, #141420); + } + .selected-image__header { + display: flex; justify-content: space-between; align-items: flex-start; + margin-bottom: 0.75rem; + } + .selected-image__header h3 { margin: 0; font-size: 0.95rem; color: var(--color-text-primary, #e5e7eb); } + .selected-image__repo { font-size: 0.75rem; color: var(--color-text-secondary, #9ca3af); } + .btn-clear { + background: transparent; border: none; cursor: pointer; + color: var(--color-text-secondary, #9ca3af); padding: 4px; + } + .btn-clear:hover { color: var(--color-status-error, #ef4444); } + + /* ─── Digest List ─── */ + .digest-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; } + .digest-option { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.45rem 0.65rem; + background: transparent; + border: 1px solid var(--color-border, #2e2e3e); + border-radius: 5px; cursor: pointer; + color: var(--color-text-primary, #e5e7eb); + font-size: 0.8rem; text-align: left; width: 100%; + transition: border-color 0.15s; + } + .digest-option:hover { border-color: var(--color-text-secondary, #9ca3af); } + .digest-option--selected { + border-color: var(--color-status-warning, #e67e22); + background: rgba(230, 126, 34, 0.08); + } + .digest-option__tag { font-weight: 600; min-width: 80px; } + .digest-option__hash { font-size: 0.72rem; color: var(--color-text-secondary, #9ca3af); flex: 1; } + .digest-option__date { font-size: 0.7rem; color: var(--color-text-secondary, #9ca3af); } + + /* ─── Chosen Summary ─── */ + .chosen-summary, .confirm-summary { + border-top: 1px solid var(--color-border, #2e2e3e); + padding-top: 0.75rem; margin-top: 0.5rem; + } + .chosen-summary dl, .confirm-summary dl { + display: grid; grid-template-columns: 110px 1fr; + gap: 0.35rem 0.75rem; margin: 0; + font-size: 0.8rem; + } + .chosen-summary dt, .confirm-summary dt { + color: var(--color-text-secondary, #9ca3af); font-weight: 500; + } + .chosen-summary dd, .confirm-summary dd { + margin: 0; color: var(--color-text-primary, #e5e7eb); + overflow-wrap: anywhere; + } + .digest-full { font-size: 0.7rem; word-break: break-all; } + + /* ─── Warning Banner ─── */ + .warning-banner { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.65rem 0.85rem; margin: 1rem 0; + background: rgba(230, 126, 34, 0.1); + border: 1px solid rgba(230, 126, 34, 0.35); + border-radius: 6px; + font-size: 0.8rem; color: var(--color-status-warning, #e67e22); + } + .warning-banner svg { flex-shrink: 0; } + + /* ─── Error Banner ─── */ + .error-banner { + padding: 0.6rem 0.85rem; margin: 0.75rem 0; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 6px; + font-size: 0.8rem; color: var(--color-status-error, #ef4444); + } + + /* ─── Checkbox ─── */ + .checkbox-confirm { + display: flex; align-items: center; gap: 0.5rem; + font-size: 0.85rem; color: var(--color-text-primary, #e5e7eb); + cursor: pointer; margin-top: 0.5rem; + } + .checkbox-confirm input[type="checkbox"] { + width: 16px; height: 16px; + accent-color: var(--color-status-warning, #e67e22); + } + + /* ─── Buttons ─── */ + .btn-primary, .btn-secondary, .btn-hotfix { + display: inline-flex; align-items: center; gap: 0.4rem; + padding: 0.55rem 1.1rem; + border-radius: 6px; border: none; + font-size: 0.82rem; font-weight: 600; + cursor: pointer; transition: opacity 0.15s; + } + .btn-primary { + background: var(--color-status-warning, #e67e22); + color: #fff; + } + .btn-primary:disabled { opacity: 0.45; cursor: not-allowed; } + .btn-secondary { + background: transparent; + border: 1px solid var(--color-border, #2e2e3e); + color: var(--color-text-secondary, #9ca3af); + } + .btn-secondary:hover { color: var(--color-text-primary, #e5e7eb); border-color: var(--color-text-secondary, #9ca3af); } + .btn-hotfix { + background: var(--color-status-warning, #e67e22); + color: #fff; + } + .btn-hotfix:disabled { opacity: 0.45; cursor: not-allowed; } + .btn-hotfix:hover:not(:disabled) { opacity: 0.9; } + + /* ─── Spinner ─── */ + .spinner { + display: inline-block; width: 14px; height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: #fff; border-radius: 50%; + animation: spin 0.6s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + `], +}) +export class CreateHotfixComponent { + private readonly router = inject(Router); + private readonly auth = inject(AUTH_SERVICE) as AuthService; + private readonly bundleApi = inject(BundleOrganizerApi); + readonly store = inject(ReleaseManagementStore); + + // ─── Wizard state ─── + readonly confirmed = signal(false); + sealConfirmed = false; + searchQuery = ''; + description = ''; + + readonly selectedImage = signal(null); + readonly selectedDigest = signal(''); + readonly selectedTag = signal(''); + readonly selectedPushedAt = signal(''); + + readonly submitError = signal(null); + readonly submitting = signal(false); + + readonly fmtDigest = formatDigest; + + // ─── Derived identity ─── + readonly derivedName = computed(() => { + const img = this.selectedImage(); + if (!img) return ''; + return `${img.name}-hotfix`; + }); + + readonly derivedVersion = computed(() => { + const tag = this.selectedTag(); + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; + return tag ? `${tag}-hf.${ts}` : `hf.${ts}`; + }); + + readonly canContinue = computed(() => { + return !!this.selectedImage() && !!this.selectedDigest(); + }); + + // ─── Actions ─── + onSearch(query: string): void { + this.store.searchImages(query); + } + + selectImage(image: RegistryImage): void { + this.selectedImage.set(image); + this.selectedDigest.set(''); + this.selectedTag.set(''); + this.selectedPushedAt.set(''); + this.store.clearSearchResults(); + this.searchQuery = ''; + } + + pickDigest(d: { tag: string; digest: string; pushedAt: string }): void { + this.selectedDigest.set(d.digest); + this.selectedTag.set(d.tag); + this.selectedPushedAt.set(d.pushedAt); + } + + clearSelection(): void { + this.selectedImage.set(null); + this.selectedDigest.set(''); + this.selectedTag.set(''); + this.selectedPushedAt.set(''); + this.searchQuery = ''; + } + + canSeal(): boolean { + return this.sealConfirmed && !!this.selectedDigest() && !this.submitting(); + } + + sealAndCreate(): void { + if (!this.canSeal()) return; + + const img = this.selectedImage(); + if (!img) return; + + if (!this.auth.hasScope(StellaOpsScopes.ORCH_OPERATE)) { + this.submitError.set('Missing orch:operate scope. Refresh authentication after bootstrap scope changes.'); + return; + } + + this.submitError.set(null); + this.submitting.set(true); + + const name = this.derivedName(); + const version = this.derivedVersion(); + const slug = this.toSlug(name); + + const descriptionLines = [ + this.description.trim(), + `type=hotfix`, + `pathIntent=hotfix-prod`, + `image=${img.repository}`, + `tag=${this.selectedTag() || 'none'}`, + `digest=${this.selectedDigest()}`, + ].filter(l => l.length > 0); + const bundleDescription = descriptionLines.join(' | '); + + const publishRequest = { + changelog: bundleDescription, + components: [{ + componentName: img.name, + componentVersionId: `${img.name}@${version}`, + imageDigest: this.selectedDigest(), + deployOrder: 10, + metadataJson: JSON.stringify({ + imageRef: img.repository, + tag: this.selectedTag() || null, + type: 'container', + hotfix: true, + }), + }], + }; + + this.createOrReuseBundle(slug, name, bundleDescription) + .pipe( + switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)), + switchMap(ver => this.materializeHotfix(ver)), + finalize(() => this.submitting.set(false)), + ) + .subscribe({ + next: ver => { + void this.router.navigate(['/releases/bundles', ver.bundleId, 'versions', ver.id], { + queryParams: { source: 'hotfix-create', type: 'hotfix', returnTo: '/releases/versions' }, + queryParamsHandling: 'merge', + }); + }, + error: err => { + this.submitError.set(this.mapError(err)); + }, + }); + } + + // ─── Private helpers ─── + private createOrReuseBundle(slug: string, name: string, description: string) { + return this.bundleApi.createBundle({ slug, name, description }).pipe( + catchError(error => { + if (this.statusCodeOf(error) !== 409) return throwError(() => error); + return this.bundleApi.listBundles(200, 0).pipe( + map(bundles => { + const existing = bundles.find(b => b.slug === slug); + if (!existing) throw error; + return existing; + }), + ); + }), + ); + } + + private materializeHotfix(version: ReleaseControlBundleVersionDetailDto) { + return this.bundleApi.materializeBundleVersion(version.bundleId, version.id, { + targetEnvironment: undefined, + reason: 'console_hotfix_create', + idempotencyKey: `hotfix-${this.toSlug(this.derivedName())}-${Date.now()}`, + }).pipe( + map(() => version), + catchError(() => of(version)), + ); + } + + private statusCodeOf(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) return null; + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : null; + } + + private mapError(error: unknown): string { + const status = this.statusCodeOf(error); + if (status === 403) return 'Hotfix creation requires orch:operate scope.'; + if (status === 409) return 'A bundle with this slug already exists and could not be reused.'; + if (status === 503) return 'Release backend is currently unavailable.'; + return 'Failed to create hotfix. Check the console for details.'; + } + + private toSlug(value: string): string { + const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized || `hotfix-${Date.now()}`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts new file mode 100644 index 000000000..8b8ad052c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts @@ -0,0 +1,738 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { catchError, finalize, map, switchMap, throwError } from 'rxjs'; + +import { ReleaseManagementStore } from '../release.store'; +import { + formatDigest, + type AddComponentRequest, + type ComponentType, + type RegistryImage, +} from '../../../../core/api/release-management.models'; +import { AUTH_SERVICE, type AuthService, StellaOpsScopes } from '../../../../core/auth/auth.service'; +import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api'; + +@Component({ + selector: 'app-create-version', + imports: [FormsModule, RouterModule], + template: ` +
+
+
+

Create Version

+

Define the artifact bundle: what images and scripts make up this version.

+
+ + + Back to Versions + +
+ + + + + +
+ @switch (step()) { + @case (1) { +
+
+

Version Identity

+

Define the canonical name and version for this artifact bundle.

+
+ +
+ + +
+ + +
+ } + + @case (2) { +
+
+

Components

+

Search your registry and attach the images or scripts that compose this version.

+
+ + +
+ Component type +
+ + +
+
+ + + + + + @if (store.searchResults().length > 0) { +
+ @for (image of store.searchResults(); track image.repository) { + + } +
+ } + + + @if (selectedImage) { +
+
+

{{ selectedImage.name }}

+ {{ selectedImage.repository }} +
+ +
+ @for (digest of selectedImage.digests; track digest.digest) { + + } +
+ + +
+ } + + +
+
+

Selected components

+ {{ components.length }} +
+ + @if (components.length === 0) { +
+ +

No components added yet

+

At least one component is required to continue.

+
+ } @else { + + + + + + + + + + + + @for (component of components; track component.name + component.digest; let idx = $index) { + + + + + + + + } + +
NameTypeTagDigest
{{ component.name }}{{ component.type }}{{ component.tag || '-' }}{{ formatDigest(component.digest) }} + +
+ } +
+
+ } + + @case (3) { +
+
+

Review & Seal

+

Verify all fields before sealing. Once sealed, the version identity becomes immutable.

+
+ +
+
+
+ +

Version identity

+
+
+
Name
{{ form.name }}
+
Version
{{ form.version }}
+
Description
{{ form.description || 'none' }}
+
+
+ +
+
+ +

Components ({{ components.length }})

+
+
+ @for (component of components; track component.name + component.digest) { +
+ {{ component.name }} + {{ component.type }} + {{ component.tag || 'untagged' }} + {{ formatDigest(component.digest) }} +
+ } +
+
+
+ + +
+ } + } +
+ + @if (submitError(); as err) { + + } + +
+ +
+ Step {{ step() }} of 3 + + @if (step() < 3) { + + } @else { + + } +
+
+ `, + styles: [` + .create-version { display: grid; gap: 0.75rem; max-width: 820px; margin: 0 auto; } + + /* Header */ + .wizard-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } + .wizard-header h1 { margin: 0; font-size: var(--font-size-xl, 1.25rem); font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight, 1.25); } + .wizard-header__sub { margin: 0.2rem 0 0; color: var(--color-text-secondary); font-size: var(--font-size-sm, 0.75rem); } + + .btn-back { + display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-primary); color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); text-decoration: none; white-space: nowrap; + transition: color var(--motion-duration-sm, 140ms) ease, border-color var(--motion-duration-sm, 140ms) ease; + } + .btn-back:hover { color: var(--color-text-primary); border-color: var(--color-border-secondary); } + + /* Stepper */ + .stepper { display: flex; align-items: center; gap: 0; padding: 0.5rem 0; } + .stepper__line { flex: 1; height: 2px; background: var(--color-border-primary); transition: background var(--motion-duration-md, 200ms) ease; } + .stepper__line.done { background: var(--color-status-success-text); } + .stepper__step { + display: flex; flex-direction: column; align-items: center; gap: 0.35rem; + background: none; border: none; padding: 0 0.25rem; cursor: pointer; + transition: opacity var(--motion-duration-sm, 140ms) ease; + } + .stepper__step:disabled { cursor: default; opacity: 0.5; } + .stepper__circle { + display: flex; align-items: center; justify-content: center; + width: 32px; height: 32px; border-radius: 50%; + border: 2px solid var(--color-border-primary); background: var(--color-surface-primary); + font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + transition: border-color var(--motion-duration-sm, 140ms) ease, background var(--motion-duration-sm, 140ms) ease, color var(--motion-duration-sm, 140ms) ease; + } + .stepper__step.active .stepper__circle { + border-color: var(--color-brand-primary); background: var(--color-brand-primary); + color: var(--color-btn-primary-text, #fff); + box-shadow: 0 0 0 3px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); + } + .stepper__step.done .stepper__circle { border-color: var(--color-status-success-text); background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .stepper__label { font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium); color: var(--color-text-muted); white-space: nowrap; } + .stepper__step.active .stepper__label { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } + .stepper__step.done .stepper__label { color: var(--color-status-success-text); } + + /* Wizard body */ + .wizard-body { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); padding: 1.25rem; } + .step-panel { display: grid; gap: 0.85rem; } + .step-intro { margin-bottom: 0.25rem; } + .step-intro h2 { margin: 0; font-size: var(--font-size-md, 1rem); font-weight: var(--font-weight-semibold); } + .step-intro p { margin: 0.2rem 0 0; font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); line-height: var(--line-height-relaxed, 1.625); } + + /* Form fields */ + .form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } + .field { display: grid; gap: 0.3rem; } + .field__label { font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); color: var(--color-text-primary); } + .field__label abbr { color: var(--color-status-error-text); text-decoration: none; } + .field__hint { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); } + + input, select, textarea { + width: 100%; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-secondary); color: var(--color-text-primary); + padding: 0.45rem 0.6rem; font-size: var(--font-size-base, 0.8125rem); font-family: inherit; + transition: border-color var(--motion-duration-sm, 140ms) ease, box-shadow var(--motion-duration-sm, 140ms) ease; + } + input:focus, select:focus, textarea:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); background: var(--color-surface-primary); } + input::placeholder, textarea::placeholder { color: var(--color-text-muted); } + + /* Type toggle row */ + .type-toggle-row { display: flex; align-items: center; gap: 0.75rem; } + + /* Toggle pair */ + .toggle-pair { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); overflow: hidden; } + .toggle-pair__btn { + padding: 0.35rem 0.65rem; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); + border: none; background: var(--color-surface-primary); color: var(--color-text-secondary); cursor: pointer; + transition: background var(--motion-duration-sm, 140ms) ease, color var(--motion-duration-sm, 140ms) ease; + } + .toggle-pair__btn + .toggle-pair__btn { border-left: 1px solid var(--color-border-primary); } + .toggle-pair__btn--active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .toggle-pair__btn:hover:not(.toggle-pair__btn--active) { background: var(--color-surface-elevated); } + + /* Search input with icon */ + .search-input-wrap { position: relative; display: flex; align-items: center; } + .search-input-wrap__icon { position: absolute; left: 0.6rem; color: var(--color-text-muted); pointer-events: none; } + .search-input-wrap__input { padding-left: 2rem; } + + /* Search results */ + .search-results { display: grid; gap: 0.3rem; max-height: 200px; overflow: auto; } + .search-item { + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; + display: grid; gap: 0.1rem; text-align: left; cursor: pointer; + background: var(--color-surface-primary); color: var(--color-text-primary); font-size: var(--font-size-sm, 0.75rem); + transition: border-color var(--motion-duration-sm, 140ms) ease, background var(--motion-duration-sm, 140ms) ease; + } + .search-item:hover { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); } + .search-item span { color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } + + /* Selection panel */ + .selection-panel { + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); border-radius: var(--radius-lg); + padding: 0.75rem; display: grid; gap: 0.6rem; + background: var(--color-brand-primary-10, var(--color-surface-subtle)); + } + .selection-panel__header h3 { margin: 0; font-size: var(--font-size-base, 0.8125rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); text-transform: none; letter-spacing: normal; } + .selection-panel__repo { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } + + .digest-options { display: grid; gap: 0.3rem; } + .digest-option { + display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.4rem 0.6rem; + font-size: var(--font-size-sm, 0.75rem); cursor: pointer; + background: var(--color-surface-primary); color: var(--color-text-primary); + transition: border-color var(--motion-duration-sm, 140ms) ease, background var(--motion-duration-sm, 140ms) ease, box-shadow var(--motion-duration-sm, 140ms) ease; + } + .digest-option:hover { border-color: var(--color-brand-primary); } + .digest-option code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } + .digest-option.selected { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); box-shadow: 0 0 0 2px var(--color-focus-ring); } + + .btn-add-component { display: inline-flex; align-items: center; gap: 0.35rem; justify-self: start; } + + /* Components section */ + .components-section { display: grid; gap: 0.5rem; } + .components-section__header { display: flex; align-items: center; gap: 0.5rem; } + .components-section__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-secondary); } + .components-section__count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 20px; height: 20px; border-radius: var(--radius-full); + background: var(--color-brand-primary-10, var(--color-surface-secondary)); + color: var(--color-text-link); font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); padding: 0 0.3rem; + } + + .components-empty { + display: flex; flex-direction: column; align-items: center; text-align: center; + padding: 1.5rem 1rem; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-md); + color: var(--color-text-muted); + } + .components-empty svg { margin-bottom: 0.5rem; opacity: 0.5; } + .components-empty p { margin: 0; font-size: var(--font-size-sm, 0.75rem); } + .validation-note { color: var(--color-status-warning-text); font-size: var(--font-size-xs, 0.6875rem); margin-top: 0.25rem; } + + /* Component table */ + .stella-table--bordered { + width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 0.75rem); + } + .stella-table--bordered th, .stella-table--bordered td { + padding: 0.45rem 0.6rem; border: 1px solid var(--color-border-primary); text-align: left; + } + .stella-table--bordered th { + background: var(--color-surface-secondary); font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); + text-transform: uppercase; letter-spacing: 0.04em; + } + .stella-table--bordered td code { + font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); + } + .stella-table--bordered tr:hover td { background: var(--color-surface-secondary); } + + .type-badge { + display: inline-block; padding: 0.05rem 0.4rem; border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); + text-transform: uppercase; letter-spacing: 0.03em; + } + + .btn-remove { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); + background: transparent; color: var(--color-text-muted); cursor: pointer; + transition: color var(--motion-duration-sm, 140ms) ease, background var(--motion-duration-sm, 140ms) ease; + } + .btn-remove:hover { color: var(--color-status-error-text); background: var(--color-status-error-bg); } + + /* Review cards */ + .review-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } + .review-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem; background: var(--color-surface-primary); } + .review-card__header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.6rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border-primary); color: var(--color-text-secondary); } + .review-card__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); text-transform: none; letter-spacing: normal; } + .review-card__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.75rem; margin: 0; font-size: var(--font-size-sm, 0.75rem); } + .review-card__dl dt { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } + .review-card__dl dd { margin: 0; color: var(--color-text-primary); } + + .review-component-list { display: grid; gap: 0.3rem; } + .review-component-item { + display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; + padding: 0.35rem 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + font-size: var(--font-size-sm, 0.75rem); + } + .review-component-item code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-muted); font-size: var(--font-size-xs, 0.6875rem); } + .review-component-item__tag { color: var(--color-text-secondary); } + + /* Seal confirm */ + .seal-confirm { + display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.75rem; + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); + border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); + cursor: pointer; + } + .seal-confirm input { width: auto; margin-top: 0.15rem; } + .seal-confirm__text { display: grid; gap: 0.15rem; } + .seal-confirm__text strong { font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-primary); } + .seal-confirm__text span { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } + + /* Error */ + .wizard-error { + display: flex; align-items: center; gap: 0.5rem; padding: 0.55rem 0.75rem; + border: 1px solid var(--color-status-error-border); border-radius: var(--radius-md); + background: var(--color-status-error-bg); color: var(--color-status-error-text); + font-size: var(--font-size-sm, 0.75rem); + } + .wizard-error svg { flex-shrink: 0; } + + /* Footer actions */ + .wizard-actions { display: flex; align-items: center; gap: 0.5rem; } + .wizard-actions__spacer { flex: 1; } + .wizard-actions__step-label { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); margin-right: 0.25rem; } + + /* Buttons */ + .btn-primary, .btn-secondary, .btn-ghost, .btn-seal { + display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; + border-radius: var(--radius-md); padding: 0.45rem 0.85rem; + font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); + cursor: pointer; white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, border-color var(--motion-duration-sm, 140ms) ease, box-shadow var(--motion-duration-sm, 140ms) ease; + } + .btn-primary { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn-primary:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } + .btn-secondary { border: 1px solid var(--color-btn-secondary-border); background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); } + .btn-secondary:hover:not(:disabled) { background: var(--color-btn-secondary-hover-bg); border-color: var(--color-btn-secondary-hover-border); } + .btn-ghost { border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); } + .btn-ghost:hover:not(:disabled) { background: var(--color-surface-secondary); color: var(--color-text-primary); } + .btn-seal { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); padding: 0.45rem 1.1rem; } + .btn-seal:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } + .btn-seal__spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .btn-primary:disabled, .btn-secondary:disabled, .btn-ghost:disabled, .btn-seal:disabled { opacity: 0.45; cursor: not-allowed; } + + /* Responsive */ + @media (max-width: 720px) { + .form-row-2 { grid-template-columns: 1fr; } + .review-cards { grid-template-columns: 1fr; } + .stepper__label { display: none; } + .wizard-header { flex-direction: column; gap: 0.5rem; } + } + `], +}) +export class CreateVersionComponent { + private readonly router = inject(Router); + private readonly auth = inject(AUTH_SERVICE) as AuthService; + private readonly bundleApi = inject(BundleOrganizerApi); + readonly store = inject(ReleaseManagementStore); + + readonly step = signal(1); + readonly submitError = signal(null); + readonly submitting = signal(false); + sealConfirmed = false; + + readonly steps = [ + { n: 1, label: 'Identity' }, + { n: 2, label: 'Components' }, + { n: 3, label: 'Review & Seal' }, + ]; + + readonly suggestedVersion = 'v1.0.0'; + + readonly suggestedName = computed(() => { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `release-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`; + }); + + readonly form = { + name: '', + version: '', + description: '', + }; + + // Component adding state + componentType: ComponentType = 'container'; + searchQuery = ''; + selectedImage: RegistryImage | null = null; + selectedDigest = ''; + selectedTag = ''; + components: AddComponentRequest[] = []; + + readonly formatDigest = formatDigest; + + // --- Step navigation --- + + canContinueStep(): boolean { + if (this.step() === 1) { + return Boolean(this.form.name.trim()) && Boolean(this.form.version.trim()); + } + if (this.step() === 2) { + return this.components.length > 0; + } + return true; + } + + canSeal(): boolean { + return this.components.length > 0 + && Boolean(this.form.name.trim()) + && Boolean(this.form.version.trim()) + && this.sealConfirmed + && !this.submitting(); + } + + nextStep(): void { + if (!this.canContinueStep()) return; + if (this.step() < 3) { + this.step.update(v => v + 1); + } + } + + prevStep(): void { + if (this.step() > 1) { + this.step.update(v => v - 1); + } + } + + // --- Image search & selection --- + + onSearchImages(query: string): void { + this.store.searchImages(query); + } + + selectImage(image: RegistryImage): void { + this.selectedImage = image; + this.selectedDigest = ''; + this.selectedTag = ''; + this.store.clearSearchResults(); + } + + addSelectedComponent(): void { + if (!this.selectedImage || !this.selectedDigest) return; + + this.components.push({ + name: this.selectedImage.name, + imageRef: this.selectedImage.repository, + digest: this.selectedDigest, + tag: this.selectedTag || undefined, + version: this.selectedTag || this.selectedDigest.slice(7, 19), + type: this.componentType, + }); + + this.selectedImage = null; + this.selectedDigest = ''; + this.selectedTag = ''; + this.searchQuery = ''; + } + + removeComponent(index: number): void { + this.components.splice(index, 1); + } + + // --- Seal --- + + sealVersion(): void { + if (!this.canSeal()) return; + + if (!this.auth.hasScope(StellaOpsScopes.ORCH_OPERATE)) { + this.submitError.set('Missing orch:operate scope. Refresh authentication after scope changes.'); + return; + } + + this.submitError.set(null); + this.submitting.set(true); + + const bundleSlug = this.toSlug(this.form.name.trim()); + const bundleName = this.form.name.trim(); + const bundleDescription = this.form.description.trim() || `Version ${this.form.version}`; + + const publishRequest = { + changelog: bundleDescription, + components: this.toBundleComponents(), + }; + + this.createOrReuseBundle(bundleSlug, bundleName, bundleDescription) + .pipe( + switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)), + finalize(() => this.submitting.set(false)), + ) + .subscribe({ + next: version => { + void this.router.navigate(['/releases/bundles', version.bundleId, 'versions', version.id], { + queryParams: { source: 'version-create', returnTo: '/releases/versions' }, + queryParamsHandling: 'merge', + }); + }, + error: error => { + this.submitError.set(this.mapCreateError(error)); + }, + }); + } + + // --- Private helpers --- + + private createOrReuseBundle(slug: string, name: string, description: string) { + return this.bundleApi.createBundle({ slug, name, description }).pipe( + catchError(error => { + if (this.statusCodeOf(error) !== 409) { + return throwError(() => error); + } + return this.bundleApi.listBundles(200, 0).pipe( + map(bundles => { + const existing = bundles.find(b => b.slug === slug); + if (!existing) throw error; + return existing; + }), + ); + }), + ); + } + + private toBundleComponents() { + return this.components.map(c => ({ + componentName: c.name, + componentVersionId: `${c.name}@${c.version}`, + imageDigest: c.digest, + deployOrder: 10, + metadataJson: JSON.stringify({ + imageRef: c.imageRef, + tag: c.tag ?? null, + type: c.type, + }), + })); + } + + private statusCodeOf(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) return null; + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : null; + } + + private mapCreateError(error: unknown): string { + const status = this.statusCodeOf(error); + if (status === 403) return 'Version creation requires orch:operate scope. Current session is not authorized.'; + if (status === 409) return 'A bundle with this slug already exists but could not be reused.'; + if (status === 503) return 'Release control backend is unavailable. The version was not created.'; + return 'Failed to create version bundle.'; + } + + private toSlug(value: string): string { + const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return normalized || `version-${Date.now()}`; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/index.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/index.ts index 867850bfc..c47fc1054 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/index.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/index.ts @@ -7,3 +7,4 @@ export * from './releases.routes'; export * from './release-list/release-list.component'; export * from './release-detail/release-detail.component'; export * from './create-release/create-release.component'; +export * from './create-version/create-version.component'; diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts index 338cac4cf..bf0be2c4f 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts @@ -1,6 +1,5 @@ // Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { PlatformContextStore } from '../../../../core/context/platform-context.store'; @@ -14,13 +13,14 @@ import { type ReleaseGateStatus, type ReleaseRiskTier, } from '../../../../core/api/release-management.models'; -import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component'; +import { StellaFilterChipComponent, FilterChipOption } from '../../../../shared/components/stella-filter-chip/stella-filter-chip.component'; +import { PaginationComponent, PageChangeEvent } from '../../../../shared/components/pagination/pagination.component'; import { DateFormatService } from '../../../../core/i18n/date-format.service'; import { PageActionService } from '../../../../core/services/page-action.service'; @Component({ selector: 'app-release-list', - imports: [FormsModule, RouterModule, FilterBarComponent], + imports: [RouterModule, StellaFilterChipComponent, PaginationComponent], template: `
@@ -30,15 +30,19 @@ import { PageActionService } from '../../../../core/services/page-action.service
- +
+ + + + + +
@if (selectedCount() > 0) {
@@ -146,7 +150,7 @@ import { PageActionService } from '../../../../core/services/page-action.service

@if (hasActiveFilters()) { - + }
} @else {
- +
@@ -228,23 +232,15 @@ import { PageActionService } from '../../../../core/services/page-action.service
- @if (store.totalCount() > store.pageSize()) { - - } +
+ +
}
`, @@ -284,6 +280,51 @@ import { PageActionService } from '../../../../core/services/page-action.service flex-shrink: 0; } + /* ─── Inline filters ─── */ + .filters { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .filter-search { + position: relative; + flex: 0 1 240px; + min-width: 160px; + } + + .filter-search__icon { + position: absolute; + left: 0.5rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-muted); + pointer-events: none; + } + + .filter-search__input { + width: 100%; + height: 28px; + padding: 0 0.5rem 0 1.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-primary); + font-size: 0.75rem; + outline: none; + transition: border-color 150ms ease; + } + + .filter-search__input:focus { + border-color: var(--color-brand-primary); + } + + .filter-search__input::placeholder { + color: var(--color-text-muted); + } + /* ─── Buttons ─── */ .btn-primary, .btn-secondary { @@ -515,56 +556,15 @@ import { PageActionService } from '../../../../core/services/page-action.service background: var(--color-surface-primary); } - .release-table { - width: 100%; - border-collapse: collapse; - } - - .release-table th, - .release-table td { - text-align: left; - padding: 0.5rem 0.6rem; - vertical-align: top; - font-size: var(--font-size-sm, 0.75rem); - } - - .release-table thead { - border-bottom: 1px solid var(--color-border-primary); - } - - .release-table th { - font-size: var(--font-size-xs, 0.6875rem); - font-weight: var(--font-weight-medium); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-secondary); - background: var(--color-surface-secondary); - padding: 0.45rem 0.6rem; - white-space: nowrap; - } - - .release-table tbody tr { - border-bottom: 1px solid var(--color-border-primary); - transition: background var(--motion-duration-sm, 140ms) ease; - } - - .release-table tbody tr:last-child { - border-bottom: none; - } - - .release-table tbody tr:hover { - background: var(--color-surface-secondary); - } - - .release-table tbody tr.selected { + :host ::ng-deep .stella-table tbody tr.selected { background: var(--color-selection-bg, var(--color-brand-primary-10)); } - .release-table tbody tr.blocked { + :host ::ng-deep .stella-table tbody tr.blocked { background: var(--color-status-error-bg); } - .release-table tbody tr.blocked:hover { + :host ::ng-deep .stella-table tbody tr.blocked:hover { background: color-mix(in srgb, var(--color-status-error-bg) 80%, var(--color-surface-secondary)); } @@ -682,45 +682,6 @@ import { PageActionService } from '../../../../core/services/page-action.service background: var(--color-surface-tertiary); } - /* ─── Pagination ─── */ - .pagination { - display: flex; - justify-content: center; - align-items: center; - gap: 0.75rem; - padding-top: 0.25rem; - } - - .pagination__btn { - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.3rem 0.6rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-primary); - font-size: var(--font-size-sm, 0.75rem); - cursor: pointer; - transition: background var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease; - } - - .pagination__btn:hover:not(:disabled) { - background: var(--color-surface-secondary); - border-color: var(--color-border-secondary); - } - - .pagination__btn:disabled { - opacity: 0.4; - cursor: not-allowed; - } - - .pagination__info { - font-size: var(--font-size-sm, 0.75rem); - color: var(--color-text-secondary); - } - /* ─── Health summary strip ─── */ .health-strip { display: flex; @@ -823,6 +784,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy { stageFilter = 'all'; gateFilter = 'all'; riskFilter = 'all'; + evidenceFilter = 'all'; blockedFilter = 'all'; needsApprovalFilter = 'all'; hotfixLaneFilter = 'all'; @@ -831,21 +793,33 @@ export class ReleaseListComponent implements OnInit, OnDestroy { readonly selectedReleaseIds = signal>(new Set()); private applyingFromQuery = false; - // Shared filter bar integration - readonly releaseFilterOptions: FilterOption[] = [ - { key: 'type', label: 'Type', options: [{ value: 'standard', label: 'Standard' }, { value: 'hotfix', label: 'Hotfix' }] }, - { key: 'stage', label: 'Stage', options: [{ value: 'draft', label: 'Draft' }, { value: 'ready', label: 'Ready' }, { value: 'deploying', label: 'Deploying' }, { value: 'deployed', label: 'Deployed' }, { value: 'failed', label: 'Failed' }, { value: 'rolled_back', label: 'Rolled Back' }] }, - { key: 'gate', label: 'Gate', options: [{ value: 'pass', label: 'Pass' }, { value: 'warn', label: 'Warn' }, { value: 'pending', label: 'Pending' }, { value: 'block', label: 'Block' }, { value: 'unknown', label: 'Unknown' }] }, - { key: 'risk', label: 'Risk', options: [{ value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, { value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' }, { value: 'none', label: 'None' }, { value: 'unknown', label: 'Unknown' }] }, - { key: 'blocked', label: 'Blocked', options: [{ value: 'true', label: 'Blocked' }, { value: 'false', label: 'Unblocked' }] }, - { key: 'needsApproval', label: 'Needs Approval', options: [{ value: 'true', label: 'Needs Approval' }, { value: 'false', label: 'No Approval Needed' }] }, - { key: 'hotfixLane', label: 'Hotfix Lane', options: [{ value: 'true', label: 'Hotfix Lane' }, { value: 'false', label: 'Standard Lane' }] }, - { key: 'replayMismatch', label: 'Replay Mismatch', options: [{ value: 'true', label: 'Mismatch' }, { value: 'false', label: 'No Mismatch' }] }, + // Inline filter chip options + readonly typeOptions: FilterChipOption[] = [ + { id: '', label: 'All Types' }, + { id: 'standard', label: 'Standard' }, + { id: 'hotfix', label: 'Hotfix' }, + ]; + readonly gateOptions: FilterChipOption[] = [ + { id: '', label: 'All Gates' }, + { id: 'pass', label: 'Pass' }, + { id: 'warn', label: 'Warn' }, + { id: 'block', label: 'Block' }, + ]; + readonly riskOptions: FilterChipOption[] = [ + { id: '', label: 'All Risk' }, + { id: 'critical', label: 'Critical' }, + { id: 'high', label: 'High' }, + { id: 'medium', label: 'Medium' }, + { id: 'low', label: 'Low' }, + { id: 'none', label: 'None' }, + ]; + readonly evidenceOptions: FilterChipOption[] = [ + { id: '', label: 'All Evidence' }, + { id: 'verified', label: 'Verified' }, + { id: 'partial', label: 'Partial' }, + { id: 'missing', label: 'Missing' }, ]; - readonly activeReleaseFilters = signal([]); - - readonly Math = Math; readonly getGateStatusLabel = getGateStatusLabel; readonly getRiskTierLabel = getRiskTierLabel; readonly getEvidencePostureLabel = getEvidencePostureLabel; @@ -882,6 +856,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy { this.stageFilter = params.get('stage') ?? 'all'; this.gateFilter = params.get('gate') ?? 'all'; this.riskFilter = params.get('risk') ?? 'all'; + this.evidenceFilter = params.get('evidence') ?? 'all'; this.blockedFilter = params.get('blocked') ?? 'all'; this.needsApprovalFilter = params.get('needsApproval') ?? 'all'; this.hotfixLaneFilter = params.get('hotfixLane') ?? 'all'; @@ -895,83 +870,45 @@ export class ReleaseListComponent implements OnInit, OnDestroy { this.pageAction.clear(); } - onReleaseSearch(value: string): void { - this.searchTerm = value; + onSearchInput(event: Event): void { + this.searchTerm = (event.target as HTMLInputElement).value; this.applyFilters(false); } - onReleaseFilterChanged(filter: ActiveFilter): void { - const filterMap: Record = { - type: 'typeFilter', - stage: 'stageFilter', - gate: 'gateFilter', - risk: 'riskFilter', - blocked: 'blockedFilter', - needsApproval: 'needsApprovalFilter', - hotfixLane: 'hotfixLaneFilter', - replayMismatch: 'replayMismatchFilter', - }; - const prop = filterMap[filter.key]; - if (prop) { - (this as any)[prop] = filter.value; - } + onTypeFilterChange(value: string): void { + this.typeFilter = value || 'all'; this.applyFilters(false); } - onReleaseFilterRemoved(filter: ActiveFilter): void { - const filterMap: Record = { - type: 'typeFilter', - stage: 'stageFilter', - gate: 'gateFilter', - risk: 'riskFilter', - blocked: 'blockedFilter', - needsApproval: 'needsApprovalFilter', - hotfixLane: 'hotfixLaneFilter', - replayMismatch: 'replayMismatchFilter', - }; - const prop = filterMap[filter.key]; - if (prop) { - (this as any)[prop] = 'all'; - } + onGateFilterChange(value: string): void { + this.gateFilter = value || 'all'; this.applyFilters(false); } - clearAllReleaseFilters(): void { + onRiskFilterChange(value: string): void { + this.riskFilter = value || 'all'; + this.applyFilters(false); + } + + onEvidenceFilterChange(value: string): void { + this.evidenceFilter = value || 'all'; + this.applyFilters(false); + } + + clearFilters(): void { this.searchTerm = ''; this.typeFilter = 'all'; this.stageFilter = 'all'; this.gateFilter = 'all'; this.riskFilter = 'all'; + this.evidenceFilter = 'all'; this.blockedFilter = 'all'; this.needsApprovalFilter = 'all'; this.hotfixLaneFilter = 'all'; this.replayMismatchFilter = 'all'; - this.activeReleaseFilters.set([]); this.applyFilters(false); } - private rebuildActiveReleaseFilters(): void { - const filters: ActiveFilter[] = []; - const filterDefs: { key: string; prop: string; label: string }[] = [ - { key: 'type', prop: 'typeFilter', label: 'Type' }, - { key: 'stage', prop: 'stageFilter', label: 'Stage' }, - { key: 'gate', prop: 'gateFilter', label: 'Gate' }, - { key: 'risk', prop: 'riskFilter', label: 'Risk' }, - { key: 'blocked', prop: 'blockedFilter', label: 'Blocked' }, - { key: 'needsApproval', prop: 'needsApprovalFilter', label: 'Approval' }, - { key: 'hotfixLane', prop: 'hotfixLaneFilter', label: 'Lane' }, - { key: 'replayMismatch', prop: 'replayMismatchFilter', label: 'Replay' }, - ]; - for (const def of filterDefs) { - const val = (this as any)[def.prop] as string; - if (val !== 'all') { - const opt = this.releaseFilterOptions.find(f => f.key === def.key)?.options.find(o => o.value === val); - filters.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) }); - } - } - this.activeReleaseFilters.set(filters); - } - applyFilters(fromQuery: boolean): void { const filter: ReleaseFilter = { search: this.searchTerm.trim() || undefined, @@ -988,7 +925,6 @@ export class ReleaseListComponent implements OnInit, OnDestroy { }; this.store.setFilter(filter); - this.rebuildActiveReleaseFilters(); if (!fromQuery && !this.applyingFromQuery) { void this.router.navigate([], { @@ -1003,6 +939,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy { this.store.setPage(page); } + onPageChange(event: PageChangeEvent): void { + this.store.setPage(event.page); + this.store.setPageSize(event.pageSize); + } + toggleRelease(releaseId: string, event: Event): void { const checked = (event.target as HTMLInputElement).checked; this.selectedReleaseIds.update((current) => { @@ -1046,7 +987,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy { } hasActiveFilters(): boolean { - return this.activeReleaseFilters().length > 0 || this.searchTerm.trim().length > 0; + return this.searchTerm.trim().length > 0 || + this.typeFilter !== 'all' || + this.gateFilter !== 'all' || + this.riskFilter !== 'all' || + this.evidenceFilter !== 'all'; } clearSelection(): void { @@ -1119,6 +1064,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy { stage: this.stageFilter !== 'all' ? this.stageFilter : null, gate: this.gateFilter !== 'all' ? this.gateFilter : null, risk: this.riskFilter !== 'all' ? this.riskFilter : null, + evidence: this.evidenceFilter !== 'all' ? this.evidenceFilter : null, blocked: this.blockedFilter !== 'all' ? this.blockedFilter : null, needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null, hotfixLane: this.hotfixLaneFilter !== 'all' ? this.hotfixLaneFilter : null, diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts index 45a1a7a25..088af3158 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts @@ -12,6 +12,13 @@ export const RELEASE_ROUTES: Routes = [ (m) => m.ReleaseListComponent ), }, + { + path: 'new', + loadComponent: () => + import('./create-deployment/create-deployment.component').then( + (m) => m.CreateDeploymentComponent + ), + }, { path: 'create', loadComponent: () => @@ -19,6 +26,27 @@ export const RELEASE_ROUTES: Routes = [ (m) => m.CreateReleaseComponent ), }, + { + path: 'create-hotfix', + loadComponent: () => + import('./create-hotfix/create-hotfix.component').then( + (m) => m.CreateHotfixComponent + ), + }, + { + path: 'create-version', + loadComponent: () => + import('./create-version/create-version.component').then( + (m) => m.CreateVersionComponent + ), + }, + { + path: 'create-deployment', + loadComponent: () => + import('./create-deployment/create-deployment.component').then( + (m) => m.CreateDeploymentComponent + ), + }, { path: ':id', loadComponent: () => diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index f3c1985ab..8681786ae 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -6,7 +6,8 @@ import { take } from 'rxjs'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component'; -import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; +import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component'; +import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component'; import { DateFormatService } from '../../core/i18n/date-format.service'; @@ -55,7 +56,7 @@ function deriveOutcomeIcon(status: string): string { @Component({ selector: 'app-releases-activity', standalone: true, - imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, FilterBarComponent], + imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent], template: `
@@ -76,15 +77,19 @@ function deriveOutcomeIcon(status: string): string { ariaLabel="Run list views" /> - +
+ + + + + +
@if (error()) { @@ -139,7 +144,7 @@ function deriveOutcomeIcon(status: string): string {
} @default { - +
@@ -153,7 +158,7 @@ function deriveOutcomeIcon(status: string): string { - @for (row of filteredRows(); track row.activityId) { + @for (row of pagedRows(); track row.activityId) { @@ -169,6 +174,15 @@ function deriveOutcomeIcon(status: string): string { }
Run
{{ row.activityId }} {{ row.releaseName }}
+
+ +
} } } @@ -177,11 +191,23 @@ function deriveOutcomeIcon(status: string): string { styles: [` .activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem} .context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)} - - .banner,table,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} + + /* Inline filter chips row */ + .activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } + .filter-search { position: relative; flex: 0 1 240px; min-width: 160px; } + .filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; } + .filter-search__input { + width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); + background: transparent; color: var(--color-text-primary); + font-size: 0.75rem; outline: none; transition: border-color 150ms ease; + } + .filter-search__input:focus { border-color: var(--color-brand-primary); } + .filter-search__input::placeholder { color: var(--color-text-muted); } + + .banner,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} .banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)} - table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase} - tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem} + .clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem} /* Timeline container */ .timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem} @@ -194,6 +220,11 @@ function deriveOutcomeIcon(status: string): string { .run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)} .run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem} .run-link:hover{text-decoration:underline} + + @media (max-width: 768px) { + .activity-filters { gap: 0.375rem; } + .filter-search { flex: 1 1 100%; } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -211,70 +242,40 @@ export class ReleasesActivityComponent { readonly rows = signal([]); readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline'); - // ── Filter-bar configuration ────────────────────────────────────────── + // ── Filter-chip options ────────────────────────────────────────────── - readonly activityFilterOptions: FilterOption[] = [ - { key: 'status', label: 'Status', options: [ - { value: 'pending_approval', label: 'Pending Approval' }, - { value: 'approved', label: 'Approved' }, - { value: 'published', label: 'Published' }, - { value: 'blocked', label: 'Blocked' }, - { value: 'rejected', label: 'Rejected' }, - ]}, - { key: 'lane', label: 'Lane', options: [ - { value: 'standard', label: 'Standard' }, - { value: 'hotfix', label: 'Hotfix' }, - ]}, - { key: 'env', label: 'Environment', options: [ - { value: 'dev', label: 'Dev' }, - { value: 'stage', label: 'Stage' }, - { value: 'prod', label: 'Prod' }, - ]}, - { key: 'outcome', label: 'Outcome', options: [ - { value: 'success', label: 'Success' }, - { value: 'in_progress', label: 'In Progress' }, - { value: 'failed', label: 'Failed' }, - ]}, - { key: 'needsApproval', label: 'Needs Approval', options: [ - { value: 'true', label: 'Needs Approval' }, - { value: 'false', label: 'No Approval Needed' }, - ]}, - { key: 'integrity', label: 'Data Integrity', options: [ - { value: 'blocked', label: 'Blocked' }, - { value: 'clear', label: 'Clear' }, - ]}, + readonly statusChipOptions: FilterChipOption[] = [ + { id: '', label: 'All Status' }, + { id: 'pending_approval', label: 'Pending' }, + { id: 'approved', label: 'Approved' }, + { id: 'published', label: 'Published' }, + { id: 'blocked', label: 'Blocked' }, + { id: 'rejected', label: 'Rejected' }, + ]; + readonly laneChipOptions: FilterChipOption[] = [ + { id: '', label: 'All Lanes' }, + { id: 'standard', label: 'Standard' }, + { id: 'hotfix', label: 'Hotfix' }, + ]; + readonly envChipOptions: FilterChipOption[] = [ + { id: '', label: 'All Envs' }, + { id: 'dev', label: 'Dev' }, + { id: 'stage', label: 'Stage' }, + { id: 'prod', label: 'Prod' }, + ]; + readonly outcomeChipOptions: FilterChipOption[] = [ + { id: '', label: 'All Outcomes' }, + { id: 'success', label: 'Success' }, + { id: 'in_progress', label: 'In Progress' }, + { id: 'failed', label: 'Failed' }, ]; - readonly statusFilter = signal('all'); - readonly laneFilter = signal('all'); - readonly envFilter = signal('all'); - readonly outcomeFilter = signal('all'); - readonly needsApprovalFilter = signal('all'); - readonly integrityFilter = signal('all'); + readonly statusFilter = signal(''); + readonly laneFilter = signal(''); + readonly envFilter = signal(''); + readonly outcomeFilter = signal(''); readonly searchQuery = signal(''); - readonly activityActiveFilters = computed(() => { - const filters: ActiveFilter[] = []; - const pairs: { key: string; value: string }[] = [ - { key: 'status', value: this.statusFilter() }, - { key: 'lane', value: this.laneFilter() }, - { key: 'env', value: this.envFilter() }, - { key: 'outcome', value: this.outcomeFilter() }, - { key: 'needsApproval', value: this.needsApprovalFilter() }, - { key: 'integrity', value: this.integrityFilter() }, - ]; - - for (const pair of pairs) { - if (pair.value !== 'all') { - const opt = this.activityFilterOptions - .find(f => f.key === pair.key)?.options - .find(o => o.value === pair.value); - filters.push({ key: pair.key, value: pair.value, label: opt?.label ?? pair.value }); - } - } - return filters; - }); - readonly filteredRows = computed(() => { let rows = [...this.rows()]; @@ -282,32 +283,42 @@ export class ReleasesActivityComponent { const laneF = this.laneFilter(); const envF = this.envFilter(); const outcomeF = this.outcomeFilter(); - const needsApprovalF = this.needsApprovalFilter(); - const integrityF = this.integrityFilter(); + const q = this.searchQuery().toLowerCase().trim(); - if (statusF !== 'all') { + if (q) { + rows = rows.filter((item) => + item.releaseName.toLowerCase().includes(q) || + item.activityId.toLowerCase().includes(q) || + item.eventType.toLowerCase().includes(q) || + item.status.toLowerCase().includes(q), + ); + } + if (statusF !== '') { rows = rows.filter((item) => item.status.toLowerCase() === statusF); } - if (laneF !== 'all') { + if (laneF !== '') { rows = rows.filter((item) => this.deriveLane(item) === laneF); } - if (envF !== 'all') { + if (envF !== '') { rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(envF)); } - if (outcomeF !== 'all') { + if (outcomeF !== '') { rows = rows.filter((item) => this.deriveOutcome(item) === outcomeF); } - if (needsApprovalF !== 'all') { - const expected = needsApprovalF === 'true'; - rows = rows.filter((item) => this.deriveNeedsApproval(item) === expected); - } - if (integrityF !== 'all') { - rows = rows.filter((item) => this.deriveDataIntegrity(item) === integrityF); - } return rows; }); + // ── Pagination state ──────────────────────────────────────────────────── + readonly currentPage = signal(1); + readonly pageSize = signal(10); + + readonly pagedRows = computed(() => { + const all = this.filteredRows(); + const start = (this.currentPage() - 1) * this.pageSize(); + return all.slice(start, start + this.pageSize()); + }); + /** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */ readonly timelineEvents = computed(() => { return this.filteredRows().map((row) => ({ @@ -370,8 +381,6 @@ export class ReleasesActivityComponent { if (params.get('lane')) this.laneFilter.set(params.get('lane')!); if (params.get('env')) this.envFilter.set(params.get('env')!); if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!); - if (params.get('needsApproval')) this.needsApprovalFilter.set(params.get('needsApproval')!); - if (params.get('integrity')) this.integrityFilter.set(params.get('integrity')!); }); effect(() => { @@ -383,12 +392,10 @@ export class ReleasesActivityComponent { mergeQuery(next: Record): Record { return { view: next['view'] ?? this.viewMode(), - status: this.statusFilter() !== 'all' ? this.statusFilter() : null, - lane: this.laneFilter() !== 'all' ? this.laneFilter() : null, - env: this.envFilter() !== 'all' ? this.envFilter() : null, - outcome: this.outcomeFilter() !== 'all' ? this.outcomeFilter() : null, - needsApproval: this.needsApprovalFilter() !== 'all' ? this.needsApprovalFilter() : null, - integrity: this.integrityFilter() !== 'all' ? this.integrityFilter() : null, + status: this.statusFilter() !== '' ? this.statusFilter() : null, + lane: this.laneFilter() !== '' ? this.laneFilter() : null, + env: this.envFilter() !== '' ? this.envFilter() : null, + outcome: this.outcomeFilter() !== '' ? this.outcomeFilter() : null, }; } @@ -400,45 +407,9 @@ export class ReleasesActivityComponent { }); } - // ── Filter-bar handlers ──────────────────────────────────────────────── - - onActivitySearch(query: string): void { - this.searchQuery.set(query); - } - - onActivityFilterAdded(f: ActiveFilter): void { - switch (f.key) { - case 'status': this.statusFilter.set(f.value); break; - case 'lane': this.laneFilter.set(f.value); break; - case 'env': this.envFilter.set(f.value); break; - case 'outcome': this.outcomeFilter.set(f.value); break; - case 'needsApproval': this.needsApprovalFilter.set(f.value); break; - case 'integrity': this.integrityFilter.set(f.value); break; - } - this.applyFilters(); - } - - onActivityFilterRemoved(f: ActiveFilter): void { - switch (f.key) { - case 'status': this.statusFilter.set('all'); break; - case 'lane': this.laneFilter.set('all'); break; - case 'env': this.envFilter.set('all'); break; - case 'outcome': this.outcomeFilter.set('all'); break; - case 'needsApproval': this.needsApprovalFilter.set('all'); break; - case 'integrity': this.integrityFilter.set('all'); break; - } - this.applyFilters(); - } - - clearAllActivityFilters(): void { - this.statusFilter.set('all'); - this.laneFilter.set('all'); - this.envFilter.set('all'); - this.outcomeFilter.set('all'); - this.needsApprovalFilter.set('all'); - this.integrityFilter.set('all'); - this.searchQuery.set(''); - this.applyFilters(); + onPageChange(event: PageChangeEvent): void { + this.currentPage.set(event.page); + this.pageSize.set(event.pageSize); } deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' { diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts index 4b2b14f75..c138f0632 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts @@ -12,8 +12,7 @@ import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, signal, import { PageActionService } from '../../core/services/page-action.service'; import { UpperCasePipe, SlicePipe } from '@angular/common'; import { RouterLink } from '@angular/router'; -import { FormsModule } from '@angular/forms'; -import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; +import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component'; import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component'; import { TableColumn } from '../../shared/components/data-table/data-table.component'; @@ -122,8 +121,7 @@ const MOCK_RELEASES: PipelineRelease[] = [ UpperCasePipe, SlicePipe, RouterLink, - FormsModule, - FilterBarComponent, + StellaFilterChipComponent, PaginationComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -137,16 +135,17 @@ const MOCK_RELEASES: PipelineRelease[] = [ -
- +
+ + + +
@@ -319,21 +318,17 @@ const MOCK_RELEASES: PipelineRelease[] = [ .rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; } .rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; } .rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; } - .rup__toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; margin-bottom: 1rem; } - :host ::ng-deep app-filter-bar { flex: 1 1 0; min-width: 0; } - .rup__toolbar-actions { display: flex; gap: 0.375rem; margin-left: auto; padding-top: 0.5rem; } - - .btn { - display: inline-flex; align-items: center; gap: 0.375rem; padding: 0 0.75rem; - border: none; border-radius: var(--radius-md, 6px); font-size: 0.75rem; - font-weight: var(--font-weight-semibold, 600); cursor: pointer; text-decoration: none; - white-space: nowrap; transition: background 150ms ease, box-shadow 150ms ease; line-height: 1; + .rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } + .rup__search { position: relative; flex: 0 1 240px; min-width: 160px; } + .rup__search-icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; } + .rup__search-input { + width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem; + border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); + background: transparent; color: var(--color-text-primary); + font-size: 0.75rem; outline: none; transition: border-color 150ms ease; } - .btn--sm { height: 32px; } - .btn--primary { background: var(--color-btn-primary-bg); color: var(--color-surface-inverse, #fff); } - .btn--primary:hover { box-shadow: var(--shadow-sm); } - .btn--warning { background: var(--color-status-warning, #C89820); color: #fff; } - .btn--warning:hover { box-shadow: var(--shadow-sm); } + .rup__search-input:focus { border-color: var(--color-brand-primary); } + .rup__search-input::placeholder { color: var(--color-text-muted); } .rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -429,8 +424,8 @@ const MOCK_RELEASES: PipelineRelease[] = [ @media (max-width: 768px) { .rup { padding: 1rem; } - .rup__toolbar { flex-direction: column; align-items: stretch; } - .rup__toolbar-actions { margin-left: 0; justify-content: flex-end; } + .rup__filters { gap: 0.375rem; } + .rup__search { flex: 1 1 100%; } } `], }) @@ -438,33 +433,34 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy { private readonly pageAction = inject(PageActionService); ngOnInit(): void { - this.pageAction.set({ label: 'New Release', route: '/releases/versions/new' }); + this.pageAction.set({ label: 'New Release', route: '/releases/new' }); } ngOnDestroy(): void { this.pageAction.clear(); } - // ── Filter-bar configuration ────────────────────────────────────────── + // ── Filter-chip options ────────────────────────────────────────────── - readonly pipelineFilterOptions: FilterOption[] = [ - { key: 'lane', label: 'Lane', options: [ - { value: 'standard', label: 'Standard' }, - { value: 'hotfix', label: 'Hotfix' }, - ]}, - { key: 'status', label: 'Status', options: [ - { value: 'draft', label: 'Draft' }, - { value: 'ready', label: 'Ready' }, - { value: 'deploying', label: 'Deploying' }, - { value: 'deployed', label: 'Deployed' }, - { value: 'failed', label: 'Failed' }, - { value: 'rolled_back', label: 'Rolled Back' }, - ]}, - { key: 'gate', label: 'Gates', options: [ - { value: 'pass', label: 'Pass' }, - { value: 'warn', label: 'Warn' }, - { value: 'block', label: 'Block' }, - ]}, + readonly laneOptions: FilterChipOption[] = [ + { id: '', label: 'All Lanes' }, + { id: 'standard', label: 'Standard' }, + { id: 'hotfix', label: 'Hotfix' }, + ]; + readonly statusOptions: FilterChipOption[] = [ + { id: '', label: 'All Status' }, + { id: 'draft', label: 'Draft' }, + { id: 'ready', label: 'Ready' }, + { id: 'deploying', label: 'Deploying' }, + { id: 'deployed', label: 'Deployed' }, + { id: 'failed', label: 'Failed' }, + { id: 'rolled_back', label: 'Rolled Back' }, + ]; + readonly gateOptions: FilterChipOption[] = [ + { id: '', label: 'All Gates' }, + { id: 'pass', label: 'Pass' }, + { id: 'warn', label: 'Warn' }, + { id: 'block', label: 'Block' }, ]; // ── Columns definition ─────────────────────────────────────────────── @@ -483,32 +479,11 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy { readonly releases = signal(MOCK_RELEASES); readonly searchQuery = signal(''); - readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all'); - readonly statusFilter = signal('all'); - readonly gateFilter = signal('all'); + readonly laneFilter = signal(''); + readonly statusFilter = signal(''); + readonly gateFilter = signal(''); readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null); - readonly pipelineActiveFilters = computed(() => { - const filters: ActiveFilter[] = []; - const lane = this.laneFilter(); - const status = this.statusFilter(); - const gate = this.gateFilter(); - - if (lane !== 'all') { - const opt = this.pipelineFilterOptions.find(f => f.key === 'lane')?.options.find(o => o.value === lane); - filters.push({ key: 'lane', value: lane, label: opt?.label ?? lane }); - } - if (status !== 'all') { - const opt = this.pipelineFilterOptions.find(f => f.key === 'status')?.options.find(o => o.value === status); - filters.push({ key: 'status', value: status, label: opt?.label ?? status }); - } - if (gate !== 'all') { - const opt = this.pipelineFilterOptions.find(f => f.key === 'gate')?.options.find(o => o.value === gate); - filters.push({ key: 'gate', value: gate, label: opt?.label ?? gate }); - } - return filters; - }); - // ── Pagination ──────────────────────────────────────────────────────── readonly currentPage = signal(1); @@ -529,13 +504,13 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy { r.digest.toLowerCase().includes(q), ); } - if (lane !== 'all') { + if (lane !== '') { list = list.filter((r) => r.lane === lane); } - if (status !== 'all') { + if (status !== '') { list = list.filter((r) => r.status === status); } - if (gate !== 'all') { + if (gate !== '') { list = list.filter((r) => r.gateStatus === gate); } return list; @@ -580,33 +555,6 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy { this.pageSize.set(event.pageSize); } - // ── Filter-bar handlers ──────────────────────────────────────────────── - - onPipelineFilterAdded(f: ActiveFilter): void { - this.currentPage.set(1); - switch (f.key) { - case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break; - case 'status': this.statusFilter.set(f.value); break; - case 'gate': this.gateFilter.set(f.value); break; - } - } - - onPipelineFilterRemoved(f: ActiveFilter): void { - switch (f.key) { - case 'lane': this.laneFilter.set('all'); break; - case 'status': this.statusFilter.set('all'); break; - case 'gate': this.gateFilter.set('all'); break; - } - } - - clearAllPipelineFilters(): void { - this.laneFilter.set('all'); - this.statusFilter.set('all'); - this.gateFilter.set('all'); - this.searchQuery.set(''); - this.currentPage.set(1); - } - // ── Sort handlers ──────────────────────────────────────────────────── toggleSort(columnKey: string): void { diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 51468146a..1e47febb2 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -67,9 +67,27 @@ export const RELEASES_ROUTES: Routes = [ (m) => m.ReleaseListComponent, ), }, + { + path: 'new', + title: 'New Release', + data: { breadcrumb: 'New Release' }, + loadComponent: () => + import('../features/release-orchestrator/releases/create-deployment/create-deployment.component').then( + (m) => m.CreateDeploymentComponent, + ), + }, { path: 'versions/new', - title: 'Create Release Version', + title: 'Create Version', + data: { breadcrumb: 'Create Version', semanticObject: 'version' }, + loadComponent: () => + import('../features/release-orchestrator/releases/create-version/create-version.component').then( + (m) => m.CreateVersionComponent, + ), + }, + { + path: 'versions/new-legacy', + title: 'Create Release Version (Legacy)', data: { breadcrumb: 'Create Release Version', semanticObject: 'version' }, loadComponent: () => import('../features/release-orchestrator/releases/create-release/create-release.component').then( @@ -179,11 +197,10 @@ export const RELEASES_ROUTES: Routes = [ path: 'hotfixes/new', title: 'Create Hotfix', data: { breadcrumb: 'Create Hotfix' }, - pathMatch: 'full', - redirectTo: preserveReleasesRedirectWithQuery('/releases/versions/new', { - type: 'hotfix', - hotfixLane: 'true', - }), + loadComponent: () => + import('../features/release-orchestrator/releases/create-hotfix/create-hotfix.component').then( + (m) => m.CreateHotfixComponent, + ), }, { path: 'hotfixes/:hotfixId', diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs index f5731ccb8..2e94f7dd7 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs @@ -210,14 +210,24 @@ internal static class ElkEdgeChannels var routeMode = EdgeRouteMode.Direct; if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) { - var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label); - if (familyKey is "failure" or "timeout") + var sourceNode = positionedNodes[sorted[index].SourceNodeId]; + var isGatewaySource = string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase); + if (isGatewaySource) { - routeMode = EdgeRouteMode.SinkOuterTop; + sinkBand = (-1, 0, 0d, double.NaN); } else { - routeMode = EdgeRouteMode.SinkOuter; + var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label); + if (familyKey is "failure" or "timeout") + { + routeMode = EdgeRouteMode.SinkOuterTop; + } + else + { + routeMode = EdgeRouteMode.SinkOuter; + } } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs index 8399c134f..c52582a01 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs @@ -153,9 +153,10 @@ internal static class ElkEdgeRouter } var channel = edgeChannels.GetValueOrDefault(edge.Id); - if (channel.RouteMode != EdgeRouteMode.Direct + var useCorridorRouting = channel.RouteMode != EdgeRouteMode.Direct || !string.IsNullOrWhiteSpace(edge.SourcePortId) - || !string.IsNullOrWhiteSpace(edge.TargetPortId)) + || !string.IsNullOrWhiteSpace(edge.TargetPortId); + if (useCorridorRouting) { reconstructed[edge.Id] = RouteEdge( edge, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs index 2adf1a149..f6e1b2a76 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkLayoutHelpers.cs @@ -184,3 +184,34 @@ internal static class ElkLayoutHelpers }; } } + +internal static class ElkSharpLayoutHelpers +{ + internal static EdgeChannel ResolveSinkOverride( + EdgeChannel channel, string edgeId, + DummyNodeResult dummyResult, + IReadOnlyDictionary edgeChannels, + IReadOnlyCollection allEdges) + { + if (channel.RouteMode != EdgeRouteMode.SinkOuter || dummyResult.EdgeDummyChains.ContainsKey(edgeId)) + { + return channel; + } + + var sourceNodeId = allEdges.FirstOrDefault(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal))?.SourceNodeId; + if (sourceNodeId is null) + { + return channel; + } + + var hasOtherForwardEdge = allEdges.Any(e => + string.Equals(e.SourceNodeId, sourceNodeId, StringComparison.Ordinal) + && !string.Equals(e.Id, edgeId, StringComparison.Ordinal) + && edgeChannels.TryGetValue(e.Id, out var otherChannel) + && otherChannel.RouteMode == EdgeRouteMode.Direct); + + return hasOtherForwardEdge + ? channel with { RouteMode = EdgeRouteMode.Direct } + : channel; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index 8c7e9caf4..14ddd9f56 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -91,10 +91,17 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine edgeChannels, layerBoundariesByNodeId); var routedEdges = graph.Edges - .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed) - ? routed - : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, - edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + .Select(edge => + { + if (reconstructedEdges.TryGetValue(edge.Id, out var routed)) + { + return routed; + } + + var ch = ElkSharpLayoutHelpers.ResolveSinkOverride( + edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges); + return ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, ch, layerBoundariesByNodeId); + }) .ToArray(); for (var gutterPass = 0; gutterPass < 3; gutterPass++) { @@ -126,7 +133,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, - edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId)) .ToArray(); } @@ -160,7 +167,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, - edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId)) .ToArray(); if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters( @@ -191,7 +198,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) ? rerouted : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, - edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) + ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId)) .ToArray(); }