From 66d84fb17a0d3e7c8db250a4957a14f251c19a4d Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 23 Mar 2026 14:05:30 +0200 Subject: [PATCH] Fix Create Deployment wizard: add missing SlicePipe import Root cause: the | slice pipe was used in the template but SlicePipe was not in the standalone component's imports array. This caused Angular's resolveDirective to throw 'Cannot read factory' on every change detection cycle, preventing mock version cards from rendering and breaking the Continue button validation. Also: removed unused RouterModule import, converted computed signals to methods for PlatformContextStore-dependent values, added platformCtx.initialize() in constructor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SPRINT_20260322_001_FE_wizard_split.md | 67 + src/Web/StellaOps.Web/AGENTS.md | 112 +- .../approval-queue.component.ts | 324 ++-- .../create-deployment.component.ts | 1608 +++++++++++++++++ .../create-hotfix/create-hotfix.component.ts | 597 ++++++ .../create-version.component.ts | 738 ++++++++ .../release-orchestrator/releases/index.ts | 1 + .../release-list/release-list.component.ts | 316 ++-- .../releases/releases.routes.ts | 28 + .../releases/releases-activity.component.ts | 243 ++- .../releases-unified-page.component.ts | 156 +- .../src/app/routes/releases.routes.ts | 29 +- .../StellaOps.ElkSharp/ElkEdgeChannels.cs | 18 +- .../StellaOps.ElkSharp/ElkEdgeRouter.cs | 5 +- .../StellaOps.ElkSharp/ElkLayoutHelpers.cs | 31 + .../ElkSharpLayeredLayoutEngine.cs | 21 +- 16 files changed, 3623 insertions(+), 671 deletions(-) create mode 100644 docs/implplan/SPRINT_20260322_001_FE_wizard_split.md create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-hotfix/create-hotfix.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts 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(); }