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) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-23 14:05:30 +02:00
parent eb27a69778
commit 66d84fb17a
16 changed files with 3623 additions and 671 deletions

View File

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

View File

@@ -162,10 +162,114 @@ const MY_TABS: readonly StellaPageTab[] = [
- Do NOT duplicate the tab bar styling — the component owns all tab CSS - 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 - The component provides: keyboard navigation, ARIA roles, active background, bottom border, icon opacity transitions, panel border-radius, enter animation
## Table Styling Convention ## Data Table Convention (MANDATORY)
All HTML tables must use the `stella-table` CSS class for consistent styling.
Never define custom table padding, borders, or header styles inline. All data tables **must** use `<app-data-table>` for interactive tables or `.stella-table` CSS classes for simple static tables.
Use the shared data-table component when possible, or the stella-table class 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
<app-data-table
[columns]="columns"
[data]="pagedItems()"
[loading]="loading()"
[striped]="true"
[hoverable]="true"
[selectable]="false"
emptyTitle="No items found"
emptyDescription="Adjust filters or create a new item."
(sortChange)="onSortChange($event)"
(rowClick)="onRowClick($event)"
>
<div tablePagination>
<app-pagination
[total]="totalItems()"
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[pageSizes]="[5, 10, 25, 50]"
(pageChange)="onPageChange($event)"
/>
</div>
</app-data-table>
```
**Column definition with custom cell templates:**
```typescript
readonly columns: TableColumn<MyItem>[] = [
{ 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
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead><tr><th>Name</th><th>Value</th></tr></thead>
<tbody><tr><td>...</td><td>...</td></tr></tbody>
</table>
```
**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
<stella-filter-chip
label="Status"
[value]="statusFilter()"
[options]="statusOptions"
(valueChange)="statusFilter.set($event)"
/>
<stella-filter-multi
label="Severity"
[options]="severityOptions()"
(optionsChange)="onSeverityChange($event)"
/>
```
**Usage (filter bar with search + dropdowns):**
```html
<app-filter-bar
searchPlaceholder="Search..."
[filters]="filterOptions"
[activeFilters]="activeFilters()"
(searchChange)="searchQuery.set($event)"
(filterChange)="onFilterAdded($event)"
(filterRemove)="onFilterRemoved($event)"
(filtersCleared)="clearAllFilters()"
/>
```
**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) ## Filter Convention (MANDATORY)

View File

@@ -2,7 +2,7 @@
* Approval Queue Component * Approval Queue Component
* Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui * 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 { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -16,11 +16,13 @@ import {
formatTimeRemaining, formatTimeRemaining,
} from '../../../../core/api/approval.models'; } from '../../../../core/api/approval.models';
import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; 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'; import { DateFormatService } from '../../../../core/i18n/date-format.service';
@Component({ @Component({
selector: 'app-approval-queue', selector: 'app-approval-queue',
imports: [RouterLink, FormsModule, LoadingStateComponent], imports: [RouterLink, FormsModule, LoadingStateComponent, PaginationComponent, StellaFilterChipComponent],
template: ` template: `
<div class="approval-queue-container"> <div class="approval-queue-container">
<header class="page-header"> <header class="page-header">
@@ -30,54 +32,17 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
</div> </div>
</header> </header>
<!-- Status Filter -->
<div class="status-switcher" role="radiogroup" aria-label="Approval status filter">
<button type="button" role="radio" class="status-segment"
[class.status-segment--active]="isStatusActive([])"
[attr.aria-checked]="isStatusActive([])"
(click)="filterByStatus([])">
<span class="status-segment__label">All</span>
<span class="status-segment__count">{{ store.approvals().length }}</span>
</button>
<button type="button" role="radio" class="status-segment"
[class.status-segment--active]="isStatusActive(['pending'])"
[attr.aria-checked]="isStatusActive(['pending'])"
(click)="filterByStatus(['pending'])">
<span class="status-segment__label">Pending</span>
<span class="status-segment__count">{{ store.approvalsByStatus().pending }}</span>
</button>
<button type="button" role="radio" class="status-segment"
[class.status-segment--active]="isStatusActive(['approved'])"
[attr.aria-checked]="isStatusActive(['approved'])"
(click)="filterByStatus(['approved'])">
<span class="status-segment__label">Approved</span>
<span class="status-segment__count">{{ store.approvalsByStatus().approved }}</span>
</button>
<button type="button" role="radio" class="status-segment"
[class.status-segment--active]="isStatusActive(['rejected'])"
[attr.aria-checked]="isStatusActive(['rejected'])"
(click)="filterByStatus(['rejected'])">
<span class="status-segment__label">Rejected</span>
<span class="status-segment__count">{{ store.approvalsByStatus().rejected }}</span>
</button>
</div>
<!-- Filters --> <!-- Filters -->
<div class="filters-bar"> <div class="filters">
<div class="filter-group"> <div class="filter-search">
<label>Urgency:</label> <svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<div class="filter-chips"> <circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
@for (urgency of urgencyOptions; track urgency.value) { </svg>
<button <input type="text" class="filter-search__input" placeholder="Search approvals..."
class="filter-chip" [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
[class.active]="isUrgencySelected(urgency.value)"
(click)="toggleUrgencyFilter(urgency.value)"
>
{{ urgency.label }}
</button>
}
</div>
</div> </div>
<stella-filter-chip label="Status" [value]="statusChipValue()" [options]="statusOptions" (valueChange)="onStatusChipChange($event)" />
<stella-filter-chip label="Urgency" [value]="urgencyChipValue()" [options]="urgencyChipOptions" (valueChange)="onUrgencyChipChange($event)" />
</div> </div>
<!-- Batch Actions --> <!-- Batch Actions -->
@@ -113,8 +78,8 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
<!-- Approval List --> <!-- Approval List -->
@if (!store.loading() && !store.error()) { @if (!store.loading() && !store.error()) {
<div class="approval-table"> <div class="approval-table-wrap">
<table> <table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead> <thead>
<tr> <tr>
<th class="col-checkbox"> <th class="col-checkbox">
@@ -134,7 +99,7 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (approval of store.filteredApprovals(); track approval.id) { @for (approval of pagedApprovals(); track approval.id) {
<tr [class.expiring-soon]="isExpiringSoon(approval)" [class.selected]="isSelected(approval.id)"> <tr [class.expiring-soon]="isExpiringSoon(approval)" [class.selected]="isSelected(approval.id)">
<td class="col-checkbox"> <td class="col-checkbox">
<input <input
@@ -203,9 +168,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
<td colspan="8" class="empty-row"> <td colspan="8" class="empty-row">
<div class="empty-state"> <div class="empty-state">
<p>No approval requests found</p> <p>No approval requests found</p>
@if (hasActiveFilters()) {
<button class="btn btn-secondary" (click)="clearFilters()">Clear Filters</button>
}
</div> </div>
</td> </td>
</tr> </tr>
@@ -213,6 +175,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
</tbody> </tbody>
</table> </table>
</div> </div>
<div style="display: flex; justify-content: flex-end; padding-top: 0.75rem;">
<app-pagination
[total]="store.filteredApprovals().length"
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[pageSizes]="[5, 10, 25, 50]"
(pageChange)="onPageChange($event)"
/>
</div>
} }
<!-- Batch Approve Dialog --> <!-- Batch Approve Dialog -->
@@ -282,106 +254,17 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.status-switcher { .filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
display: inline-flex; .filter-search { position: relative; flex: 0 1 240px; min-width: 160px; }
border: 1px solid var(--color-border-primary); .filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
border-radius: var(--radius-md); .filter-search__input {
overflow: hidden; width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
margin-bottom: 0.5rem; background: transparent; color: var(--color-text-primary);
} font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
.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);
} }
.filter-search__input:focus { border-color: var(--color-brand-primary); }
.filter-search__input::placeholder { color: var(--color-text-muted); }
.batch-actions { .batch-actions {
display: flex; display: flex;
@@ -398,48 +281,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
color: var(--color-status-warning-text); color: var(--color-status-warning-text);
} }
.approval-table { .approval-table-wrap {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
} }
.approval-table table { .approval-table-wrap tr.selected {
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 {
background: var(--color-status-info-bg); background: var(--color-status-info-bg);
} }
.approval-table tr.expiring-soon { .approval-table-wrap tr.expiring-soon {
background: var(--color-status-error-bg); background: var(--color-status-error-bg);
} }
@@ -667,6 +518,11 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
gap: 12px; gap: 12px;
margin-top: 20px; margin-top: 20px;
} }
@media (max-width: 768px) {
.filters { gap: 0.375rem; }
.filter-search { flex: 1 1 100%; }
}
`] `]
}) })
export class ApprovalQueueComponent implements OnInit { export class ApprovalQueueComponent implements OnInit {
@@ -681,13 +537,56 @@ export class ApprovalQueueComponent implements OnInit {
readonly getUrgencyColor = getUrgencyColor; readonly getUrgencyColor = getUrgencyColor;
readonly formatTimeRemaining = formatTimeRemaining; readonly formatTimeRemaining = formatTimeRemaining;
readonly urgencyOptions = [ // ── Filter-chip options ──────────────────────────────────────────────
{ label: 'Low', value: 'low' as ApprovalUrgency },
{ label: 'Normal', value: 'normal' as ApprovalUrgency }, readonly statusOptions: FilterChipOption[] = [
{ label: 'High', value: 'high' as ApprovalUrgency }, { id: '', label: 'All Status' },
{ label: 'Critical', value: 'critical' as ApprovalUrgency }, { 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 showBatchApproveDialog = signal(false);
readonly showBatchRejectDialog = signal(false); readonly showBatchRejectDialog = signal(false);
batchComment = ''; batchComment = '';
@@ -696,42 +595,21 @@ export class ApprovalQueueComponent implements OnInit {
this.store.loadApprovals(); this.store.loadApprovals();
} }
filterByStatus(statuses: ApprovalStatus[]): void { onStatusChipChange(value: string): void {
this.store.setStatusFilter(statuses); this.store.setStatusFilter(value ? [value as ApprovalStatus] : []);
this.currentPage.set(1);
this.store.loadApprovals(); this.store.loadApprovals();
} }
isStatusActive(statuses: ApprovalStatus[]): boolean { onUrgencyChipChange(value: string): void {
const current = this.store.statusFilter(); this.store.setUrgencyFilter(value ? [value as ApprovalUrgency] : []);
if (statuses.length === 0 && current.length === 0) return true; this.currentPage.set(1);
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);
this.store.loadApprovals(); this.store.loadApprovals();
} }
isUrgencySelected(urgency: ApprovalUrgency): boolean { onPageChange(event: PageChangeEvent): void {
return this.store.urgencyFilter().includes(urgency); this.currentPage.set(event.page);
} this.pageSize.set(event.pageSize);
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();
} }
isSelected(id: string): boolean { isSelected(id: string): boolean {

View File

@@ -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: `
<div class="hotfix-wizard">
<header class="hotfix-header">
<div class="hotfix-header__brand">
<span class="hotfix-header__badge">HOTFIX</span>
<div>
<h1>Create Hotfix</h1>
<p class="hotfix-header__sub">Single emergency image patch &mdash; fast-track deployment.</p>
</div>
</div>
<a routerLink="/releases/versions" class="btn-back">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Back
</a>
</header>
<!-- ─── Step 1: Select Package ─── -->
@if (!confirmed()) {
<section class="hotfix-card">
<div class="hotfix-card__step-label">
<span class="step-num">1</span>
Select Package
</div>
<label class="field">
<span class="field__label">Search registry</span>
<div class="search-wrap">
<svg class="search-wrap__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
class="search-wrap__input"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearch($event)"
placeholder="Search by image name, e.g. api-gateway"
/>
</div>
</label>
@if (store.searchResults().length > 0 && !selectedImage()) {
<ul class="search-results">
@for (image of store.searchResults(); track image.repository) {
<li>
<button type="button" class="search-item" (click)="selectImage(image)">
<strong>{{ image.name }}</strong>
<span class="search-item__repo">{{ image.repository }}</span>
<span class="search-item__tags">{{ image.tags.length }} tag(s)</span>
</button>
</li>
}
</ul>
}
@if (selectedImage(); as img) {
<div class="selected-image">
<div class="selected-image__header">
<div>
<h3>{{ img.name }}</h3>
<span class="selected-image__repo">{{ img.repository }}</span>
</div>
<button type="button" class="btn-clear" (click)="clearSelection()" aria-label="Clear selection">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="digest-list">
@for (d of img.digests; track d.digest) {
<button
type="button"
class="digest-option"
[class.digest-option--selected]="selectedDigest() === d.digest"
(click)="pickDigest(d)">
<span class="digest-option__tag">{{ d.tag || 'untagged' }}</span>
<code class="digest-option__hash">{{ fmtDigest(d.digest) }}</code>
<span class="digest-option__date">{{ d.pushedAt | slice:0:10 }}</span>
</button>
}
</div>
@if (selectedDigest()) {
<div class="chosen-summary">
<dl>
<dt>Repository</dt><dd>{{ img.repository }}</dd>
<dt>Tag</dt><dd>{{ selectedTag() || 'untagged' }}</dd>
<dt>Digest</dt><dd><code class="digest-full">{{ selectedDigest() }}</code></dd>
<dt>Pushed</dt><dd>{{ selectedPushedAt() | slice:0:10 }}</dd>
<dt>Hotfix name</dt><dd><strong>{{ derivedName() }}</strong></dd>
<dt>Hotfix version</dt><dd><code>{{ derivedVersion() }}</code></dd>
</dl>
</div>
}
</div>
<label class="field field--desc">
<span class="field__label">Notes (optional)</span>
<textarea [(ngModel)]="description" rows="2" placeholder="Brief reason for this hotfix"></textarea>
</label>
}
<div class="hotfix-card__actions">
<button
type="button"
class="btn-primary btn-continue"
[disabled]="!canContinue()"
(click)="confirmed.set(true)">
Continue to Confirm
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</section>
}
<!-- ─── Step 2: Confirm & Seal ─── -->
@if (confirmed()) {
<section class="hotfix-card hotfix-card--confirm">
<div class="hotfix-card__step-label">
<span class="step-num">2</span>
Confirm &amp; Seal
</div>
<div class="confirm-summary">
<dl>
<dt>Image</dt><dd><strong>{{ selectedImage()!.name }}</strong></dd>
<dt>Repository</dt><dd>{{ selectedImage()!.repository }}</dd>
<dt>Tag</dt><dd>{{ selectedTag() || 'untagged' }}</dd>
<dt>Digest</dt><dd><code class="digest-full">{{ selectedDigest() }}</code></dd>
<dt>Hotfix name</dt><dd><strong>{{ derivedName() }}</strong></dd>
<dt>Hotfix version</dt><dd><code>{{ derivedVersion() }}</code></dd>
</dl>
</div>
<div class="warning-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>Hotfixes bypass the standard promotion pipeline and deploy directly.</span>
</div>
<label class="checkbox-confirm">
<input type="checkbox" [(ngModel)]="sealConfirmed" />
<span>I confirm this hotfix is ready</span>
</label>
@if (submitError()) {
<div class="error-banner">{{ submitError() }}</div>
}
<div class="hotfix-card__actions hotfix-card__actions--confirm">
<button type="button" class="btn-secondary" (click)="confirmed.set(false)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Back
</button>
<button
type="button"
class="btn-hotfix"
[disabled]="!canSeal()"
(click)="sealAndCreate()">
@if (submitting()) {
<span class="spinner"></span>
Creating...
} @else {
Create Hotfix
}
</button>
</div>
</section>
}
</div>
`,
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<RegistryImage | null>(null);
readonly selectedDigest = signal('');
readonly selectedTag = signal('');
readonly selectedPushedAt = signal('');
readonly submitError = signal<string | null>(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()}`;
}
}

View File

@@ -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: `
<div class="create-version">
<header class="wizard-header">
<div class="wizard-header__title">
<h1>Create Version</h1>
<p class="wizard-header__sub">Define the artifact bundle: what images and scripts make up this version.</p>
</div>
<a routerLink="/releases/versions" class="btn-back">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Back to Versions
</a>
</header>
<!-- Step indicator -->
<nav class="stepper" aria-label="Create version steps">
@for (s of steps; track s.n) {
@if (s.n > 1) {
<div class="stepper__line" [class.done]="step() >= s.n"></div>
}
<button type="button" class="stepper__step"
[class.active]="step() === s.n"
[class.done]="step() > s.n"
[disabled]="s.n > step() + 1"
(click)="s.n <= step() ? step.set(s.n) : null">
<span class="stepper__circle">
@if (step() > s.n) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>
} @else {
{{ s.n }}
}
</span>
<span class="stepper__label">{{ s.label }}</span>
</button>
}
</nav>
<!-- Wizard body -->
<section class="wizard-body">
@switch (step()) {
@case (1) {
<div class="step-panel">
<div class="step-intro">
<h2>Version Identity</h2>
<p>Define the canonical name and version for this artifact bundle.</p>
</div>
<div class="form-row-2">
<label class="field">
<span class="field__label">Name <abbr title="required">*</abbr></span>
<input type="text" [(ngModel)]="form.name" [placeholder]="suggestedName()" />
<span class="field__hint">A short name for this artifact bundle</span>
</label>
<label class="field">
<span class="field__label">Version <abbr title="required">*</abbr></span>
<input type="text" [(ngModel)]="form.version" [placeholder]="suggestedVersion" />
<span class="field__hint">Semantic version, e.g. v1.0.0</span>
</label>
</div>
<label class="field">
<span class="field__label">Description</span>
<textarea [(ngModel)]="form.description" rows="3" placeholder="What changed in this version"></textarea>
</label>
</div>
}
@case (2) {
<div class="step-panel">
<div class="step-intro">
<h2>Components</h2>
<p>Search your registry and attach the images or scripts that compose this version.</p>
</div>
<!-- Type toggle -->
<div class="type-toggle-row">
<span class="field__label">Component type</span>
<div class="toggle-pair" role="radiogroup" aria-label="Component type">
<button type="button" role="radio" class="toggle-pair__btn"
[class.toggle-pair__btn--active]="componentType === 'container'"
[attr.aria-checked]="componentType === 'container'"
(click)="componentType = 'container'">Image</button>
<button type="button" role="radio" class="toggle-pair__btn"
[class.toggle-pair__btn--active]="componentType === 'script'"
[attr.aria-checked]="componentType === 'script'"
(click)="componentType = 'script'">Script</button>
</div>
</div>
<!-- Search input -->
<label class="field">
<span class="field__label">{{ componentType === 'container' ? 'Search registry' : 'Search scripts' }}</span>
<div class="search-input-wrap">
<svg class="search-input-wrap__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input
type="text"
class="search-input-wrap__input"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchImages($event)"
[placeholder]="componentType === 'container' ? 'Search by image name, e.g. checkout-api' : 'Search by script name'"
/>
</div>
</label>
<!-- Search results dropdown -->
@if (store.searchResults().length > 0) {
<div class="search-results">
@for (image of store.searchResults(); track image.repository) {
<button type="button" class="search-item" (click)="selectImage(image)">
<strong>{{ image.name }}</strong>
<span>{{ image.repository }}</span>
</button>
}
</div>
}
<!-- Selected image digest picker -->
@if (selectedImage) {
<div class="selection-panel">
<div class="selection-panel__header">
<h3>{{ selectedImage.name }}</h3>
<span class="selection-panel__repo">{{ selectedImage.repository }}</span>
</div>
<div class="digest-options">
@for (digest of selectedImage.digests; track digest.digest) {
<button
type="button"
class="digest-option"
[class.selected]="selectedDigest === digest.digest"
(click)="selectedDigest = digest.digest; selectedTag = digest.tag">
<span class="digest-option__tag">{{ digest.tag || 'untagged' }}</span>
<code>{{ formatDigest(digest.digest) }}</code>
</button>
}
</div>
<button type="button" class="btn-secondary btn-add-component" (click)="addSelectedComponent()" [disabled]="!selectedDigest">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Component
</button>
</div>
}
<!-- Component list -->
<div class="components-section">
<div class="components-section__header">
<h3>Selected components</h3>
<span class="components-section__count">{{ components.length }}</span>
</div>
@if (components.length === 0) {
<div class="components-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<p>No components added yet</p>
<p class="validation-note">At least one component is required to continue.</p>
</div>
} @else {
<table class="stella-table--bordered">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Tag</th>
<th>Digest</th>
<th></th>
</tr>
</thead>
<tbody>
@for (component of components; track component.name + component.digest; let idx = $index) {
<tr>
<td><strong>{{ component.name }}</strong></td>
<td><span class="type-badge">{{ component.type }}</span></td>
<td>{{ component.tag || '-' }}</td>
<td><code>{{ formatDigest(component.digest) }}</code></td>
<td>
<button type="button" class="btn-remove" (click)="removeComponent(idx)" aria-label="Remove component">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
@case (3) {
<div class="step-panel">
<div class="step-intro">
<h2>Review & Seal</h2>
<p>Verify all fields before sealing. Once sealed, the version identity becomes immutable.</p>
</div>
<div class="review-cards">
<div class="review-card">
<div class="review-card__header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<h3>Version identity</h3>
</div>
<dl class="review-card__dl">
<dt>Name</dt><dd><strong>{{ form.name }}</strong></dd>
<dt>Version</dt><dd>{{ form.version }}</dd>
<dt>Description</dt><dd>{{ form.description || 'none' }}</dd>
</dl>
</div>
<div class="review-card">
<div class="review-card__header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<h3>Components ({{ components.length }})</h3>
</div>
<div class="review-component-list">
@for (component of components; track component.name + component.digest) {
<div class="review-component-item">
<strong>{{ component.name }}</strong>
<span class="type-badge">{{ component.type }}</span>
<span class="review-component-item__tag">{{ component.tag || 'untagged' }}</span>
<code>{{ formatDigest(component.digest) }}</code>
</div>
}
</div>
</div>
</div>
<label class="seal-confirm">
<input type="checkbox" [(ngModel)]="sealConfirmed" />
<div class="seal-confirm__text">
<strong>I confirm this version is ready to seal</strong>
<span>The version identity will become immutable and evidence recording will begin.</span>
</div>
</label>
</div>
}
}
</section>
@if (submitError(); as err) {
<div class="wizard-error" role="alert">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>{{ err }}</span>
</div>
}
<footer class="wizard-actions">
<button type="button" class="btn-ghost" (click)="prevStep()" [disabled]="step() === 1 || submitting()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
Back
</button>
<div class="wizard-actions__spacer"></div>
<span class="wizard-actions__step-label">Step {{ step() }} of 3</span>
@if (step() < 3) {
<button type="button" class="btn-primary" (click)="nextStep()" [disabled]="!canContinueStep() || submitting()">
Continue
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
} @else {
<button type="button" class="btn-seal" (click)="sealVersion()" [disabled]="!canSeal()">
@if (submitting()) {
<span class="btn-seal__spinner"></span>
Sealing...
} @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Seal Version
}
</button>
}
</footer>
</div>
`,
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<string | null>(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()}`;
}
}

View File

@@ -7,3 +7,4 @@ export * from './releases.routes';
export * from './release-list/release-list.component'; export * from './release-list/release-list.component';
export * from './release-detail/release-detail.component'; export * from './release-detail/release-detail.component';
export * from './create-release/create-release.component'; export * from './create-release/create-release.component';
export * from './create-version/create-version.component';

View File

@@ -1,6 +1,5 @@
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) // Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003)
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core'; import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { PlatformContextStore } from '../../../../core/context/platform-context.store'; import { PlatformContextStore } from '../../../../core/context/platform-context.store';
@@ -14,13 +13,14 @@ import {
type ReleaseGateStatus, type ReleaseGateStatus,
type ReleaseRiskTier, type ReleaseRiskTier,
} from '../../../../core/api/release-management.models'; } 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 { DateFormatService } from '../../../../core/i18n/date-format.service';
import { PageActionService } from '../../../../core/services/page-action.service'; import { PageActionService } from '../../../../core/services/page-action.service';
@Component({ @Component({
selector: 'app-release-list', selector: 'app-release-list',
imports: [FormsModule, RouterModule, FilterBarComponent], imports: [RouterModule, StellaFilterChipComponent, PaginationComponent],
template: ` template: `
<div class="release-list"> <div class="release-list">
<header class="list-header"> <header class="list-header">
@@ -30,15 +30,19 @@ import { PageActionService } from '../../../../core/services/page-action.service
</div> </div>
</header> </header>
<app-filter-bar <div class="filters">
searchPlaceholder="Search by digest, release version name, or slug" <div class="filter-search">
[filters]="releaseFilterOptions" <svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
[activeFilters]="activeReleaseFilters()" <circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
(searchChange)="onReleaseSearch($event)" </svg>
(filterChange)="onReleaseFilterChanged($event)" <input type="text" class="filter-search__input" placeholder="Search by digest, version name, or slug..."
(filterRemove)="onReleaseFilterRemoved($event)" [value]="searchTerm" (input)="onSearchInput($event)" />
(filtersCleared)="clearAllReleaseFilters()" </div>
></app-filter-bar> <stella-filter-chip label="Type" [value]="typeFilter" [options]="typeOptions" (valueChange)="onTypeFilterChange($event)" />
<stella-filter-chip label="Gate" [value]="gateFilter" [options]="gateOptions" (valueChange)="onGateFilterChange($event)" />
<stella-filter-chip label="Risk" [value]="riskFilter" [options]="riskOptions" (valueChange)="onRiskFilterChange($event)" />
<stella-filter-chip label="Evidence" [value]="evidenceFilter" [options]="evidenceOptions" (valueChange)="onEvidenceFilterChange($event)" />
</div>
@if (selectedCount() > 0) { @if (selectedCount() > 0) {
<div class="bulk-bar"> <div class="bulk-bar">
@@ -146,7 +150,7 @@ import { PageActionService } from '../../../../core/services/page-action.service
</p> </p>
<div class="empty-state__actions"> <div class="empty-state__actions">
@if (hasActiveFilters()) { @if (hasActiveFilters()) {
<button type="button" class="btn-secondary" (click)="clearAllReleaseFilters()">Clear All Filters</button> <button type="button" class="btn-secondary" (click)="clearFilters()">Clear All Filters</button>
} }
<button type="button" class="btn-primary" (click)="createStandard()"> <button type="button" class="btn-primary" (click)="createStandard()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
@@ -156,7 +160,7 @@ import { PageActionService } from '../../../../core/services/page-action.service
</div> </div>
} @else { } @else {
<div class="table-container"> <div class="table-container">
<table class="release-table"> <table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead> <thead>
<tr> <tr>
<th class="col-check"> <th class="col-check">
@@ -228,23 +232,15 @@ import { PageActionService } from '../../../../core/services/page-action.service
</table> </table>
</div> </div>
@if (store.totalCount() > store.pageSize()) { <div style="display: flex; justify-content: flex-end; padding-top: 0.75rem;">
<div class="pagination"> <app-pagination
<button type="button" class="pagination__btn" [disabled]="store.currentPage() === 1" (click)="setPage(store.currentPage() - 1)"> [total]="store.totalCount()"
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg> [currentPage]="store.currentPage()"
Previous [pageSize]="store.pageSize()"
</button> [pageSizes]="[5, 10, 25, 50]"
<span class="pagination__info">Page {{ store.currentPage() }} of {{ Math.ceil(store.totalCount() / store.pageSize()) }}</span> (pageChange)="onPageChange($event)"
<button />
type="button" </div>
class="pagination__btn"
[disabled]="store.currentPage() * store.pageSize() >= store.totalCount()"
(click)="setPage(store.currentPage() + 1)">
Next
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
}
} }
</div> </div>
`, `,
@@ -284,6 +280,51 @@ import { PageActionService } from '../../../../core/services/page-action.service
flex-shrink: 0; 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 ─── */ /* ─── Buttons ─── */
.btn-primary, .btn-primary,
.btn-secondary { .btn-secondary {
@@ -515,56 +556,15 @@ import { PageActionService } from '../../../../core/services/page-action.service
background: var(--color-surface-primary); background: var(--color-surface-primary);
} }
.release-table { :host ::ng-deep .stella-table tbody tr.selected {
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 {
background: var(--color-selection-bg, var(--color-brand-primary-10)); 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); 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)); 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); 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 summary strip ─── */
.health-strip { .health-strip {
display: flex; display: flex;
@@ -823,6 +784,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
stageFilter = 'all'; stageFilter = 'all';
gateFilter = 'all'; gateFilter = 'all';
riskFilter = 'all'; riskFilter = 'all';
evidenceFilter = 'all';
blockedFilter = 'all'; blockedFilter = 'all';
needsApprovalFilter = 'all'; needsApprovalFilter = 'all';
hotfixLaneFilter = 'all'; hotfixLaneFilter = 'all';
@@ -831,21 +793,33 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
readonly selectedReleaseIds = signal<Set<string>>(new Set()); readonly selectedReleaseIds = signal<Set<string>>(new Set());
private applyingFromQuery = false; private applyingFromQuery = false;
// Shared filter bar integration // Inline filter chip options
readonly releaseFilterOptions: FilterOption[] = [ readonly typeOptions: FilterChipOption[] = [
{ key: 'type', label: 'Type', options: [{ value: 'standard', label: 'Standard' }, { value: 'hotfix', label: 'Hotfix' }] }, { id: '', label: 'All Types' },
{ 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' }] }, { id: 'standard', label: 'Standard' },
{ 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' }] }, { id: 'hotfix', label: 'Hotfix' },
{ 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' }] }, readonly gateOptions: FilterChipOption[] = [
{ key: 'needsApproval', label: 'Needs Approval', options: [{ value: 'true', label: 'Needs Approval' }, { value: 'false', label: 'No Approval Needed' }] }, { id: '', label: 'All Gates' },
{ key: 'hotfixLane', label: 'Hotfix Lane', options: [{ value: 'true', label: 'Hotfix Lane' }, { value: 'false', label: 'Standard Lane' }] }, { id: 'pass', label: 'Pass' },
{ key: 'replayMismatch', label: 'Replay Mismatch', options: [{ value: 'true', label: 'Mismatch' }, { value: 'false', label: 'No Mismatch' }] }, { 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<ActiveFilter[]>([]);
readonly Math = Math;
readonly getGateStatusLabel = getGateStatusLabel; readonly getGateStatusLabel = getGateStatusLabel;
readonly getRiskTierLabel = getRiskTierLabel; readonly getRiskTierLabel = getRiskTierLabel;
readonly getEvidencePostureLabel = getEvidencePostureLabel; readonly getEvidencePostureLabel = getEvidencePostureLabel;
@@ -882,6 +856,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
this.stageFilter = params.get('stage') ?? 'all'; this.stageFilter = params.get('stage') ?? 'all';
this.gateFilter = params.get('gate') ?? 'all'; this.gateFilter = params.get('gate') ?? 'all';
this.riskFilter = params.get('risk') ?? 'all'; this.riskFilter = params.get('risk') ?? 'all';
this.evidenceFilter = params.get('evidence') ?? 'all';
this.blockedFilter = params.get('blocked') ?? 'all'; this.blockedFilter = params.get('blocked') ?? 'all';
this.needsApprovalFilter = params.get('needsApproval') ?? 'all'; this.needsApprovalFilter = params.get('needsApproval') ?? 'all';
this.hotfixLaneFilter = params.get('hotfixLane') ?? 'all'; this.hotfixLaneFilter = params.get('hotfixLane') ?? 'all';
@@ -895,83 +870,45 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
this.pageAction.clear(); this.pageAction.clear();
} }
onReleaseSearch(value: string): void { onSearchInput(event: Event): void {
this.searchTerm = value; this.searchTerm = (event.target as HTMLInputElement).value;
this.applyFilters(false); this.applyFilters(false);
} }
onReleaseFilterChanged(filter: ActiveFilter): void { onTypeFilterChange(value: string): void {
const filterMap: Record<string, string> = { this.typeFilter = value || 'all';
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;
}
this.applyFilters(false); this.applyFilters(false);
} }
onReleaseFilterRemoved(filter: ActiveFilter): void { onGateFilterChange(value: string): void {
const filterMap: Record<string, string> = { this.gateFilter = value || 'all';
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';
}
this.applyFilters(false); 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.searchTerm = '';
this.typeFilter = 'all'; this.typeFilter = 'all';
this.stageFilter = 'all'; this.stageFilter = 'all';
this.gateFilter = 'all'; this.gateFilter = 'all';
this.riskFilter = 'all'; this.riskFilter = 'all';
this.evidenceFilter = 'all';
this.blockedFilter = 'all'; this.blockedFilter = 'all';
this.needsApprovalFilter = 'all'; this.needsApprovalFilter = 'all';
this.hotfixLaneFilter = 'all'; this.hotfixLaneFilter = 'all';
this.replayMismatchFilter = 'all'; this.replayMismatchFilter = 'all';
this.activeReleaseFilters.set([]);
this.applyFilters(false); 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 { applyFilters(fromQuery: boolean): void {
const filter: ReleaseFilter = { const filter: ReleaseFilter = {
search: this.searchTerm.trim() || undefined, search: this.searchTerm.trim() || undefined,
@@ -988,7 +925,6 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
}; };
this.store.setFilter(filter); this.store.setFilter(filter);
this.rebuildActiveReleaseFilters();
if (!fromQuery && !this.applyingFromQuery) { if (!fromQuery && !this.applyingFromQuery) {
void this.router.navigate([], { void this.router.navigate([], {
@@ -1003,6 +939,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
this.store.setPage(page); this.store.setPage(page);
} }
onPageChange(event: PageChangeEvent): void {
this.store.setPage(event.page);
this.store.setPageSize(event.pageSize);
}
toggleRelease(releaseId: string, event: Event): void { toggleRelease(releaseId: string, event: Event): void {
const checked = (event.target as HTMLInputElement).checked; const checked = (event.target as HTMLInputElement).checked;
this.selectedReleaseIds.update((current) => { this.selectedReleaseIds.update((current) => {
@@ -1046,7 +987,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
} }
hasActiveFilters(): boolean { 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 { clearSelection(): void {
@@ -1119,6 +1064,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
stage: this.stageFilter !== 'all' ? this.stageFilter : null, stage: this.stageFilter !== 'all' ? this.stageFilter : null,
gate: this.gateFilter !== 'all' ? this.gateFilter : null, gate: this.gateFilter !== 'all' ? this.gateFilter : null,
risk: this.riskFilter !== 'all' ? this.riskFilter : null, risk: this.riskFilter !== 'all' ? this.riskFilter : null,
evidence: this.evidenceFilter !== 'all' ? this.evidenceFilter : null,
blocked: this.blockedFilter !== 'all' ? this.blockedFilter : null, blocked: this.blockedFilter !== 'all' ? this.blockedFilter : null,
needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null, needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null,
hotfixLane: this.hotfixLaneFilter !== 'all' ? this.hotfixLaneFilter : null, hotfixLane: this.hotfixLaneFilter !== 'all' ? this.hotfixLaneFilter : null,

View File

@@ -12,6 +12,13 @@ export const RELEASE_ROUTES: Routes = [
(m) => m.ReleaseListComponent (m) => m.ReleaseListComponent
), ),
}, },
{
path: 'new',
loadComponent: () =>
import('./create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent
),
},
{ {
path: 'create', path: 'create',
loadComponent: () => loadComponent: () =>
@@ -19,6 +26,27 @@ export const RELEASE_ROUTES: Routes = [
(m) => m.CreateReleaseComponent (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', path: ':id',
loadComponent: () => loadComponent: () =>

View File

@@ -6,7 +6,8 @@ import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store'; import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component'; 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'; import { DateFormatService } from '../../core/i18n/date-format.service';
@@ -55,7 +56,7 @@ function deriveOutcomeIcon(status: string): string {
@Component({ @Component({
selector: 'app-releases-activity', selector: 'app-releases-activity',
standalone: true, standalone: true,
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, FilterBarComponent], imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent],
template: ` template: `
<section class="activity"> <section class="activity">
<header> <header>
@@ -76,15 +77,19 @@ function deriveOutcomeIcon(status: string): string {
ariaLabel="Run list views" ariaLabel="Run list views"
/> />
<app-filter-bar <div class="activity-filters">
searchPlaceholder="Search runs..." <div class="filter-search">
[filters]="activityFilterOptions" <svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
[activeFilters]="activityActiveFilters()" <circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
(searchChange)="onActivitySearch($event)" </svg>
(filterChange)="onActivityFilterAdded($event)" <input type="text" class="filter-search__input" placeholder="Search activity..."
(filterRemove)="onActivityFilterRemoved($event)" [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
(filtersCleared)="clearAllActivityFilters()" </div>
/> <stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusChipOptions" (valueChange)="statusFilter.set($event); currentPage.set(1); applyFilters()" />
<stella-filter-chip label="Lane" [value]="laneFilter()" [options]="laneChipOptions" (valueChange)="laneFilter.set($event); currentPage.set(1); applyFilters()" />
<stella-filter-chip label="Env" [value]="envFilter()" [options]="envChipOptions" (valueChange)="envFilter.set($event); currentPage.set(1); applyFilters()" />
<stella-filter-chip label="Outcome" [value]="outcomeFilter()" [options]="outcomeChipOptions" (valueChange)="outcomeFilter.set($event); currentPage.set(1); applyFilters()" />
</div>
@if (error()) { @if (error()) {
<div class="banner error">{{ error() }}</div> <div class="banner error">{{ error() }}</div>
@@ -139,7 +144,7 @@ function deriveOutcomeIcon(status: string): string {
</div> </div>
} }
@default { @default {
<table> <table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead> <thead>
<tr> <tr>
<th>Run</th> <th>Run</th>
@@ -153,7 +158,7 @@ function deriveOutcomeIcon(status: string): string {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (row of filteredRows(); track row.activityId) { @for (row of pagedRows(); track row.activityId) {
<tr> <tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td> <td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td> <td>{{ row.releaseName }}</td>
@@ -169,6 +174,15 @@ function deriveOutcomeIcon(status: string): string {
} }
</tbody> </tbody>
</table> </table>
<div style="display: flex; justify-content: flex-end; padding-top: 0.75rem;">
<app-pagination
[total]="filteredRows().length"
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[pageSizes]="[5, 10, 25, 50]"
(pageChange)="onPageChange($event)"
/>
</div>
} }
} }
} }
@@ -178,10 +192,22 @@ function deriveOutcomeIcon(status: string): string {
.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} .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)} .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)} .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} .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}
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}
/* Timeline container */ /* Timeline container */
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem} .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-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{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
.run-link:hover{text-decoration:underline} .run-link:hover{text-decoration:underline}
@media (max-width: 768px) {
.activity-filters { gap: 0.375rem; }
.filter-search { flex: 1 1 100%; }
}
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
@@ -211,70 +242,40 @@ export class ReleasesActivityComponent {
readonly rows = signal<ReleaseActivityProjection[]>([]); readonly rows = signal<ReleaseActivityProjection[]>([]);
readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline'); readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline');
// ── Filter-bar configuration ────────────────────────────────────────── // ── Filter-chip options ──────────────────────────────────────────────
readonly activityFilterOptions: FilterOption[] = [ readonly statusChipOptions: FilterChipOption[] = [
{ key: 'status', label: 'Status', options: [ { id: '', label: 'All Status' },
{ value: 'pending_approval', label: 'Pending Approval' }, { id: 'pending_approval', label: 'Pending' },
{ value: 'approved', label: 'Approved' }, { id: 'approved', label: 'Approved' },
{ value: 'published', label: 'Published' }, { id: 'published', label: 'Published' },
{ value: 'blocked', label: 'Blocked' }, { id: 'blocked', label: 'Blocked' },
{ value: 'rejected', label: 'Rejected' }, { id: 'rejected', label: 'Rejected' },
]}, ];
{ key: 'lane', label: 'Lane', options: [ readonly laneChipOptions: FilterChipOption[] = [
{ value: 'standard', label: 'Standard' }, { id: '', label: 'All Lanes' },
{ value: 'hotfix', label: 'Hotfix' }, { id: 'standard', label: 'Standard' },
]}, { id: 'hotfix', label: 'Hotfix' },
{ key: 'env', label: 'Environment', options: [ ];
{ value: 'dev', label: 'Dev' }, readonly envChipOptions: FilterChipOption[] = [
{ value: 'stage', label: 'Stage' }, { id: '', label: 'All Envs' },
{ value: 'prod', label: 'Prod' }, { id: 'dev', label: 'Dev' },
]}, { id: 'stage', label: 'Stage' },
{ key: 'outcome', label: 'Outcome', options: [ { id: 'prod', label: 'Prod' },
{ value: 'success', label: 'Success' }, ];
{ value: 'in_progress', label: 'In Progress' }, readonly outcomeChipOptions: FilterChipOption[] = [
{ value: 'failed', label: 'Failed' }, { id: '', label: 'All Outcomes' },
]}, { id: 'success', label: 'Success' },
{ key: 'needsApproval', label: 'Needs Approval', options: [ { id: 'in_progress', label: 'In Progress' },
{ value: 'true', label: 'Needs Approval' }, { id: 'failed', label: 'Failed' },
{ value: 'false', label: 'No Approval Needed' },
]},
{ key: 'integrity', label: 'Data Integrity', options: [
{ value: 'blocked', label: 'Blocked' },
{ value: 'clear', label: 'Clear' },
]},
]; ];
readonly statusFilter = signal('all'); readonly statusFilter = signal('');
readonly laneFilter = signal('all'); readonly laneFilter = signal('');
readonly envFilter = signal('all'); readonly envFilter = signal('');
readonly outcomeFilter = signal('all'); readonly outcomeFilter = signal('');
readonly needsApprovalFilter = signal('all');
readonly integrityFilter = signal('all');
readonly searchQuery = signal(''); readonly searchQuery = signal('');
readonly activityActiveFilters = computed<ActiveFilter[]>(() => {
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(() => { readonly filteredRows = computed(() => {
let rows = [...this.rows()]; let rows = [...this.rows()];
@@ -282,32 +283,42 @@ export class ReleasesActivityComponent {
const laneF = this.laneFilter(); const laneF = this.laneFilter();
const envF = this.envFilter(); const envF = this.envFilter();
const outcomeF = this.outcomeFilter(); const outcomeF = this.outcomeFilter();
const needsApprovalF = this.needsApprovalFilter(); const q = this.searchQuery().toLowerCase().trim();
const integrityF = this.integrityFilter();
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); rows = rows.filter((item) => item.status.toLowerCase() === statusF);
} }
if (laneF !== 'all') { if (laneF !== '') {
rows = rows.filter((item) => this.deriveLane(item) === laneF); rows = rows.filter((item) => this.deriveLane(item) === laneF);
} }
if (envF !== 'all') { if (envF !== '') {
rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(envF)); rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(envF));
} }
if (outcomeF !== 'all') { if (outcomeF !== '') {
rows = rows.filter((item) => this.deriveOutcome(item) === 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; 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. */ /** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
readonly timelineEvents = computed<TimelineEvent[]>(() => { readonly timelineEvents = computed<TimelineEvent[]>(() => {
return this.filteredRows().map((row) => ({ 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('lane')) this.laneFilter.set(params.get('lane')!);
if (params.get('env')) this.envFilter.set(params.get('env')!); if (params.get('env')) this.envFilter.set(params.get('env')!);
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!); 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(() => { effect(() => {
@@ -383,12 +392,10 @@ export class ReleasesActivityComponent {
mergeQuery(next: Record<string, string>): Record<string, string | null> { mergeQuery(next: Record<string, string>): Record<string, string | null> {
return { return {
view: next['view'] ?? this.viewMode(), view: next['view'] ?? this.viewMode(),
status: this.statusFilter() !== 'all' ? this.statusFilter() : null, status: this.statusFilter() !== '' ? this.statusFilter() : null,
lane: this.laneFilter() !== 'all' ? this.laneFilter() : null, lane: this.laneFilter() !== '' ? this.laneFilter() : null,
env: this.envFilter() !== 'all' ? this.envFilter() : null, env: this.envFilter() !== '' ? this.envFilter() : null,
outcome: this.outcomeFilter() !== 'all' ? this.outcomeFilter() : null, outcome: this.outcomeFilter() !== '' ? this.outcomeFilter() : null,
needsApproval: this.needsApprovalFilter() !== 'all' ? this.needsApprovalFilter() : null,
integrity: this.integrityFilter() !== 'all' ? this.integrityFilter() : null,
}; };
} }
@@ -400,45 +407,9 @@ export class ReleasesActivityComponent {
}); });
} }
// ── Filter-bar handlers ──────────────────────────────────────────────── onPageChange(event: PageChangeEvent): void {
this.currentPage.set(event.page);
onActivitySearch(query: string): void { this.pageSize.set(event.pageSize);
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();
} }
deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' { deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' {

View File

@@ -12,8 +12,7 @@ import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, signal,
import { PageActionService } from '../../core/services/page-action.service'; import { PageActionService } from '../../core/services/page-action.service';
import { UpperCasePipe, SlicePipe } from '@angular/common'; import { UpperCasePipe, SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component'; import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
import { TableColumn } from '../../shared/components/data-table/data-table.component'; import { TableColumn } from '../../shared/components/data-table/data-table.component';
@@ -122,8 +121,7 @@ const MOCK_RELEASES: PipelineRelease[] = [
UpperCasePipe, UpperCasePipe,
SlicePipe, SlicePipe,
RouterLink, RouterLink,
FormsModule, StellaFilterChipComponent,
FilterBarComponent,
PaginationComponent, PaginationComponent,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -137,16 +135,17 @@ const MOCK_RELEASES: PipelineRelease[] = [
</header> </header>
<!-- Pipeline --> <!-- Pipeline -->
<div class="rup__toolbar"> <div class="rup__filters">
<app-filter-bar <div class="rup__search">
searchPlaceholder="Search releases..." <svg class="rup__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
[filters]="pipelineFilterOptions" <circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
[activeFilters]="pipelineActiveFilters()" </svg>
(searchChange)="searchQuery.set($event); currentPage.set(1)" <input type="text" class="rup__search-input" placeholder="Search releases..."
(filterChange)="onPipelineFilterAdded($event)" [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
(filterRemove)="onPipelineFilterRemoved($event)" </div>
(filtersCleared)="clearAllPipelineFilters()" <stella-filter-chip label="Lane" [value]="laneFilter()" [options]="laneOptions" (valueChange)="laneFilter.set($event); currentPage.set(1)" />
/> <stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusOptions" (valueChange)="statusFilter.set($event); currentPage.set(1)" />
<stella-filter-chip label="Gates" [value]="gateFilter()" [options]="gateOptions" (valueChange)="gateFilter.set($event); currentPage.set(1)" />
</div> </div>
<!-- Releases table --> <!-- Releases table -->
@@ -319,21 +318,17 @@ const MOCK_RELEASES: PipelineRelease[] = [
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; } .rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; } .rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; } .rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
.rup__toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; margin-bottom: 1rem; } .rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
:host ::ng-deep app-filter-bar { flex: 1 1 0; min-width: 0; } .rup__search { position: relative; flex: 0 1 240px; min-width: 160px; }
.rup__toolbar-actions { display: flex; gap: 0.375rem; margin-left: auto; padding-top: 0.5rem; } .rup__search-icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
.rup__search-input {
.btn { width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
display: inline-flex; align-items: center; gap: 0.375rem; padding: 0 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
border: none; border-radius: var(--radius-md, 6px); font-size: 0.75rem; background: transparent; color: var(--color-text-primary);
font-weight: var(--font-weight-semibold, 600); cursor: pointer; text-decoration: none; font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
white-space: nowrap; transition: background 150ms ease, box-shadow 150ms ease; line-height: 1;
} }
.btn--sm { height: 32px; } .rup__search-input:focus { border-color: var(--color-brand-primary); }
.btn--primary { background: var(--color-btn-primary-bg); color: var(--color-surface-inverse, #fff); } .rup__search-input::placeholder { color: var(--color-text-muted); }
.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__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } .rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
@@ -429,8 +424,8 @@ const MOCK_RELEASES: PipelineRelease[] = [
@media (max-width: 768px) { @media (max-width: 768px) {
.rup { padding: 1rem; } .rup { padding: 1rem; }
.rup__toolbar { flex-direction: column; align-items: stretch; } .rup__filters { gap: 0.375rem; }
.rup__toolbar-actions { margin-left: 0; justify-content: flex-end; } .rup__search { flex: 1 1 100%; }
} }
`], `],
}) })
@@ -438,33 +433,34 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
private readonly pageAction = inject(PageActionService); private readonly pageAction = inject(PageActionService);
ngOnInit(): void { ngOnInit(): void {
this.pageAction.set({ label: 'New Release', route: '/releases/versions/new' }); this.pageAction.set({ label: 'New Release', route: '/releases/new' });
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.pageAction.clear(); this.pageAction.clear();
} }
// ── Filter-bar configuration ────────────────────────────────────────── // ── Filter-chip options ──────────────────────────────────────────────
readonly pipelineFilterOptions: FilterOption[] = [ readonly laneOptions: FilterChipOption[] = [
{ key: 'lane', label: 'Lane', options: [ { id: '', label: 'All Lanes' },
{ value: 'standard', label: 'Standard' }, { id: 'standard', label: 'Standard' },
{ value: 'hotfix', label: 'Hotfix' }, { id: 'hotfix', label: 'Hotfix' },
]}, ];
{ key: 'status', label: 'Status', options: [ readonly statusOptions: FilterChipOption[] = [
{ value: 'draft', label: 'Draft' }, { id: '', label: 'All Status' },
{ value: 'ready', label: 'Ready' }, { id: 'draft', label: 'Draft' },
{ value: 'deploying', label: 'Deploying' }, { id: 'ready', label: 'Ready' },
{ value: 'deployed', label: 'Deployed' }, { id: 'deploying', label: 'Deploying' },
{ value: 'failed', label: 'Failed' }, { id: 'deployed', label: 'Deployed' },
{ value: 'rolled_back', label: 'Rolled Back' }, { id: 'failed', label: 'Failed' },
]}, { id: 'rolled_back', label: 'Rolled Back' },
{ key: 'gate', label: 'Gates', options: [ ];
{ value: 'pass', label: 'Pass' }, readonly gateOptions: FilterChipOption[] = [
{ value: 'warn', label: 'Warn' }, { id: '', label: 'All Gates' },
{ value: 'block', label: 'Block' }, { id: 'pass', label: 'Pass' },
]}, { id: 'warn', label: 'Warn' },
{ id: 'block', label: 'Block' },
]; ];
// ── Columns definition ─────────────────────────────────────────────── // ── Columns definition ───────────────────────────────────────────────
@@ -483,32 +479,11 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES); readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
readonly searchQuery = signal(''); readonly searchQuery = signal('');
readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all'); readonly laneFilter = signal('');
readonly statusFilter = signal<string>('all'); readonly statusFilter = signal('');
readonly gateFilter = signal<string>('all'); readonly gateFilter = signal('');
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null); readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
readonly pipelineActiveFilters = computed<ActiveFilter[]>(() => {
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 ──────────────────────────────────────────────────────── // ── Pagination ────────────────────────────────────────────────────────
readonly currentPage = signal(1); readonly currentPage = signal(1);
@@ -529,13 +504,13 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
r.digest.toLowerCase().includes(q), r.digest.toLowerCase().includes(q),
); );
} }
if (lane !== 'all') { if (lane !== '') {
list = list.filter((r) => r.lane === lane); list = list.filter((r) => r.lane === lane);
} }
if (status !== 'all') { if (status !== '') {
list = list.filter((r) => r.status === status); list = list.filter((r) => r.status === status);
} }
if (gate !== 'all') { if (gate !== '') {
list = list.filter((r) => r.gateStatus === gate); list = list.filter((r) => r.gateStatus === gate);
} }
return list; return list;
@@ -580,33 +555,6 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
this.pageSize.set(event.pageSize); 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 ──────────────────────────────────────────────────── // ── Sort handlers ────────────────────────────────────────────────────
toggleSort(columnKey: string): void { toggleSort(columnKey: string): void {

View File

@@ -67,9 +67,27 @@ export const RELEASES_ROUTES: Routes = [
(m) => m.ReleaseListComponent, (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', 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' }, data: { breadcrumb: 'Create Release Version', semanticObject: 'version' },
loadComponent: () => loadComponent: () =>
import('../features/release-orchestrator/releases/create-release/create-release.component').then( import('../features/release-orchestrator/releases/create-release/create-release.component').then(
@@ -179,11 +197,10 @@ export const RELEASES_ROUTES: Routes = [
path: 'hotfixes/new', path: 'hotfixes/new',
title: 'Create Hotfix', title: 'Create Hotfix',
data: { breadcrumb: 'Create Hotfix' }, data: { breadcrumb: 'Create Hotfix' },
pathMatch: 'full', loadComponent: () =>
redirectTo: preserveReleasesRedirectWithQuery('/releases/versions/new', { import('../features/release-orchestrator/releases/create-hotfix/create-hotfix.component').then(
type: 'hotfix', (m) => m.CreateHotfixComponent,
hotfixLane: 'true', ),
}),
}, },
{ {
path: 'hotfixes/:hotfixId', path: 'hotfixes/:hotfixId',

View File

@@ -210,14 +210,24 @@ internal static class ElkEdgeChannels
var routeMode = EdgeRouteMode.Direct; var routeMode = EdgeRouteMode.Direct;
if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id))
{ {
var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label); var sourceNode = positionedNodes[sorted[index].SourceNodeId];
if (familyKey is "failure" or "timeout") 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 else
{ {
routeMode = EdgeRouteMode.SinkOuter; var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label);
if (familyKey is "failure" or "timeout")
{
routeMode = EdgeRouteMode.SinkOuterTop;
}
else
{
routeMode = EdgeRouteMode.SinkOuter;
}
} }
} }

View File

@@ -153,9 +153,10 @@ internal static class ElkEdgeRouter
} }
var channel = edgeChannels.GetValueOrDefault(edge.Id); var channel = edgeChannels.GetValueOrDefault(edge.Id);
if (channel.RouteMode != EdgeRouteMode.Direct var useCorridorRouting = channel.RouteMode != EdgeRouteMode.Direct
|| !string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.SourcePortId)
|| !string.IsNullOrWhiteSpace(edge.TargetPortId)) || !string.IsNullOrWhiteSpace(edge.TargetPortId);
if (useCorridorRouting)
{ {
reconstructed[edge.Id] = RouteEdge( reconstructed[edge.Id] = RouteEdge(
edge, edge,

View File

@@ -184,3 +184,34 @@ internal static class ElkLayoutHelpers
}; };
} }
} }
internal static class ElkSharpLayoutHelpers
{
internal static EdgeChannel ResolveSinkOverride(
EdgeChannel channel, string edgeId,
DummyNodeResult dummyResult,
IReadOnlyDictionary<string, EdgeChannel> edgeChannels,
IReadOnlyCollection<ElkEdge> 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;
}
}

View File

@@ -91,10 +91,17 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
edgeChannels, edgeChannels,
layerBoundariesByNodeId); layerBoundariesByNodeId);
var routedEdges = graph.Edges var routedEdges = graph.Edges
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed) .Select(edge =>
? routed {
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, if (reconstructedEdges.TryGetValue(edge.Id, out var routed))
edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId)) {
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(); .ToArray();
for (var gutterPass = 0; gutterPass < 3; gutterPass++) 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) .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
? rerouted ? rerouted
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, : 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(); .ToArray();
} }
@@ -160,7 +167,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
? rerouted ? rerouted
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, : 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(); .ToArray();
if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters( if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters(
@@ -191,7 +198,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
? rerouted ? rerouted
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, : 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(); .ToArray();
} }