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:
67
docs/implplan/SPRINT_20260322_001_FE_wizard_split.md
Normal file
67
docs/implplan/SPRINT_20260322_001_FE_wizard_split.md
Normal 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.
|
||||
@@ -162,10 +162,114 @@ const MY_TABS: readonly StellaPageTab[] = [
|
||||
- Do NOT duplicate the tab bar styling — the component owns all tab CSS
|
||||
- The component provides: keyboard navigation, ARIA roles, active background, bottom border, icon opacity transitions, panel border-radius, enter animation
|
||||
|
||||
## Table Styling Convention
|
||||
All HTML tables must use the `stella-table` CSS class for consistent styling.
|
||||
Never define custom table padding, borders, or header styles inline.
|
||||
Use the shared data-table component when possible, or the stella-table class for simple static tables.
|
||||
## Data Table Convention (MANDATORY)
|
||||
|
||||
All data tables **must** use `<app-data-table>` for interactive tables or `.stella-table` CSS classes for simple static tables.
|
||||
Do NOT create custom table styling, sort headers, or selection checkboxes.
|
||||
|
||||
**Components:**
|
||||
- `shared/components/data-table/data-table.component.ts` — interactive table (sorting, selection, templates)
|
||||
- `shared/components/pagination/pagination.component.ts` — page navigation
|
||||
- `src/styles/_tables.scss` — global `.stella-table` CSS classes
|
||||
|
||||
**Usage (interactive table with sorting + pagination):**
|
||||
```html
|
||||
<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)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Approval Queue Component
|
||||
* Sprint: SPRINT_20260110_111_005_FE_promotion_approval_ui
|
||||
*/
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
formatTimeRemaining,
|
||||
} from '../../../../core/api/approval.models';
|
||||
import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component';
|
||||
import { PaginationComponent, type PageChangeEvent } from '../../../../shared/components/pagination/pagination.component';
|
||||
import { StellaFilterChipComponent, type FilterChipOption } from '../../../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
|
||||
import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
@Component({
|
||||
selector: 'app-approval-queue',
|
||||
imports: [RouterLink, FormsModule, LoadingStateComponent],
|
||||
imports: [RouterLink, FormsModule, LoadingStateComponent, PaginationComponent, StellaFilterChipComponent],
|
||||
template: `
|
||||
<div class="approval-queue-container">
|
||||
<header class="page-header">
|
||||
@@ -30,54 +32,17 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label>Urgency:</label>
|
||||
<div class="filter-chips">
|
||||
@for (urgency of urgencyOptions; track urgency.value) {
|
||||
<button
|
||||
class="filter-chip"
|
||||
[class.active]="isUrgencySelected(urgency.value)"
|
||||
(click)="toggleUrgencyFilter(urgency.value)"
|
||||
>
|
||||
{{ urgency.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="filters">
|
||||
<div class="filter-search">
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="filter-search__input" placeholder="Search approvals..."
|
||||
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
|
||||
</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>
|
||||
|
||||
<!-- Batch Actions -->
|
||||
@@ -113,8 +78,8 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
|
||||
<!-- Approval List -->
|
||||
@if (!store.loading() && !store.error()) {
|
||||
<div class="approval-table">
|
||||
<table>
|
||||
<div class="approval-table-wrap">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-checkbox">
|
||||
@@ -134,7 +99,7 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
</tr>
|
||||
</thead>
|
||||
<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)">
|
||||
<td class="col-checkbox">
|
||||
<input
|
||||
@@ -203,9 +168,6 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
<td colspan="8" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<p>No approval requests found</p>
|
||||
@if (hasActiveFilters()) {
|
||||
<button class="btn btn-secondary" (click)="clearFilters()">Clear Filters</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -213,6 +175,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
</tbody>
|
||||
</table>
|
||||
</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 -->
|
||||
@@ -282,106 +254,17 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-switcher {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm, 0.75rem);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-segment:not(:last-child) {
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.status-segment:hover:not(.status-segment--active) {
|
||||
background: var(--color-surface-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-segment:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.status-segment--active {
|
||||
background: var(--color-text-heading);
|
||||
color: var(--color-surface-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.status-segment--active:hover {
|
||||
background: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.status-segment__count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--color-surface-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: var(--color-status-info);
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background: var(--color-status-info);
|
||||
color: var(--color-surface-primary);
|
||||
border-color: var(--color-status-info);
|
||||
.filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.filter-search { position: relative; flex: 0 1 240px; min-width: 160px; }
|
||||
.filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
|
||||
.filter-search__input {
|
||||
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--color-text-primary);
|
||||
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
|
||||
}
|
||||
.filter-search__input:focus { border-color: var(--color-brand-primary); }
|
||||
.filter-search__input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
@@ -398,48 +281,16 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.approval-table {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
.approval-table-wrap {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.approval-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.approval-table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-surface-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.approval-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-surface-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.approval-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.approval-table tr:hover {
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.approval-table tr.selected {
|
||||
.approval-table-wrap tr.selected {
|
||||
background: var(--color-status-info-bg);
|
||||
}
|
||||
|
||||
.approval-table tr.expiring-soon {
|
||||
.approval-table-wrap tr.expiring-soon {
|
||||
background: var(--color-status-error-bg);
|
||||
}
|
||||
|
||||
@@ -667,6 +518,11 @@ import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters { gap: 0.375rem; }
|
||||
.filter-search { flex: 1 1 100%; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ApprovalQueueComponent implements OnInit {
|
||||
@@ -681,13 +537,56 @@ export class ApprovalQueueComponent implements OnInit {
|
||||
readonly getUrgencyColor = getUrgencyColor;
|
||||
readonly formatTimeRemaining = formatTimeRemaining;
|
||||
|
||||
readonly urgencyOptions = [
|
||||
{ label: 'Low', value: 'low' as ApprovalUrgency },
|
||||
{ label: 'Normal', value: 'normal' as ApprovalUrgency },
|
||||
{ label: 'High', value: 'high' as ApprovalUrgency },
|
||||
{ label: 'Critical', value: 'critical' as ApprovalUrgency },
|
||||
// ── Filter-chip options ──────────────────────────────────────────────
|
||||
|
||||
readonly statusOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Status' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
readonly urgencyChipOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Urgency' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'normal', label: 'Normal' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'critical', label: 'Critical' },
|
||||
];
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly searchQuery = signal('');
|
||||
readonly currentPage = signal(1);
|
||||
readonly pageSize = signal(10);
|
||||
|
||||
readonly statusChipValue = computed(() => {
|
||||
const f = this.store.statusFilter();
|
||||
return f.length === 1 ? f[0] : '';
|
||||
});
|
||||
|
||||
readonly urgencyChipValue = computed(() => {
|
||||
const f = this.store.urgencyFilter();
|
||||
return f.length === 1 ? f[0] : '';
|
||||
});
|
||||
|
||||
readonly pagedApprovals = computed(() => {
|
||||
let all = this.store.filteredApprovals();
|
||||
const q = this.searchQuery().toLowerCase().trim();
|
||||
if (q) {
|
||||
all = all.filter(
|
||||
(a) =>
|
||||
a.releaseName.toLowerCase().includes(q) ||
|
||||
a.releaseVersion.toLowerCase().includes(q) ||
|
||||
a.sourceEnvironment.toLowerCase().includes(q) ||
|
||||
a.targetEnvironment.toLowerCase().includes(q) ||
|
||||
a.requestedBy.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
const start = (this.currentPage() - 1) * this.pageSize();
|
||||
return all.slice(start, start + this.pageSize());
|
||||
});
|
||||
|
||||
readonly showBatchApproveDialog = signal(false);
|
||||
readonly showBatchRejectDialog = signal(false);
|
||||
batchComment = '';
|
||||
@@ -696,42 +595,21 @@ export class ApprovalQueueComponent implements OnInit {
|
||||
this.store.loadApprovals();
|
||||
}
|
||||
|
||||
filterByStatus(statuses: ApprovalStatus[]): void {
|
||||
this.store.setStatusFilter(statuses);
|
||||
onStatusChipChange(value: string): void {
|
||||
this.store.setStatusFilter(value ? [value as ApprovalStatus] : []);
|
||||
this.currentPage.set(1);
|
||||
this.store.loadApprovals();
|
||||
}
|
||||
|
||||
isStatusActive(statuses: ApprovalStatus[]): boolean {
|
||||
const current = this.store.statusFilter();
|
||||
if (statuses.length === 0 && current.length === 0) return true;
|
||||
return JSON.stringify(current.sort()) === JSON.stringify(statuses.sort());
|
||||
}
|
||||
|
||||
toggleUrgencyFilter(urgency: ApprovalUrgency): void {
|
||||
const current = [...this.store.urgencyFilter()];
|
||||
const index = current.indexOf(urgency);
|
||||
if (index >= 0) {
|
||||
current.splice(index, 1);
|
||||
} else {
|
||||
current.push(urgency);
|
||||
}
|
||||
this.store.setUrgencyFilter(current);
|
||||
onUrgencyChipChange(value: string): void {
|
||||
this.store.setUrgencyFilter(value ? [value as ApprovalUrgency] : []);
|
||||
this.currentPage.set(1);
|
||||
this.store.loadApprovals();
|
||||
}
|
||||
|
||||
isUrgencySelected(urgency: ApprovalUrgency): boolean {
|
||||
return this.store.urgencyFilter().includes(urgency);
|
||||
}
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return this.store.urgencyFilter().length > 0 ||
|
||||
this.store.statusFilter().length !== 1 ||
|
||||
this.store.statusFilter()[0] !== 'pending';
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.store.clearFilters();
|
||||
this.store.loadApprovals();
|
||||
onPageChange(event: PageChangeEvent): void {
|
||||
this.currentPage.set(event.page);
|
||||
this.pageSize.set(event.pageSize);
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 — 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 & 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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './releases.routes';
|
||||
export * from './release-list/release-list.component';
|
||||
export * from './release-detail/release-detail.component';
|
||||
export * from './create-release/create-release.component';
|
||||
export * from './create-version/create-version.component';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003)
|
||||
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
||||
@@ -14,13 +13,14 @@ import {
|
||||
type ReleaseGateStatus,
|
||||
type ReleaseRiskTier,
|
||||
} from '../../../../core/api/release-management.models';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../../../shared/components/pagination/pagination.component';
|
||||
|
||||
import { DateFormatService } from '../../../../core/i18n/date-format.service';
|
||||
import { PageActionService } from '../../../../core/services/page-action.service';
|
||||
@Component({
|
||||
selector: 'app-release-list',
|
||||
imports: [FormsModule, RouterModule, FilterBarComponent],
|
||||
imports: [RouterModule, StellaFilterChipComponent, PaginationComponent],
|
||||
template: `
|
||||
<div class="release-list">
|
||||
<header class="list-header">
|
||||
@@ -30,15 +30,19 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search by digest, release version name, or slug"
|
||||
[filters]="releaseFilterOptions"
|
||||
[activeFilters]="activeReleaseFilters()"
|
||||
(searchChange)="onReleaseSearch($event)"
|
||||
(filterChange)="onReleaseFilterChanged($event)"
|
||||
(filterRemove)="onReleaseFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllReleaseFilters()"
|
||||
></app-filter-bar>
|
||||
<div class="filters">
|
||||
<div class="filter-search">
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="filter-search__input" placeholder="Search by digest, version name, or slug..."
|
||||
[value]="searchTerm" (input)="onSearchInput($event)" />
|
||||
</div>
|
||||
<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) {
|
||||
<div class="bulk-bar">
|
||||
@@ -146,7 +150,7 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
</p>
|
||||
<div class="empty-state__actions">
|
||||
@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()">
|
||||
<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>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table class="release-table">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check">
|
||||
@@ -228,23 +232,15 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (store.totalCount() > store.pageSize()) {
|
||||
<div class="pagination">
|
||||
<button type="button" class="pagination__btn" [disabled]="store.currentPage() === 1" (click)="setPage(store.currentPage() - 1)">
|
||||
<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>
|
||||
Previous
|
||||
</button>
|
||||
<span class="pagination__info">Page {{ store.currentPage() }} of {{ Math.ceil(store.totalCount() / store.pageSize()) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
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 style="display: flex; justify-content: flex-end; padding-top: 0.75rem;">
|
||||
<app-pagination
|
||||
[total]="store.totalCount()"
|
||||
[currentPage]="store.currentPage()"
|
||||
[pageSize]="store.pageSize()"
|
||||
[pageSizes]="[5, 10, 25, 50]"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
@@ -284,6 +280,51 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Inline filters ─── */
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
position: relative;
|
||||
flex: 0 1 240px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filter-search__icon {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-search__input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
padding: 0 0.5rem 0 1.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.filter-search__input:focus {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.filter-search__input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
@@ -515,56 +556,15 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.release-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.release-table th,
|
||||
.release-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
vertical-align: top;
|
||||
font-size: var(--font-size-sm, 0.75rem);
|
||||
}
|
||||
|
||||
.release-table thead {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.release-table th {
|
||||
font-size: var(--font-size-xs, 0.6875rem);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 0.45rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-table tbody tr {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
transition: background var(--motion-duration-sm, 140ms) ease;
|
||||
}
|
||||
|
||||
.release-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.release-table tbody tr:hover {
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.release-table tbody tr.selected {
|
||||
:host ::ng-deep .stella-table tbody tr.selected {
|
||||
background: var(--color-selection-bg, var(--color-brand-primary-10));
|
||||
}
|
||||
|
||||
.release-table tbody tr.blocked {
|
||||
:host ::ng-deep .stella-table tbody tr.blocked {
|
||||
background: var(--color-status-error-bg);
|
||||
}
|
||||
|
||||
.release-table tbody tr.blocked:hover {
|
||||
:host ::ng-deep .stella-table tbody tr.blocked:hover {
|
||||
background: color-mix(in srgb, var(--color-status-error-bg) 80%, var(--color-surface-secondary));
|
||||
}
|
||||
|
||||
@@ -682,45 +682,6 @@ import { PageActionService } from '../../../../core/services/page-action.service
|
||||
background: var(--color-surface-tertiary);
|
||||
}
|
||||
|
||||
/* ─── Pagination ─── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm, 0.75rem);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-duration-sm, 140ms) ease,
|
||||
border-color var(--motion-duration-sm, 140ms) ease;
|
||||
}
|
||||
|
||||
.pagination__btn:hover:not(:disabled) {
|
||||
background: var(--color-surface-secondary);
|
||||
border-color: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination__info {
|
||||
font-size: var(--font-size-sm, 0.75rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Health summary strip ─── */
|
||||
.health-strip {
|
||||
display: flex;
|
||||
@@ -823,6 +784,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
stageFilter = 'all';
|
||||
gateFilter = 'all';
|
||||
riskFilter = 'all';
|
||||
evidenceFilter = 'all';
|
||||
blockedFilter = 'all';
|
||||
needsApprovalFilter = 'all';
|
||||
hotfixLaneFilter = 'all';
|
||||
@@ -831,21 +793,33 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
readonly selectedReleaseIds = signal<Set<string>>(new Set());
|
||||
private applyingFromQuery = false;
|
||||
|
||||
// Shared filter bar integration
|
||||
readonly releaseFilterOptions: FilterOption[] = [
|
||||
{ key: 'type', label: 'Type', options: [{ value: 'standard', label: 'Standard' }, { value: 'hotfix', label: 'Hotfix' }] },
|
||||
{ key: 'stage', label: 'Stage', options: [{ value: 'draft', label: 'Draft' }, { value: 'ready', label: 'Ready' }, { value: 'deploying', label: 'Deploying' }, { value: 'deployed', label: 'Deployed' }, { value: 'failed', label: 'Failed' }, { value: 'rolled_back', label: 'Rolled Back' }] },
|
||||
{ key: 'gate', label: 'Gate', options: [{ value: 'pass', label: 'Pass' }, { value: 'warn', label: 'Warn' }, { value: 'pending', label: 'Pending' }, { value: 'block', label: 'Block' }, { value: 'unknown', label: 'Unknown' }] },
|
||||
{ key: 'risk', label: 'Risk', options: [{ value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, { value: 'medium', label: 'Medium' }, { value: 'low', label: 'Low' }, { value: 'none', label: 'None' }, { value: 'unknown', label: 'Unknown' }] },
|
||||
{ key: 'blocked', label: 'Blocked', options: [{ value: 'true', label: 'Blocked' }, { value: 'false', label: 'Unblocked' }] },
|
||||
{ key: 'needsApproval', label: 'Needs Approval', options: [{ value: 'true', label: 'Needs Approval' }, { value: 'false', label: 'No Approval Needed' }] },
|
||||
{ key: 'hotfixLane', label: 'Hotfix Lane', options: [{ value: 'true', label: 'Hotfix Lane' }, { value: 'false', label: 'Standard Lane' }] },
|
||||
{ key: 'replayMismatch', label: 'Replay Mismatch', options: [{ value: 'true', label: 'Mismatch' }, { value: 'false', label: 'No Mismatch' }] },
|
||||
// Inline filter chip options
|
||||
readonly typeOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Types' },
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'hotfix', label: 'Hotfix' },
|
||||
];
|
||||
readonly gateOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Gates' },
|
||||
{ id: 'pass', label: 'Pass' },
|
||||
{ id: 'warn', label: 'Warn' },
|
||||
{ id: 'block', label: 'Block' },
|
||||
];
|
||||
readonly riskOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Risk' },
|
||||
{ id: 'critical', label: 'Critical' },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'none', label: 'None' },
|
||||
];
|
||||
readonly evidenceOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Evidence' },
|
||||
{ id: 'verified', label: 'Verified' },
|
||||
{ id: 'partial', label: 'Partial' },
|
||||
{ id: 'missing', label: 'Missing' },
|
||||
];
|
||||
|
||||
readonly activeReleaseFilters = signal<ActiveFilter[]>([]);
|
||||
|
||||
readonly Math = Math;
|
||||
readonly getGateStatusLabel = getGateStatusLabel;
|
||||
readonly getRiskTierLabel = getRiskTierLabel;
|
||||
readonly getEvidencePostureLabel = getEvidencePostureLabel;
|
||||
@@ -882,6 +856,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
this.stageFilter = params.get('stage') ?? 'all';
|
||||
this.gateFilter = params.get('gate') ?? 'all';
|
||||
this.riskFilter = params.get('risk') ?? 'all';
|
||||
this.evidenceFilter = params.get('evidence') ?? 'all';
|
||||
this.blockedFilter = params.get('blocked') ?? 'all';
|
||||
this.needsApprovalFilter = params.get('needsApproval') ?? 'all';
|
||||
this.hotfixLaneFilter = params.get('hotfixLane') ?? 'all';
|
||||
@@ -895,83 +870,45 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
onReleaseSearch(value: string): void {
|
||||
this.searchTerm = value;
|
||||
onSearchInput(event: Event): void {
|
||||
this.searchTerm = (event.target as HTMLInputElement).value;
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
onReleaseFilterChanged(filter: ActiveFilter): void {
|
||||
const filterMap: Record<string, string> = {
|
||||
type: 'typeFilter',
|
||||
stage: 'stageFilter',
|
||||
gate: 'gateFilter',
|
||||
risk: 'riskFilter',
|
||||
blocked: 'blockedFilter',
|
||||
needsApproval: 'needsApprovalFilter',
|
||||
hotfixLane: 'hotfixLaneFilter',
|
||||
replayMismatch: 'replayMismatchFilter',
|
||||
};
|
||||
const prop = filterMap[filter.key];
|
||||
if (prop) {
|
||||
(this as any)[prop] = filter.value;
|
||||
}
|
||||
onTypeFilterChange(value: string): void {
|
||||
this.typeFilter = value || 'all';
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
onReleaseFilterRemoved(filter: ActiveFilter): void {
|
||||
const filterMap: Record<string, string> = {
|
||||
type: 'typeFilter',
|
||||
stage: 'stageFilter',
|
||||
gate: 'gateFilter',
|
||||
risk: 'riskFilter',
|
||||
blocked: 'blockedFilter',
|
||||
needsApproval: 'needsApprovalFilter',
|
||||
hotfixLane: 'hotfixLaneFilter',
|
||||
replayMismatch: 'replayMismatchFilter',
|
||||
};
|
||||
const prop = filterMap[filter.key];
|
||||
if (prop) {
|
||||
(this as any)[prop] = 'all';
|
||||
}
|
||||
onGateFilterChange(value: string): void {
|
||||
this.gateFilter = value || 'all';
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
clearAllReleaseFilters(): void {
|
||||
onRiskFilterChange(value: string): void {
|
||||
this.riskFilter = value || 'all';
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
onEvidenceFilterChange(value: string): void {
|
||||
this.evidenceFilter = value || 'all';
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchTerm = '';
|
||||
this.typeFilter = 'all';
|
||||
this.stageFilter = 'all';
|
||||
this.gateFilter = 'all';
|
||||
this.riskFilter = 'all';
|
||||
this.evidenceFilter = 'all';
|
||||
this.blockedFilter = 'all';
|
||||
this.needsApprovalFilter = 'all';
|
||||
this.hotfixLaneFilter = 'all';
|
||||
this.replayMismatchFilter = 'all';
|
||||
this.activeReleaseFilters.set([]);
|
||||
this.applyFilters(false);
|
||||
}
|
||||
|
||||
private rebuildActiveReleaseFilters(): void {
|
||||
const filters: ActiveFilter[] = [];
|
||||
const filterDefs: { key: string; prop: string; label: string }[] = [
|
||||
{ key: 'type', prop: 'typeFilter', label: 'Type' },
|
||||
{ key: 'stage', prop: 'stageFilter', label: 'Stage' },
|
||||
{ key: 'gate', prop: 'gateFilter', label: 'Gate' },
|
||||
{ key: 'risk', prop: 'riskFilter', label: 'Risk' },
|
||||
{ key: 'blocked', prop: 'blockedFilter', label: 'Blocked' },
|
||||
{ key: 'needsApproval', prop: 'needsApprovalFilter', label: 'Approval' },
|
||||
{ key: 'hotfixLane', prop: 'hotfixLaneFilter', label: 'Lane' },
|
||||
{ key: 'replayMismatch', prop: 'replayMismatchFilter', label: 'Replay' },
|
||||
];
|
||||
for (const def of filterDefs) {
|
||||
const val = (this as any)[def.prop] as string;
|
||||
if (val !== 'all') {
|
||||
const opt = this.releaseFilterOptions.find(f => f.key === def.key)?.options.find(o => o.value === val);
|
||||
filters.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) });
|
||||
}
|
||||
}
|
||||
this.activeReleaseFilters.set(filters);
|
||||
}
|
||||
|
||||
applyFilters(fromQuery: boolean): void {
|
||||
const filter: ReleaseFilter = {
|
||||
search: this.searchTerm.trim() || undefined,
|
||||
@@ -988,7 +925,6 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
this.store.setFilter(filter);
|
||||
this.rebuildActiveReleaseFilters();
|
||||
|
||||
if (!fromQuery && !this.applyingFromQuery) {
|
||||
void this.router.navigate([], {
|
||||
@@ -1003,6 +939,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
this.store.setPage(page);
|
||||
}
|
||||
|
||||
onPageChange(event: PageChangeEvent): void {
|
||||
this.store.setPage(event.page);
|
||||
this.store.setPageSize(event.pageSize);
|
||||
}
|
||||
|
||||
toggleRelease(releaseId: string, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.selectedReleaseIds.update((current) => {
|
||||
@@ -1046,7 +987,11 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
hasActiveFilters(): boolean {
|
||||
return this.activeReleaseFilters().length > 0 || this.searchTerm.trim().length > 0;
|
||||
return this.searchTerm.trim().length > 0 ||
|
||||
this.typeFilter !== 'all' ||
|
||||
this.gateFilter !== 'all' ||
|
||||
this.riskFilter !== 'all' ||
|
||||
this.evidenceFilter !== 'all';
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
@@ -1119,6 +1064,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
|
||||
stage: this.stageFilter !== 'all' ? this.stageFilter : null,
|
||||
gate: this.gateFilter !== 'all' ? this.gateFilter : null,
|
||||
risk: this.riskFilter !== 'all' ? this.riskFilter : null,
|
||||
evidence: this.evidenceFilter !== 'all' ? this.evidenceFilter : null,
|
||||
blocked: this.blockedFilter !== 'all' ? this.blockedFilter : null,
|
||||
needsApproval: this.needsApprovalFilter !== 'all' ? this.needsApprovalFilter : null,
|
||||
hotfixLane: this.hotfixLaneFilter !== 'all' ? this.hotfixLaneFilter : null,
|
||||
|
||||
@@ -12,6 +12,13 @@ export const RELEASE_ROUTES: Routes = [
|
||||
(m) => m.ReleaseListComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
loadComponent: () =>
|
||||
import('./create-deployment/create-deployment.component').then(
|
||||
(m) => m.CreateDeploymentComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadComponent: () =>
|
||||
@@ -19,6 +26,27 @@ export const RELEASE_ROUTES: Routes = [
|
||||
(m) => m.CreateReleaseComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'create-hotfix',
|
||||
loadComponent: () =>
|
||||
import('./create-hotfix/create-hotfix.component').then(
|
||||
(m) => m.CreateHotfixComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'create-version',
|
||||
loadComponent: () =>
|
||||
import('./create-version/create-version.component').then(
|
||||
(m) => m.CreateVersionComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'create-deployment',
|
||||
loadComponent: () =>
|
||||
import('./create-deployment/create-deployment.component').then(
|
||||
(m) => m.CreateDeploymentComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||
|
||||
import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
|
||||
@@ -55,7 +56,7 @@ function deriveOutcomeIcon(status: string): string {
|
||||
@Component({
|
||||
selector: 'app-releases-activity',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, FilterBarComponent],
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent],
|
||||
template: `
|
||||
<section class="activity">
|
||||
<header>
|
||||
@@ -76,15 +77,19 @@ function deriveOutcomeIcon(status: string): string {
|
||||
ariaLabel="Run list views"
|
||||
/>
|
||||
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search runs..."
|
||||
[filters]="activityFilterOptions"
|
||||
[activeFilters]="activityActiveFilters()"
|
||||
(searchChange)="onActivitySearch($event)"
|
||||
(filterChange)="onActivityFilterAdded($event)"
|
||||
(filterRemove)="onActivityFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllActivityFilters()"
|
||||
/>
|
||||
<div class="activity-filters">
|
||||
<div class="filter-search">
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="filter-search__input" placeholder="Search activity..."
|
||||
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
|
||||
</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()) {
|
||||
<div class="banner error">{{ error() }}</div>
|
||||
@@ -139,7 +144,7 @@ function deriveOutcomeIcon(status: string): string {
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<table>
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
@@ -153,7 +158,7 @@ function deriveOutcomeIcon(status: string): string {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
@for (row of pagedRows(); track row.activityId) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||
<td>{{ row.releaseName }}</td>
|
||||
@@ -169,6 +174,15 @@ function deriveOutcomeIcon(status: string): string {
|
||||
}
|
||||
</tbody>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,11 +191,23 @@ function deriveOutcomeIcon(status: string): string {
|
||||
styles: [`
|
||||
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
|
||||
.context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)}
|
||||
|
||||
.banner,table,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
|
||||
|
||||
/* Inline filter chips row */
|
||||
.activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.filter-search { position: relative; flex: 0 1 240px; min-width: 160px; }
|
||||
.filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
|
||||
.filter-search__input {
|
||||
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--color-text-primary);
|
||||
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
|
||||
}
|
||||
.filter-search__input:focus { border-color: var(--color-brand-primary); }
|
||||
.filter-search__input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.banner,.clusters article{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)}
|
||||
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
|
||||
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
|
||||
.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
|
||||
|
||||
/* Timeline container */
|
||||
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem}
|
||||
@@ -194,6 +220,11 @@ function deriveOutcomeIcon(status: string): string {
|
||||
.run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
|
||||
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
|
||||
.run-link:hover{text-decoration:underline}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.activity-filters { gap: 0.375rem; }
|
||||
.filter-search { flex: 1 1 100%; }
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -211,70 +242,40 @@ export class ReleasesActivityComponent {
|
||||
readonly rows = signal<ReleaseActivityProjection[]>([]);
|
||||
readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline');
|
||||
|
||||
// ── Filter-bar configuration ──────────────────────────────────────────
|
||||
// ── Filter-chip options ──────────────────────────────────────────────
|
||||
|
||||
readonly activityFilterOptions: FilterOption[] = [
|
||||
{ key: 'status', label: 'Status', options: [
|
||||
{ value: 'pending_approval', label: 'Pending Approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
]},
|
||||
{ key: 'lane', label: 'Lane', options: [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'hotfix', label: 'Hotfix' },
|
||||
]},
|
||||
{ key: 'env', label: 'Environment', options: [
|
||||
{ value: 'dev', label: 'Dev' },
|
||||
{ value: 'stage', label: 'Stage' },
|
||||
{ value: 'prod', label: 'Prod' },
|
||||
]},
|
||||
{ key: 'outcome', label: 'Outcome', options: [
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
]},
|
||||
{ key: 'needsApproval', label: 'Needs Approval', options: [
|
||||
{ value: 'true', label: 'Needs Approval' },
|
||||
{ value: 'false', label: 'No Approval Needed' },
|
||||
]},
|
||||
{ key: 'integrity', label: 'Data Integrity', options: [
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
{ value: 'clear', label: 'Clear' },
|
||||
]},
|
||||
readonly statusChipOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Status' },
|
||||
{ id: 'pending_approval', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'published', label: 'Published' },
|
||||
{ id: 'blocked', label: 'Blocked' },
|
||||
{ id: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
readonly laneChipOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Lanes' },
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'hotfix', label: 'Hotfix' },
|
||||
];
|
||||
readonly envChipOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Envs' },
|
||||
{ id: 'dev', label: 'Dev' },
|
||||
{ id: 'stage', label: 'Stage' },
|
||||
{ id: 'prod', label: 'Prod' },
|
||||
];
|
||||
readonly outcomeChipOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Outcomes' },
|
||||
{ id: 'success', label: 'Success' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
||||
readonly statusFilter = signal('all');
|
||||
readonly laneFilter = signal('all');
|
||||
readonly envFilter = signal('all');
|
||||
readonly outcomeFilter = signal('all');
|
||||
readonly needsApprovalFilter = signal('all');
|
||||
readonly integrityFilter = signal('all');
|
||||
readonly statusFilter = signal('');
|
||||
readonly laneFilter = signal('');
|
||||
readonly envFilter = signal('');
|
||||
readonly outcomeFilter = signal('');
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
readonly activityActiveFilters = computed<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(() => {
|
||||
let rows = [...this.rows()];
|
||||
|
||||
@@ -282,32 +283,42 @@ export class ReleasesActivityComponent {
|
||||
const laneF = this.laneFilter();
|
||||
const envF = this.envFilter();
|
||||
const outcomeF = this.outcomeFilter();
|
||||
const needsApprovalF = this.needsApprovalFilter();
|
||||
const integrityF = this.integrityFilter();
|
||||
const q = this.searchQuery().toLowerCase().trim();
|
||||
|
||||
if (statusF !== 'all') {
|
||||
if (q) {
|
||||
rows = rows.filter((item) =>
|
||||
item.releaseName.toLowerCase().includes(q) ||
|
||||
item.activityId.toLowerCase().includes(q) ||
|
||||
item.eventType.toLowerCase().includes(q) ||
|
||||
item.status.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (statusF !== '') {
|
||||
rows = rows.filter((item) => item.status.toLowerCase() === statusF);
|
||||
}
|
||||
if (laneF !== 'all') {
|
||||
if (laneF !== '') {
|
||||
rows = rows.filter((item) => this.deriveLane(item) === laneF);
|
||||
}
|
||||
if (envF !== 'all') {
|
||||
if (envF !== '') {
|
||||
rows = rows.filter((item) => (item.targetEnvironment ?? '').toLowerCase().includes(envF));
|
||||
}
|
||||
if (outcomeF !== 'all') {
|
||||
if (outcomeF !== '') {
|
||||
rows = rows.filter((item) => this.deriveOutcome(item) === outcomeF);
|
||||
}
|
||||
if (needsApprovalF !== 'all') {
|
||||
const expected = needsApprovalF === 'true';
|
||||
rows = rows.filter((item) => this.deriveNeedsApproval(item) === expected);
|
||||
}
|
||||
if (integrityF !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveDataIntegrity(item) === integrityF);
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
// ── Pagination state ────────────────────────────────────────────────────
|
||||
readonly currentPage = signal(1);
|
||||
readonly pageSize = signal(10);
|
||||
|
||||
readonly pagedRows = computed(() => {
|
||||
const all = this.filteredRows();
|
||||
const start = (this.currentPage() - 1) * this.pageSize();
|
||||
return all.slice(start, start + this.pageSize());
|
||||
});
|
||||
|
||||
/** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
return this.filteredRows().map((row) => ({
|
||||
@@ -370,8 +381,6 @@ export class ReleasesActivityComponent {
|
||||
if (params.get('lane')) this.laneFilter.set(params.get('lane')!);
|
||||
if (params.get('env')) this.envFilter.set(params.get('env')!);
|
||||
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!);
|
||||
if (params.get('needsApproval')) this.needsApprovalFilter.set(params.get('needsApproval')!);
|
||||
if (params.get('integrity')) this.integrityFilter.set(params.get('integrity')!);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -383,12 +392,10 @@ export class ReleasesActivityComponent {
|
||||
mergeQuery(next: Record<string, string>): Record<string, string | null> {
|
||||
return {
|
||||
view: next['view'] ?? this.viewMode(),
|
||||
status: this.statusFilter() !== 'all' ? this.statusFilter() : null,
|
||||
lane: this.laneFilter() !== 'all' ? this.laneFilter() : null,
|
||||
env: this.envFilter() !== 'all' ? this.envFilter() : null,
|
||||
outcome: this.outcomeFilter() !== 'all' ? this.outcomeFilter() : null,
|
||||
needsApproval: this.needsApprovalFilter() !== 'all' ? this.needsApprovalFilter() : null,
|
||||
integrity: this.integrityFilter() !== 'all' ? this.integrityFilter() : null,
|
||||
status: this.statusFilter() !== '' ? this.statusFilter() : null,
|
||||
lane: this.laneFilter() !== '' ? this.laneFilter() : null,
|
||||
env: this.envFilter() !== '' ? this.envFilter() : null,
|
||||
outcome: this.outcomeFilter() !== '' ? this.outcomeFilter() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,45 +407,9 @@ export class ReleasesActivityComponent {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Filter-bar handlers ────────────────────────────────────────────────
|
||||
|
||||
onActivitySearch(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
}
|
||||
|
||||
onActivityFilterAdded(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'status': this.statusFilter.set(f.value); break;
|
||||
case 'lane': this.laneFilter.set(f.value); break;
|
||||
case 'env': this.envFilter.set(f.value); break;
|
||||
case 'outcome': this.outcomeFilter.set(f.value); break;
|
||||
case 'needsApproval': this.needsApprovalFilter.set(f.value); break;
|
||||
case 'integrity': this.integrityFilter.set(f.value); break;
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onActivityFilterRemoved(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'status': this.statusFilter.set('all'); break;
|
||||
case 'lane': this.laneFilter.set('all'); break;
|
||||
case 'env': this.envFilter.set('all'); break;
|
||||
case 'outcome': this.outcomeFilter.set('all'); break;
|
||||
case 'needsApproval': this.needsApprovalFilter.set('all'); break;
|
||||
case 'integrity': this.integrityFilter.set('all'); break;
|
||||
}
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
clearAllActivityFilters(): void {
|
||||
this.statusFilter.set('all');
|
||||
this.laneFilter.set('all');
|
||||
this.envFilter.set('all');
|
||||
this.outcomeFilter.set('all');
|
||||
this.needsApprovalFilter.set('all');
|
||||
this.integrityFilter.set('all');
|
||||
this.searchQuery.set('');
|
||||
this.applyFilters();
|
||||
onPageChange(event: PageChangeEvent): void {
|
||||
this.currentPage.set(event.page);
|
||||
this.pageSize.set(event.pageSize);
|
||||
}
|
||||
|
||||
deriveLane(item: ReleaseActivityProjection): 'standard' | 'hotfix' {
|
||||
|
||||
@@ -12,8 +12,7 @@ import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, signal,
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { UpperCasePipe, SlicePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||
import { TableColumn } from '../../shared/components/data-table/data-table.component';
|
||||
|
||||
@@ -122,8 +121,7 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
UpperCasePipe,
|
||||
SlicePipe,
|
||||
RouterLink,
|
||||
FormsModule,
|
||||
FilterBarComponent,
|
||||
StellaFilterChipComponent,
|
||||
PaginationComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -137,16 +135,17 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
</header>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<div class="rup__toolbar">
|
||||
<app-filter-bar
|
||||
searchPlaceholder="Search releases..."
|
||||
[filters]="pipelineFilterOptions"
|
||||
[activeFilters]="pipelineActiveFilters()"
|
||||
(searchChange)="searchQuery.set($event); currentPage.set(1)"
|
||||
(filterChange)="onPipelineFilterAdded($event)"
|
||||
(filterRemove)="onPipelineFilterRemoved($event)"
|
||||
(filtersCleared)="clearAllPipelineFilters()"
|
||||
/>
|
||||
<div class="rup__filters">
|
||||
<div class="rup__search">
|
||||
<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">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="rup__search-input" placeholder="Search releases..."
|
||||
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- 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__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
|
||||
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
.rup__toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
:host ::ng-deep app-filter-bar { flex: 1 1 0; min-width: 0; }
|
||||
.rup__toolbar-actions { display: flex; gap: 0.375rem; margin-left: auto; padding-top: 0.5rem; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 0.375rem; padding: 0 0.75rem;
|
||||
border: none; border-radius: var(--radius-md, 6px); font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold, 600); cursor: pointer; text-decoration: none;
|
||||
white-space: nowrap; transition: background 150ms ease, box-shadow 150ms ease; line-height: 1;
|
||||
.rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.rup__search { position: relative; flex: 0 1 240px; min-width: 160px; }
|
||||
.rup__search-icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
|
||||
.rup__search-input {
|
||||
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--color-text-primary);
|
||||
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
|
||||
}
|
||||
.btn--sm { height: 32px; }
|
||||
.btn--primary { background: var(--color-btn-primary-bg); color: var(--color-surface-inverse, #fff); }
|
||||
.btn--primary:hover { box-shadow: var(--shadow-sm); }
|
||||
.btn--warning { background: var(--color-status-warning, #C89820); color: #fff; }
|
||||
.btn--warning:hover { box-shadow: var(--shadow-sm); }
|
||||
.rup__search-input:focus { border-color: var(--color-brand-primary); }
|
||||
.rup__search-input::placeholder { color: var(--color-text-muted); }
|
||||
|
||||
.rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
@@ -429,8 +424,8 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rup { padding: 1rem; }
|
||||
.rup__toolbar { flex-direction: column; align-items: stretch; }
|
||||
.rup__toolbar-actions { margin-left: 0; justify-content: flex-end; }
|
||||
.rup__filters { gap: 0.375rem; }
|
||||
.rup__search { flex: 1 1 100%; }
|
||||
}
|
||||
`],
|
||||
})
|
||||
@@ -438,33 +433,34 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'New Release', route: '/releases/versions/new' });
|
||||
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
// ── Filter-bar configuration ──────────────────────────────────────────
|
||||
// ── Filter-chip options ──────────────────────────────────────────────
|
||||
|
||||
readonly pipelineFilterOptions: FilterOption[] = [
|
||||
{ key: 'lane', label: 'Lane', options: [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'hotfix', label: 'Hotfix' },
|
||||
]},
|
||||
{ key: 'status', label: 'Status', options: [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'ready', label: 'Ready' },
|
||||
{ value: 'deploying', label: 'Deploying' },
|
||||
{ value: 'deployed', label: 'Deployed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'rolled_back', label: 'Rolled Back' },
|
||||
]},
|
||||
{ key: 'gate', label: 'Gates', options: [
|
||||
{ value: 'pass', label: 'Pass' },
|
||||
{ value: 'warn', label: 'Warn' },
|
||||
{ value: 'block', label: 'Block' },
|
||||
]},
|
||||
readonly laneOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Lanes' },
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'hotfix', label: 'Hotfix' },
|
||||
];
|
||||
readonly statusOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Status' },
|
||||
{ id: 'draft', label: 'Draft' },
|
||||
{ id: 'ready', label: 'Ready' },
|
||||
{ id: 'deploying', label: 'Deploying' },
|
||||
{ id: 'deployed', label: 'Deployed' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
{ id: 'rolled_back', label: 'Rolled Back' },
|
||||
];
|
||||
readonly gateOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Gates' },
|
||||
{ id: 'pass', label: 'Pass' },
|
||||
{ id: 'warn', label: 'Warn' },
|
||||
{ id: 'block', label: 'Block' },
|
||||
];
|
||||
|
||||
// ── Columns definition ───────────────────────────────────────────────
|
||||
@@ -483,32 +479,11 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
|
||||
readonly searchQuery = signal('');
|
||||
readonly laneFilter = signal<'all' | 'standard' | 'hotfix'>('all');
|
||||
readonly statusFilter = signal<string>('all');
|
||||
readonly gateFilter = signal<string>('all');
|
||||
readonly laneFilter = signal('');
|
||||
readonly statusFilter = signal('');
|
||||
readonly gateFilter = signal('');
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
readonly currentPage = signal(1);
|
||||
@@ -529,13 +504,13 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
r.digest.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (lane !== 'all') {
|
||||
if (lane !== '') {
|
||||
list = list.filter((r) => r.lane === lane);
|
||||
}
|
||||
if (status !== 'all') {
|
||||
if (status !== '') {
|
||||
list = list.filter((r) => r.status === status);
|
||||
}
|
||||
if (gate !== 'all') {
|
||||
if (gate !== '') {
|
||||
list = list.filter((r) => r.gateStatus === gate);
|
||||
}
|
||||
return list;
|
||||
@@ -580,33 +555,6 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
this.pageSize.set(event.pageSize);
|
||||
}
|
||||
|
||||
// ── Filter-bar handlers ────────────────────────────────────────────────
|
||||
|
||||
onPipelineFilterAdded(f: ActiveFilter): void {
|
||||
this.currentPage.set(1);
|
||||
switch (f.key) {
|
||||
case 'lane': this.laneFilter.set(f.value as 'all' | 'standard' | 'hotfix'); break;
|
||||
case 'status': this.statusFilter.set(f.value); break;
|
||||
case 'gate': this.gateFilter.set(f.value); break;
|
||||
}
|
||||
}
|
||||
|
||||
onPipelineFilterRemoved(f: ActiveFilter): void {
|
||||
switch (f.key) {
|
||||
case 'lane': this.laneFilter.set('all'); break;
|
||||
case 'status': this.statusFilter.set('all'); break;
|
||||
case 'gate': this.gateFilter.set('all'); break;
|
||||
}
|
||||
}
|
||||
|
||||
clearAllPipelineFilters(): void {
|
||||
this.laneFilter.set('all');
|
||||
this.statusFilter.set('all');
|
||||
this.gateFilter.set('all');
|
||||
this.searchQuery.set('');
|
||||
this.currentPage.set(1);
|
||||
}
|
||||
|
||||
// ── Sort handlers ────────────────────────────────────────────────────
|
||||
|
||||
toggleSort(columnKey: string): void {
|
||||
|
||||
@@ -67,9 +67,27 @@ export const RELEASES_ROUTES: Routes = [
|
||||
(m) => m.ReleaseListComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
title: 'New Release',
|
||||
data: { breadcrumb: 'New Release' },
|
||||
loadComponent: () =>
|
||||
import('../features/release-orchestrator/releases/create-deployment/create-deployment.component').then(
|
||||
(m) => m.CreateDeploymentComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'versions/new',
|
||||
title: 'Create Release Version',
|
||||
title: 'Create Version',
|
||||
data: { breadcrumb: 'Create Version', semanticObject: 'version' },
|
||||
loadComponent: () =>
|
||||
import('../features/release-orchestrator/releases/create-version/create-version.component').then(
|
||||
(m) => m.CreateVersionComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'versions/new-legacy',
|
||||
title: 'Create Release Version (Legacy)',
|
||||
data: { breadcrumb: 'Create Release Version', semanticObject: 'version' },
|
||||
loadComponent: () =>
|
||||
import('../features/release-orchestrator/releases/create-release/create-release.component').then(
|
||||
@@ -179,11 +197,10 @@ export const RELEASES_ROUTES: Routes = [
|
||||
path: 'hotfixes/new',
|
||||
title: 'Create Hotfix',
|
||||
data: { breadcrumb: 'Create Hotfix' },
|
||||
pathMatch: 'full',
|
||||
redirectTo: preserveReleasesRedirectWithQuery('/releases/versions/new', {
|
||||
type: 'hotfix',
|
||||
hotfixLane: 'true',
|
||||
}),
|
||||
loadComponent: () =>
|
||||
import('../features/release-orchestrator/releases/create-hotfix/create-hotfix.component').then(
|
||||
(m) => m.CreateHotfixComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'hotfixes/:hotfixId',
|
||||
|
||||
@@ -210,14 +210,24 @@ internal static class ElkEdgeChannels
|
||||
var routeMode = EdgeRouteMode.Direct;
|
||||
if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id))
|
||||
{
|
||||
var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label);
|
||||
if (familyKey is "failure" or "timeout")
|
||||
var sourceNode = positionedNodes[sorted[index].SourceNodeId];
|
||||
var isGatewaySource = string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase);
|
||||
if (isGatewaySource)
|
||||
{
|
||||
routeMode = EdgeRouteMode.SinkOuterTop;
|
||||
sinkBand = (-1, 0, 0d, double.NaN);
|
||||
}
|
||||
else
|
||||
{
|
||||
routeMode = EdgeRouteMode.SinkOuter;
|
||||
var familyKey = ElkEdgeChannelBands.ResolveLaneFamilyKey(sorted[index].Label);
|
||||
if (familyKey is "failure" or "timeout")
|
||||
{
|
||||
routeMode = EdgeRouteMode.SinkOuterTop;
|
||||
}
|
||||
else
|
||||
{
|
||||
routeMode = EdgeRouteMode.SinkOuter;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,10 @@ internal static class ElkEdgeRouter
|
||||
}
|
||||
|
||||
var channel = edgeChannels.GetValueOrDefault(edge.Id);
|
||||
if (channel.RouteMode != EdgeRouteMode.Direct
|
||||
var useCorridorRouting = channel.RouteMode != EdgeRouteMode.Direct
|
||||
|| !string.IsNullOrWhiteSpace(edge.SourcePortId)
|
||||
|| !string.IsNullOrWhiteSpace(edge.TargetPortId))
|
||||
|| !string.IsNullOrWhiteSpace(edge.TargetPortId);
|
||||
if (useCorridorRouting)
|
||||
{
|
||||
reconstructed[edge.Id] = RouteEdge(
|
||||
edge,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +91,17 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
edgeChannels,
|
||||
layerBoundariesByNodeId);
|
||||
var routedEdges = graph.Edges
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var routed)
|
||||
? routed
|
||||
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds,
|
||||
edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId))
|
||||
.Select(edge =>
|
||||
{
|
||||
if (reconstructedEdges.TryGetValue(edge.Id, out var routed))
|
||||
{
|
||||
return routed;
|
||||
}
|
||||
|
||||
var ch = ElkSharpLayoutHelpers.ResolveSinkOverride(
|
||||
edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges);
|
||||
return ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, ch, layerBoundariesByNodeId);
|
||||
})
|
||||
.ToArray();
|
||||
for (var gutterPass = 0; gutterPass < 3; gutterPass++)
|
||||
{
|
||||
@@ -126,7 +133,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds,
|
||||
edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId))
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
@@ -160,7 +167,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds,
|
||||
edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId))
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
|
||||
if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters(
|
||||
@@ -191,7 +198,7 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
||||
.Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted)
|
||||
? rerouted
|
||||
: ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds,
|
||||
edgeChannels.GetValueOrDefault(edge.Id), layerBoundariesByNodeId))
|
||||
ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user