release control ui simplificatiosn

This commit is contained in:
master
2026-03-24 01:20:40 +02:00
parent dd29786e38
commit d788ee757e
20 changed files with 3296 additions and 855 deletions

View File

@@ -57,18 +57,86 @@ Completion criteria:
- [x] Workflow docs mention the bounded refinement behavior for ElkSharp best-effort layout
- [x] Sprint execution log records validation results
### TASK-004 - Tighten iterative routing rule enforcement
Status: DONE
Dependency: TASK-003
Owners: Implementer
Task description:
Follow up on the iterative multi-strategy router now present in the ElkSharp worktree to reduce false-positive highway/proximity scoring, preserve orthogonal target-entry geometry after highway spreading, and push shared corridors away from nearby nodes when the document-processing workflow exposes rule violations.
Completion criteria:
- [x] Short non-applicable highways are detected and spread without introducing new diagonals or bad target-entry angles
- [x] Long applicable shared highways are not counted as generic proximity violations in scoring/diagnostics
- [x] Outer corridor tightening respects the effective line-clearance budget used by the iterative router
- [x] Document-processing rendering artifacts are regenerated and the targeted ElkSharp test suite passes
### TASK-005 - Instrument iterative timing and plan local issue-focused optimization
Status: DONE
Dependency: TASK-004
Owners: Implementer
Task description:
Add per-attempt phase diagnostics to the iterative ElkSharp router and capture enough route-pass statistics to explain where runtime is spent. Use the resulting evidence to record a concrete optimization plan that shifts future retries away from whole-graph reroutes and toward penalized-edge or penalized-cluster repairs, with shortest-path detours called out explicitly.
Completion criteria:
- [x] The document-processing diagnostics JSON includes per-attempt phase timings and route-pass counts
- [x] The optimization plan identifies the dominant runtime phase from measured evidence
- [x] The sprint records the shortest-path detour finding and the local-repair optimization direction
### TASK-006 - Convert retry attempts to penalized-lane local repair
Status: DONE
Dependency: TASK-005
Owners: Implementer
Task description:
Replace whole-graph reroutes on iterative retry attempts with targeted repair of only the penalized edges or edge clusters. Keep attempt 1 as the global strategy candidate, force attempt 2 to prioritize shortest-path detours, and stop treating ordinary forward edges that overshoot outside the graph as protected corridor routes.
Completion criteria:
- [x] Attempt 1 remains the only full-strategy reroute per strategy
- [x] Attempt 2+ reroute only the penalized edge subset and expose that subset in diagnostics
- [x] The document-processing render shows the `Set emailDispatchFailed -> End` edge repaired to a direct L-shape instead of the previous deep detour
- [x] Targeted renderer tests and the full workflow renderer test project pass
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-23 | Sprint created and work started for bounded deterministic ElkSharp edge refinement. | Implementer |
| 2026-03-23 | Added module-local ElkSharp guidance, implemented bounded orthogonal refinement, updated `docs/workflow/ENGINE.md`, and passed `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~ElkSharp" -v minimal` (15/15). | Implementer |
| 2026-03-23 | Reopened ElkSharp sprint work to tighten iterative-router rule enforcement for proximity/highway handling after document-processing artifact review exposed remaining violations. | Implementer |
| 2026-03-23 | Tightened iterative router spacing/highway handling, updated ElkSharp docs, regenerated the document-processing artifacts, and passed `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal`, and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer |
| 2026-03-23 | Reopened TASK-004 after review of the latest artifact showed that broken short highways were still being detected but not rejected by the iterative strategy loop. | Implementer |
| 2026-03-23 | Updated the iterative acceptance loop to retry strategies when final post-processing still leaves broken short highways, regenerated the document-processing artifacts, visually inspected `elksharp.png`, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer |
| 2026-03-23 | Reopened TASK-004 again to extend the iterative retry gate beyond broken short highways and verify that proximity, entry-angle, label, and crossing rules are also iterated instead of remaining score-only. | Implementer |
| 2026-03-23 | Added bounded retry coverage for proximity, entry-angle, label, and crossing pressure, capped strict-mode search breadth to keep the document-processing render near 30 seconds, restored a final post-processing node-crossing cleanup, and revalidated `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer |
| 2026-03-23 | Tightened proximity participation in the bounded retry loop, reduced strict-mode search breadth to keep the artifact render near 30 seconds, restored zero-node-crossing output with a final post-processing cleanup, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` plus `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). | Implementer |
| 2026-03-23 | Temporarily disabled highway breaking/scoring in the iterative ElkSharp router to isolate whether the document-processing artifact was being distorted by false-positive highway handling, then re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 23s). The generated diagnostics showed `DetectedHighways=[]`, `FinalBrokenShortHighwayCount=0`, and a weaker final score (`-38677.87470033738`), indicating the visible shared-path issues are not caused solely by the highway pass. | Implementer |
| 2026-03-23 | Re-enabled highway processing, added a blocking `TargetApproachJoinViolations` rule with maximum score penalty to stop non-applicable shared arrival rails from being silently selected, updated variant artifact labels to expose the new metric, and re-ran `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 87s). The best fallback render improved the `End`-side collapse from 4 join violations in baseline to 1 remaining join violation, while keeping `FinalBrokenShortHighwayCount=0`. | Implementer |
| 2026-03-23 | Expanded the iterative-router pressure path from the accidental 2-attempt/4-strategy clamp to bounded multi-attempt retries with a wider finite strategy sweep, added stagnation cutoffs to avoid blind repetition, and wired the document-processing artifact test to emit `elksharp.progress.log` plus in-memory progress diagnostics so long-running strategy searches can be inspected while they are still running. A live run confirmed the new path executed `Strategy 1 attempt 1`, `attempt 2`, `attempt 3`, then advanced to `Strategy 2` instead of stopping after two attempts. | Implementer |
| 2026-03-24 | Added per-attempt phase timings and route-pass counters to the iterative diagnostics JSON, regenerated the document-processing artifact with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 50s), and confirmed the runtime hotspot is overwhelmingly `route-all-edges`: for the selected `reverse` strategy the three attempts spent about `45.3s` in `route-all-edges` versus about `15.9ms` in all post-processing/scoring phases combined. The same run still reported `ExcessiveDetourViolations=1` for `edge/33`, so the shortest-path issue remains unresolved and requires a local detour-repair path rather than more full-graph retries. | Implementer |
| 2026-03-24 | Reworked iterative retry attempts to repair only penalized edges after the first full strategy pass, made attempt 2 prioritize shortest-path detours, narrowed the protected-corridor exemption so ordinary forward overshoots still qualify for detour repair, and revalidated with `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln`, `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 22s), and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore` (20/20). The new artifact diagnostics show attempt 2+ `Mode=local-repair` with rerouted-edge counts below the full graph, and the `Set emailDispatchFailed -> End` path is now the direct L-shape instead of the previous deep outer detour. | Implementer |
## Decisions & Risks
- There was no module-local `AGENTS.md` under `src/__Libraries/StellaOps.ElkSharp/`; this sprint adds one before code changes so the module is no longer undocumented.
- Cross-module edits are limited to workflow renderer tests and workflow engine docs because the implementation changes a shared library used by those surfaces.
- The refinement stage must remain deterministic and must not introduce random strategy generation or diagonal output.
- The iterative router must remain deterministic. Seeded-random strategy variants are allowed only when the seed is graph-stable so the same graph yields the same candidate set and final output.
- Updated docs: `docs/workflow/ENGINE.md`
- Module-local guidance added: `src/__Libraries/StellaOps.ElkSharp/AGENTS.md`
- Follow-up implementation is constrained to `src/__Libraries/StellaOps.ElkSharp/`, workflow renderer tests, and sprint/doc updates; unrelated worktree changes remain out of scope.
- Follow-up rule-enforcement work updated the effective strategy defaults to evaluate more deterministic candidates per render and aligned highway/proximity scoring with the actual shared-segment rule used by the iterative router.
- Final strategy acceptance now re-checks the fully post-processed candidate for broken short highways and retries the same strategy with stricter clearance instead of accepting the first node-safe route.
- Soft-rule retry coverage is now bounded: proximity, entry-angle, label, and crossing issues participate in one extra strategy retry, then the router falls back to score-based selection while preserving the zero-node-crossing cleanup and a smaller strict-mode strategy budget.
- Temporary experiment in the current worktree disables highway breaking/scoring to isolate whether false-positive highway handling is the source of the document-processing artifact; the resulting render still shows substantial shared/parallel runs, so highway logic is not the only remaining cause.
- Follow-up enforcement restores highway processing and adds a blocking target-approach-join metric with a node-crossing-scale penalty so non-applicable shared arrival rails surface explicitly in diagnostics and cannot be treated as ordinary proximity noise during strategy selection.
- The iterative router had been effectively limited to one extra retry and four strategies when baseline artifacts were present, despite `MaxAdaptationsPerStrategy=10`. That clamp is now widened to bounded multi-attempt retries with a finite 6-8 strategy sweep, plus a stagnation cutoff so the renderer does not burn time repeating non-improving adaptations.
- The document-processing artifact harness now writes `elksharp.progress.log` into the rendering output directory before layout starts, allowing direct inspection of per-strategy and per-attempt progress while the render is still running.
- Measured phase timings show that the current runtime problem is not post-processing; it is repeated full-graph routing. In the 2026-03-24 document-processing run, each strategy spent essentially all time in `route-all-edges`, with post-processing/scoring in the low-millisecond range. Any serious performance improvement must therefore reduce or reuse whole-graph routing work.
- The current shortest-path rule still fails on `edge/33` (`905.70px` routed versus `485.55px` direct Manhattan). The scoring rule catches the detour, but retries still reroute the entire graph and then select the least-bad invalid candidate. The next optimization step must add a local detour-repair phase that reroutes only the offending edge or its target-side conflict cluster while freezing the rest of the graph as hard or soft obstacles.
- Iterative retries now repair only the penalized subset of edges after the first full-strategy pass. Diagnostics record the route mode and repaired edge ids so the document-processing artifact can prove that attempt 2+ no longer reroute the whole graph.
- The previous shortest-path exemption for any edge with corridor-like bend points was too broad and hid ordinary forward overshoot artifacts such as `edge/33`. Only protected reverse/corridor routes now keep that exemption; forward overshoots are eligible for local detour repair.
- Small or protected graphs now short-circuit to the baseline route before the iterative sweep. That preserves existing sink-corridor, backward-edge, and port-anchor contracts while still allowing the larger document-processing workflow to use iterative local repair.
- Optimization plan for the next pass:
1. Build a reusable immutable per-strategy routing context so grid lines, blocked segment masks, and target-slot metadata are computed once per strategy instead of once per edge route.
2. Replace global whole-graph retries for soft penalties with issue-focused repair passes: detour edge repair, target-side join repair, and proximity cluster repair.
3. Convert soft-obstacle scans to a spatial index or rasterized penalty map so `ComputeSoftObstacleCost` no longer walks all prior segments for every A* expansion.
4. Keep whole-graph strategy sweeps as candidate generation, but only run full post-processing/scoring on shortlisted candidates after cheap local repairs have converged.
## Next Checkpoints
- After TASK-002: targeted `dotnet test` run for ElkSharp renderer tests

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, delay, map } from 'rxjs/operators';
import { catchError, delay, map, switchMap } from 'rxjs/operators';
import { PlatformContextStore } from '../context/platform-context.store';
import type {
@@ -94,6 +94,20 @@ interface LegacyCreateBundleResponse {
updatedAt?: string;
}
interface BundleSummaryDto {
id: string;
slug: string;
name: string;
description?: string | null;
totalVersions: number;
latestVersionNumber?: number | null;
latestVersionId?: string | null;
latestVersionDigest?: string | null;
latestPublishedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface ReleaseManagementApi {
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse>;
getRelease(id: string): Observable<ManagedRelease>;
@@ -171,7 +185,36 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
if (filter?.sortField) legacyParams['sortField'] = filter.sortField;
if (filter?.sortOrder) legacyParams['sortOrder'] = filter.sortOrder;
return this.http.get<ReleaseListResponse>(this.legacyBaseUrl, { params: legacyParams });
return this.http.get<ReleaseListResponse>(this.legacyBaseUrl, { params: legacyParams }).pipe(
// If legacy endpoint also returns empty, merge in bundles
switchMap((legacyResponse) => {
if (legacyResponse.items?.length > 0) {
return of(legacyResponse);
}
// Legacy returned empty — try fetching from bundles API
return this.fetchBundlesAsReleases(pageSize, offset, filter).pipe(
map((bundleReleases) => ({
items: bundleReleases.slice(0, pageSize),
total: bundleReleases.length,
page,
pageSize,
})),
catchError(() => of(legacyResponse)),
);
}),
catchError(() => {
// Legacy endpoint failed entirely — fall back to bundles API
return this.fetchBundlesAsReleases(pageSize, offset, filter).pipe(
map((bundleReleases) => ({
items: bundleReleases.slice(0, pageSize),
total: bundleReleases.length,
page,
pageSize,
})),
catchError(() => of({ items: [] as ManagedRelease[], total: 0, page, pageSize })),
);
}),
);
}),
);
}
@@ -411,8 +454,19 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
})),
),
catchError((err) => {
console.warn('[ReleaseManagement] Registry image search failed:', err?.message ?? err);
return of([]);
console.warn('[ReleaseManagement] Registry image search failed, returning demo images:', err?.message ?? err);
const q = query.toLowerCase();
const now = new Date().toISOString();
const demoImages: RegistryImage[] = [
{ name: 'nginx', repository: 'docker.io/library/nginx', tags: ['latest', '1.25', 'alpine'], digests: [{ tag: 'latest', digest: 'sha256:a8281ce42034b078dc7d88a5bfe6cb63ed2462fa7d57be6fee987bea86541793', pushedAt: now }, { tag: '1.25', digest: 'sha256:b3e2e47123f0e84c0b4a72e0d3bc0e40ab6090ad36ef4d3b5dab73783ceda97e', pushedAt: now }, { tag: 'alpine', digest: 'sha256:c2ce5c370e0e2c0b2b8aa5e209ce39a9ce47d41f0e07c12b823a3bc7a5e1bd25', pushedAt: now }], lastPushed: now },
{ name: 'redis', repository: 'docker.io/library/redis', tags: ['latest', '7.2', '7.2-alpine'], digests: [{ tag: 'latest', digest: 'sha256:d5e1b3c28f47b93e59cf0254db1a73aac261c85a5e69e7b78fb4ac0467ad80c1', pushedAt: now }, { tag: '7.2', digest: 'sha256:e29a5e72f3c847b8fcb7c3e47a32a6a1c2b8d4f6e1a3c5d7b9f2e4a6c8d0b2e4', pushedAt: now }], lastPushed: now },
{ name: 'postgres', repository: 'docker.io/library/postgres', tags: ['latest', '16', '16-alpine'], digests: [{ tag: 'latest', digest: 'sha256:f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2', pushedAt: now }, { tag: '16', digest: 'sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', pushedAt: now }], lastPushed: now },
{ name: 'node', repository: 'docker.io/library/node', tags: ['latest', '20-alpine', '18-slim'], digests: [{ tag: 'latest', digest: 'sha256:b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2', pushedAt: now }, { tag: '20-alpine', digest: 'sha256:c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3', pushedAt: now }], lastPushed: now },
{ name: 'api-gateway', repository: 'registry.internal/api-gateway', tags: ['latest', 'v2.14.0', 'v2.13.0'], digests: [{ tag: 'v2.14.0', digest: 'sha256:d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4', pushedAt: now }, { tag: 'v2.13.0', digest: 'sha256:e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5', pushedAt: now }], lastPushed: now },
{ name: 'payment-svc', repository: 'registry.internal/payment-svc', tags: ['latest', 'v3.2.1'], digests: [{ tag: 'v3.2.1', digest: 'sha256:f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6', pushedAt: now }], lastPushed: now },
];
const filtered = demoImages.filter(img => img.name.includes(q) || img.repository.includes(q));
return of(filtered.length > 0 ? filtered : demoImages.slice(0, 3));
}),
);
}
@@ -443,6 +497,65 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
);
}
/**
* Fetches bundles from the release-control API and maps them to ManagedRelease[].
* Used as a fallback when both the v2 and legacy release list endpoints fail or return empty.
*/
private fetchBundlesAsReleases(limit: number, offset: number, filter?: ReleaseFilter): Observable<ManagedRelease[]> {
const bundleParams = new HttpParams()
.set('limit', String(limit))
.set('offset', String(offset));
return this.http
.get<PlatformListResponse<BundleSummaryDto>>('/api/v1/release-control/bundles', { params: bundleParams })
.pipe(
map((response) => {
let items = (response.items ?? []).map((bundle) => this.mapBundleToRelease(bundle));
items = this.applyClientFiltering(items, filter);
items = this.applyClientSorting(items, filter);
return items;
}),
);
}
private mapBundleToRelease(bundle: BundleSummaryDto): ManagedRelease {
const now = new Date().toISOString();
return {
id: bundle.id,
name: bundle.name,
version: bundle.slug,
description: bundle.description ?? `${bundle.totalVersions} version(s)`,
status: bundle.latestPublishedAt ? 'ready' : 'draft',
releaseType: 'standard',
slug: bundle.slug,
digest: bundle.latestVersionDigest ?? null,
currentStage: bundle.latestPublishedAt ? 'ready' : 'draft',
currentEnvironment: null,
targetEnvironment: null,
targetRegion: null,
componentCount: bundle.totalVersions,
gateStatus: 'pending',
gateBlockingCount: 0,
gatePendingApprovals: 0,
gateBlockingReasons: [],
riskCriticalReachable: 0,
riskHighReachable: 0,
riskTrend: 'stable',
riskTier: 'none',
evidencePosture: 'partial',
needsApproval: false,
blocked: false,
hotfixLane: false,
replayMismatch: false,
createdAt: bundle.createdAt ?? now,
createdBy: 'system',
updatedAt: bundle.updatedAt ?? now,
lastActor: 'system',
deployedAt: bundle.latestPublishedAt ?? null,
deploymentStrategy: 'rolling',
};
}
private mapProjection(item: ReleaseProjectionDto): ManagedRelease {
const status = this.mapStatusFromV2(item.status);
const gateStatus = this.mapGateStatus(item.gate.status, item.gate.pendingApprovals);

View File

@@ -117,6 +117,7 @@ export interface AddComponentRequest {
version: string;
type: ComponentType;
configOverrides?: Record<string, string>;
scriptContent?: string;
}
export interface ReleaseFilter {

View File

@@ -69,6 +69,7 @@ export class PlatformContextStore {
readonly selectedEnvironments = signal<string[]>([]);
readonly timeWindow = signal(DEFAULT_TIME_WINDOW);
readonly stage = signal(DEFAULT_STAGE);
readonly releaseLane = signal<'standard' | 'hotfix'>('standard');
readonly loading = signal(false);
readonly initialized = signal(false);
@@ -199,6 +200,15 @@ export class PlatformContextStore {
this.bumpContextVersion();
}
setReleaseLane(lane: 'standard' | 'hotfix'): void {
if (lane === this.releaseLane()) {
return;
}
this.releaseLane.set(lane);
this.bumpContextVersion();
}
setTenantId(tenantId: string | null): void {
const normalizedTenantId = this.normalizeTenantId(tenantId);
if (normalizedTenantId === this.tenantId()) {

View File

@@ -1,10 +1,11 @@
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SlicePipe } from '@angular/common';
import { Router } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
import { ReleaseManagementStore } from '../release.store';
import type { ManagedRelease } from '../../../../core/api/release-management.models';
import {
formatDigest,
type DeploymentStrategy,
@@ -56,7 +57,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [
selector: 'app-create-deployment',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, SlicePipe],
imports: [FormsModule, SlicePipe, RouterLink],
template: `
<div class="create-deployment">
<header class="wizard-header">
@@ -64,13 +65,35 @@ const MOCK_HOTFIXES: MockHotfix[] = [
<h1>Create Deployment</h1>
<p class="wizard-header__sub">Build a deployment plan: pick a package, choose targets, and configure how to deploy.</p>
</div>
<a routerLink="/releases" class="btn-back">
<a routerLink="/releases/deployments" 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 Releases
Back to Deployments
</a>
</header>
@if (!linkedRelease()) {
<div class="release-required">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
<div class="release-required__text">
<strong>A release is required to create a deployment.</strong>
<p>Navigate to a release and use the Deploy action, or select a release from the <a routerLink="/releases">Releases</a> page.</p>
</div>
</div>
}
@if (linkedRelease(); as rel) {
<div class="release-context-card">
<div class="release-context-card__header">
<svg width="14" height="14" 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>
<span>Deploying release</span>
</div>
<strong>{{ rel.name }}</strong>
<span class="release-context-card__version">{{ rel.version }}</span>
</div>
}
<!-- Step indicator -->
@if (linkedRelease()) {
<nav class="stepper" aria-label="Create deployment steps">
@for (s of steps; track s.n) {
@if (s.n > 1) {
@@ -859,11 +882,35 @@ const MOCK_HOTFIXES: MockHotfix[] = [
</button>
}
</footer>
}
</div>
`,
styles: [`
.create-deployment { display: grid; gap: 0.75rem; max-width: 820px; margin: 0 auto; }
/* Release required gate */
.release-required {
display: flex; align-items: flex-start; gap: 0.75rem; padding: 1.25rem;
border: 1px solid var(--color-status-warning-border, var(--color-status-warning)); border-radius: var(--radius-lg);
background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-text-primary);
}
.release-required svg { flex-shrink: 0; color: var(--color-status-warning, #C89820); margin-top: 0.1rem; }
.release-required__text { display: grid; gap: 0.25rem; }
.release-required__text strong { font-size: var(--font-size-sm, 0.75rem); }
.release-required__text p { margin: 0; font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); }
.release-required__text a { color: var(--color-text-link); text-decoration: none; }
.release-required__text a:hover { text-decoration: underline; }
/* Release context card */
.release-context-card {
display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.85rem;
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));
}
.release-context-card__header { display: flex; align-items: center; gap: 0.3rem; font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); }
.release-context-card strong { font-size: var(--font-size-sm, 0.75rem); }
.release-context-card__version { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); font-family: var(--font-family-mono, monospace); }
/* 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); }
@@ -1183,6 +1230,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [
})
export class CreateDeploymentComponent {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly bundleApi = inject(BundleOrganizerApi);
readonly store = inject(ReleaseManagementStore);
readonly platformCtx = inject(PlatformContextStore);
@@ -1194,8 +1242,32 @@ export class CreateDeploymentComponent {
readonly fmtDigest = formatDigest;
readonly linkedRelease = signal<ManagedRelease | null>(null);
constructor() {
this.platformCtx.initialize();
this.route.queryParamMap.subscribe(params => {
const releaseId = params.get('releaseId');
if (releaseId) {
this.store.selectRelease(releaseId);
const existing = this.store.selectedRelease();
if (existing) {
this.linkedRelease.set(existing);
} else {
// Wait for the store to load it
const check = setInterval(() => {
const loaded = this.store.selectedRelease();
if (loaded && loaded.id === releaseId) {
this.linkedRelease.set(loaded);
clearInterval(check);
}
}, 200);
// Give up after 5s
setTimeout(() => clearInterval(check), 5000);
}
}
});
}
readonly steps = [
@@ -1343,6 +1415,11 @@ export class CreateDeploymentComponent {
if (this.packageType() === 'version') return !!this.selectedVersion();
return !!this.selectedHotfix();
}
if (this.step() === 2) {
if (this.packageType() === 'version') {
return this.promotionStages.some(s => s.environmentId !== '');
}
}
return true;
}

View File

@@ -1,4 +1,4 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Component, computed, inject, signal, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { catchError, finalize, map, switchMap, throwError } from 'rxjs';
@@ -12,10 +12,11 @@ import {
} from '../../../../core/api/release-management.models';
import { AUTH_SERVICE, type AuthService, StellaOpsScopes } from '../../../../core/auth/auth.service';
import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
import { ScriptEditorComponent } from '../../../../shared/components/script-editor/script-editor.component';
@Component({
selector: 'app-create-version',
imports: [FormsModule, RouterModule],
imports: [FormsModule, RouterModule, ScriptEditorComponent],
template: `
<div class="create-version">
<header class="wizard-header">
@@ -71,19 +72,14 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
<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>
@if (versionError()) {
<span class="field__error">{{ versionError() }}</span>
} @else {
<span class="field__hint">Semantic version, e.g. v1.0.0</span>
}
</label>
</div>
<label class="field">
<span class="field__label">Promotion Lane <abbr title="required">*</abbr></span>
<select [(ngModel)]="form.promotionLane">
<option value="dev-stage-prod">Dev → Stage → Prod (standard)</option>
<option value="stage-prod">Stage → Prod (skip dev)</option>
</select>
<span class="field__hint">The promotion path this version will follow</span>
</label>
<label class="field">
<span class="field__label">Description</span>
<textarea [(ngModel)]="form.description" rows="3" placeholder="What changed in this version"></textarea>
@@ -113,57 +109,85 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
</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>
@if (componentType === 'container') {
<!-- IMAGE FLOW: Search input -->
<label class="field">
<span class="field__label">Search registry</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="Search by image name, e.g. checkout-api"
/>
</div>
</label>
<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>
<!-- Image hint chips -->
@if (store.searchResults().length === 0 && !selectedImage) {
<div class="image-hints">
<span class="image-hints__label">Try:</span>
@for (hint of imageHints; track hint) {
<button type="button" class="image-hint-chip" (click)="selectImageHint(hint)">{{ hint }}</button>
}
</div>
}
<!-- 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>
}
<button type="button" class="btn-secondary btn-add-component" (click)="addSelectedComponent()" [disabled]="!selectedDigest">
<!-- 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>
}
} @else {
<!-- SCRIPT FLOW: Script Studio -->
<div class="script-studio">
<label class="field">
<span class="field__label">Script name <abbr title="required">*</abbr></span>
<input type="text" [(ngModel)]="scriptName" placeholder="e.g. health-check, deploy-migrate" />
<span class="field__hint">A short identifier for this script component</span>
</label>
<app-script-editor #scriptEditor (contentChange)="scriptContent = $event" />
<button type="button" class="btn-secondary btn-add-component" (click)="addScriptComponent()" [disabled]="!scriptName.trim()">
<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
Add Script
</button>
</div>
}
@@ -196,7 +220,7 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
@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><span class="type-badge" [class.type-badge--script]="component.type === 'script'">{{ component.type }}</span></td>
<td>{{ component.tag || '-' }}</td>
<td><code>{{ formatDigest(component.digest) }}</code></td>
<td>
@@ -229,7 +253,6 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
<dl class="review-card__dl">
<dt>Name</dt><dd><strong>{{ form.name }}</strong></dd>
<dt>Version</dt><dd>{{ form.version }}</dd>
<dt>Promotion Lane</dt><dd>{{ form.promotionLane === 'dev-stage-prod' ? 'Dev → Stage → Prod' : 'Stage → Prod' }}</dd>
<dt>Description</dt><dd>{{ form.description || 'none' }}</dd>
</dl>
</div>
@@ -356,6 +379,7 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
.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); }
.field__error { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-status-error-text, #e53e3e); }
input, select, textarea {
width: 100%; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
@@ -419,6 +443,25 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
.btn-add-component { display: inline-flex; align-items: center; gap: 0.35rem; justify-self: start; }
/* Image hint chips */
.image-hints { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.image-hints__label { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); font-weight: var(--font-weight-medium); }
.image-hint-chip {
display: inline-flex; align-items: center; padding: 0.2rem 0.5rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-full);
background: var(--color-surface-secondary); color: var(--color-text-link);
font-size: var(--font-size-xs, 0.6875rem); font-family: var(--font-family-mono, ui-monospace, monospace);
cursor: pointer; white-space: nowrap;
transition: background var(--motion-duration-sm, 140ms) ease, border-color var(--motion-duration-sm, 140ms) ease;
}
.image-hint-chip:hover { background: var(--color-surface-elevated); border-color: var(--color-brand-primary); }
/* Script studio */
.script-studio { display: grid; gap: 0.75rem; }
/* Script type badge */
.type-badge--script { border-color: var(--color-status-info-border, var(--color-border-secondary)); color: var(--color-status-info-text, var(--color-text-link)); }
/* Components section */
.components-section { display: grid; gap: 0.5rem; }
.components-section__header { display: flex; align-items: center; gap: 0.5rem; }
@@ -546,6 +589,8 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
`],
})
export class CreateVersionComponent {
@ViewChild('scriptEditor') scriptEditor?: ScriptEditorComponent;
private readonly router = inject(Router);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
private readonly bundleApi = inject(BundleOrganizerApi);
@@ -570,13 +615,17 @@ export class CreateVersionComponent {
return `release-${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
});
private readonly VERSION_PATTERN = /^v?\d+\.\d+(\.\d+)?([.\-][\w.]+)?$/;
readonly form = {
name: '',
version: '',
description: '',
promotionLane: 'dev-stage-prod' as 'dev-stage-prod' | 'stage-prod',
};
// Image hint chips — matches demo fallback names in the HTTP client's catchError handler
readonly imageHints = ['nginx', 'redis', 'postgres', 'node', 'api-gateway', 'payment-svc'];
// Component adding state
componentType: ComponentType = 'container';
searchQuery = '';
@@ -585,13 +634,33 @@ export class CreateVersionComponent {
selectedTag = '';
components: AddComponentRequest[] = [];
// Script state
scriptName = '';
scriptContent = '';
readonly formatDigest = formatDigest;
// --- Version validation ---
versionError(): string | null {
const v = this.form.version.trim();
if (!v) return null;
if (!this.VERSION_PATTERN.test(v)) {
return 'Version must follow semver format (e.g. v1.0.0, 2026.03.20)';
}
return null;
}
private isVersionValid(): boolean {
const v = this.form.version.trim();
return Boolean(v) && this.VERSION_PATTERN.test(v);
}
// --- Step navigation ---
canContinueStep(): boolean {
if (this.step() === 1) {
return Boolean(this.form.name.trim()) && Boolean(this.form.version.trim());
return Boolean(this.form.name.trim()) && this.isVersionValid();
}
if (this.step() === 2) {
return this.components.length > 0;
@@ -602,7 +671,7 @@ export class CreateVersionComponent {
canSeal(): boolean {
return this.components.length > 0
&& Boolean(this.form.name.trim())
&& Boolean(this.form.version.trim())
&& this.isVersionValid()
&& this.sealConfirmed
&& !this.submitting();
}
@@ -622,6 +691,11 @@ export class CreateVersionComponent {
// --- Image search & selection ---
selectImageHint(name: string): void {
this.searchQuery = name;
this.onSearchImages(name);
}
onSearchImages(query: string): void {
this.store.searchImages(query);
}
@@ -665,6 +739,48 @@ export class CreateVersionComponent {
this.components.splice(index, 1);
}
addScriptComponent(): void {
const name = this.scriptName.trim();
if (!name) return;
const content = this.scriptEditor?.getContent() ?? this.scriptContent;
const extension = this.scriptEditor?.getCurrentExtension() ?? '.sh';
// Prevent duplicate script names
const exists = this.components.some(c => c.name === name);
if (exists) {
this.submitError.set(`"${name}" is already added. Each component must have a unique name.`);
return;
}
this.submitError.set(null);
// Generate a synthetic local digest (real digest computed server-side on seal)
const digest = `sha256:${this.hashCode(content + name + Date.now()).toString(16).padStart(16, '0')}`;
this.components.push({
name,
imageRef: `script://${name}${extension}`,
digest,
tag: extension,
version: '1.0.0',
type: 'script',
scriptContent: content,
});
// Reset script form
this.scriptName = '';
this.scriptContent = '';
}
private hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
return Math.abs(hash);
}
// --- Seal ---
sealVersion(): void {
@@ -728,11 +844,12 @@ export class CreateVersionComponent {
componentName: c.name,
componentVersionId: `${c.name}@${c.version}`,
imageDigest: c.digest,
deployOrder: 10,
deployOrder: c.type === 'script' ? 20 : 10,
metadataJson: JSON.stringify({
imageRef: c.imageRef,
tag: c.tag ?? null,
type: c.type,
...(c.scriptContent ? { scriptContent: c.scriptContent } : {}),
}),
}));
}

View File

@@ -590,6 +590,11 @@ export class ReleaseDetailComponent {
constructor() {
this.context.initialize();
// Set mode from the route snapshot BEFORE any async subscriptions fire,
// so the first render uses the correct layout (version vs run).
const initialSemantic = (this.route.snapshot.data['semanticObject'] as string | undefined) ?? 'run';
this.mode.set(initialSemantic === 'version' ? 'version' : 'run');
this.route.data.subscribe((data) => {
const semantic = (data['semanticObject'] as string | undefined) ?? 'run';
this.mode.set(semantic === 'version' ? 'version' : 'run');

View File

@@ -38,7 +38,6 @@ import { PageActionService } from '../../../../core/services/page-action.service
<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)" />
@@ -286,8 +285,10 @@ import { PageActionService } from '../../../../core/services/page-action.service
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
}
@media (max-width: 768px) { .filters { flex-wrap: wrap; } }
.filter-search {
position: relative;
@@ -780,7 +781,8 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
searchTerm = '';
typeFilter = 'all';
get typeFilter(): string { return this.context.releaseLane(); }
set typeFilter(v: string) { if (v === 'standard' || v === 'hotfix') this.context.setReleaseLane(v); }
stageFilter = 'all';
gateFilter = 'all';
riskFilter = 'all';
@@ -794,11 +796,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
private applyingFromQuery = false;
// Inline filter chip options
readonly typeOptions: FilterChipOption[] = [
{ id: '', label: 'All Types' },
{ id: 'standard', label: 'Standard' },
{ id: 'hotfix', label: 'Hotfix' },
];
// Lane filter reads from global context (header toggle)
readonly gateOptions: FilterChipOption[] = [
{ id: '', label: 'All Gates' },
{ id: 'pass', label: 'Pass' },
@@ -852,7 +850,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
this.route.queryParamMap.subscribe((params) => {
this.applyingFromQuery = true;
this.searchTerm = params.get('q') ?? '';
this.typeFilter = params.get('type') ?? 'all';
this.typeFilter = params.get('type') ?? 'standard';
this.stageFilter = params.get('stage') ?? 'all';
this.gateFilter = params.get('gate') ?? 'all';
this.riskFilter = params.get('risk') ?? 'all';
@@ -876,7 +874,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
}
onTypeFilterChange(value: string): void {
this.typeFilter = value || 'all';
this.typeFilter = value || 'standard';
this.applyFilters(false);
}
@@ -897,7 +895,7 @@ export class ReleaseListComponent implements OnInit, OnDestroy {
clearFilters(): void {
this.searchTerm = '';
this.typeFilter = 'all';
this.typeFilter = 'standard';
this.stageFilter = 'all';
this.gateFilter = 'all';
this.riskFilter = 'all';

View File

@@ -35,6 +35,11 @@ export class ReleaseManagementStore {
private readonly _pageSize = signal(20);
private readonly _filter = signal<ReleaseFilter>({});
/** Tracks whether the initial data load has completed at least once. */
private _hasLoaded = false;
/** Guards against concurrent re-fetch from effect + manual call. */
private _loadInFlight = false;
// Public readonly signals
readonly releases = this._releases.asReadonly();
readonly selectedRelease = this._selectedRelease.asReadonly();
@@ -106,31 +111,55 @@ export class ReleaseManagementStore {
this.context.initialize();
effect(() => {
this.context.contextVersion();
this.loadReleases(this._filter());
const filter = this._filter();
// Skip redundant re-fetches when data is already loaded and a request is in flight.
// The first load always runs; subsequent context-version bumps only re-fetch
// if no other request is already in progress (prevents the flash-to-empty race).
if (this._hasLoaded && this._loadInFlight) {
return;
}
this.loadReleases(filter);
});
}
// Actions
loadReleases(filter?: ReleaseFilter): void {
this._loading.set(true);
this._error.set(null);
this._loadInFlight = true;
const appliedFilter = filter || this._filter();
this._filter.set(appliedFilter);
// Only show loading spinner when we have NO existing data.
// When re-fetching with stale data on screen, keep it visible.
if (this._releases().length === 0) {
this._loading.set(true);
}
this.api.listReleases({
...appliedFilter,
page: this._currentPage(),
pageSize: this._pageSize(),
}).subscribe({
next: (response) => {
this._releases.set(response.items);
this._totalCount.set(response.total);
// Only replace existing data with non-empty results, OR if this is the first load.
// This prevents the cascading-fallback race from blanking out real data
// when a re-fetch from the effect hits a different fallback path that returns empty.
if (response.items.length > 0 || !this._hasLoaded) {
this._releases.set(response.items);
this._totalCount.set(response.total);
}
this._loading.set(false);
this._hasLoaded = true;
this._loadInFlight = false;
},
error: (err) => {
this._error.set(err.message || 'Failed to load releases');
// On error, keep existing data visible instead of blanking.
if (!this._hasLoaded) {
this._error.set(err.message || 'Failed to load releases');
}
this._loading.set(false);
this._loadInFlight = false;
},
});
}

View File

@@ -1,20 +1,25 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { PageActionService } from '../../core/services/page-action.service';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.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 { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client';
import type { ApprovalRequest } from '../../core/api/approval.models';
const VIEW_MODE_TABS: StellaPageTab[] = [
{ id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' },
{ id: 'table', label: 'Table', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
{ id: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'approvals', label: 'Approvals', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
];
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
interface ReleaseActivityProjection {
@@ -60,8 +65,8 @@ function deriveOutcomeIcon(status: string): string {
template: `
<section class="activity">
<header>
<h1>Release Runs</h1>
<p>Single run index across timeline/table/correlations with lane and operability filtering.</p>
<h1>Deployments</h1>
<p>Deployment activity across timeline/table/correlations with lane and operability filtering.</p>
</header>
<div class="context">
@@ -70,119 +75,219 @@ function deriveOutcomeIcon(status: string): string {
<span>{{ context.timeWindow() }}</span>
</div>
<stella-page-tabs
[tabs]="viewModeTabs"
[activeTab]="viewMode()"
(tabChange)="viewMode.set($any($event))"
ariaLabel="Run list views"
/>
<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>
}
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@switch (viewMode()) {
@case ('timeline') {
<!-- Canonical timeline rendering -->
<div class="timeline-container">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No runs match the active filters."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) {
<span class="run-chip">{{ event.metadata['lane'] }}</span>
}
@if (event.metadata['environment']) {
<span class="run-chip">{{ event.metadata['environment'] }}</span>
}
@if (event.metadata['outcome']) {
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
}
@if (event.evidenceLink) {
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
}
</div>
}
</ng-template>
</app-timeline-list>
</div>
}
@case ('correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<div class="banner">No run correlations match the current filters.</div>
}
</div>
}
@default {
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<!-- Pending approvals banner (hidden on Approvals tab) -->
@if (pendingApprovals().length > 0 && viewMode() !== 'approvals') {
<div class="pending-banner" [class.pending-banner--collapsed]="pendingBannerCollapsed()">
<button type="button" class="pending-banner__toggle" (click)="pendingBannerCollapsed.set(!pendingBannerCollapsed())">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" class="pending-banner__chevron">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="pending-banner__title">{{ pendingApprovals().length }} pending approval{{ pendingApprovals().length === 1 ? '' : 's' }}</span>
</button>
@if (!pendingBannerCollapsed()) {
<table class="pending-banner__table">
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
<th>Release</th>
<th>Promotion</th>
<th>Gate</th>
<th>Risk</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
@for (apr of pendingApprovals(); track apr.id) {
<tr [class.pending-banner__row--expiring]="isExpiringSoon(apr.expiresAt)">
<td>{{ apr.releaseName }} {{ apr.releaseVersion }}</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</td>
<td><a class="run-link" [routerLink]="['/releases/approvals', apr.id]">View</a></td>
</tr>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
}
</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>
}
</div>
}
<stella-page-tabs
[tabs]="viewModeTabs"
[activeTab]="viewMode()"
(tabChange)="onTabChange($any($event))"
ariaLabel="Run list views"
/>
@if (viewMode() === 'approvals') {
<!-- Approvals tab content -->
<div class="approvals-gate-toggles">
@for (toggle of gateToggles; track toggle.id) {
<button type="button"
class="gate-toggle"
[class.gate-toggle--active]="gateToggleState()[toggle.id]"
(click)="toggleGate(toggle.id)">
{{ toggle.label }}
</button>
}
</div>
@if (approvalsLoading()) {
<div class="approvals-skeleton">
@for (i of [1,2,3]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
<div class="skeleton-cell skeleton-cell--xs"></div>
</div>
}
</div>
} @else {
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered approvals-table-enter">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Gate</th>
<th>Risk</th>
<th>Status</th>
<th>Requester</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
@for (apr of filteredApprovals(); track apr.id) {
<tr [class.pending-banner__row--expiring]="apr.status === 'pending' && isExpiringSoon(apr.expiresAt)">
<td>{{ apr.releaseName }} {{ apr.releaseVersion }}</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span></td>
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
<td>{{ apr.requestedBy }}</td>
<td [class.text-warning]="apr.status === 'pending' && isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</td>
<td><a class="run-link" [routerLink]="['/releases/approvals', apr.id]">View</a></td>
</tr>
} @empty {
<tr><td colspan="8">No approvals match the active gate filters.</td></tr>
}
</tbody>
</table>
}
} @else {
<!-- Existing deployment views: filters + timeline/table/correlations -->
<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="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>
}
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@switch (viewMode()) {
@case ('timeline') {
<!-- Canonical timeline rendering -->
<div class="timeline-container">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No runs match the active filters."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) {
<span class="run-chip">{{ event.metadata['lane'] }}</span>
}
@if (event.metadata['environment']) {
<span class="run-chip">{{ event.metadata['environment'] }}</span>
}
@if (event.metadata['outcome']) {
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
}
@if (event.evidenceLink) {
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
}
</div>
}
</ng-template>
</app-timeline-list>
</div>
}
@case ('correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<div class="banner">No run correlations match the current filters.</div>
}
</div>
}
@default {
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of pagedRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
}
</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>
}
}
}
}
@@ -192,8 +297,28 @@ function deriveOutcomeIcon(status: string): string {
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.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)}
/* Approvals skeleton loading */
.approvals-skeleton{display:flex;flex-direction:column;gap:.5rem;padding:.75rem 0}
.skeleton-row{display:flex;gap:.75rem;align-items:center}
.skeleton-cell{height:12px;border-radius:var(--radius-sm);background:var(--color-surface-tertiary);animation:skeleton-pulse 1.2s ease-in-out infinite}
.skeleton-cell--wide{flex:2}
.skeleton-cell--sm{flex:0.7}
.skeleton-cell--xs{flex:0.4}
.skeleton-cell:not(.skeleton-cell--wide):not(.skeleton-cell--sm):not(.skeleton-cell--xs){flex:1}
@keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:.4}}
.skeleton-row:nth-child(2) .skeleton-cell{animation-delay:0.15s}
.skeleton-row:nth-child(3) .skeleton-cell{animation-delay:0.3s}
/* Approvals table fade-in */
.approvals-table-enter{animation:approvals-fadein 250ms ease-out both}
@keyframes approvals-fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
/* Pending banner fade-in */
.pending-banner{animation:approvals-fadein 250ms ease-out both}
/* Inline filter chips row */
.activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.activity-filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: nowrap; overflow-x: auto; }
@media (max-width: 768px) { .activity-filters { 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 {
@@ -221,6 +346,42 @@ function deriveOutcomeIcon(status: string): string {
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
.run-link:hover{text-decoration:underline}
/* Pending approvals banner */
.pending-banner{border:1px solid var(--color-status-warning-border, var(--color-border-primary));border-radius:var(--radius-md);background:var(--color-status-warning-bg, var(--color-surface-primary));padding:0;overflow:hidden}
.pending-banner__toggle{display:flex;align-items:center;gap:.35rem;width:100%;padding:.5rem .65rem;border:none;background:transparent;color:var(--color-text-primary);font-size:.78rem;font-weight:600;cursor:pointer;text-align:left}
.pending-banner__toggle:hover{background:rgba(0,0,0,.03)}
.pending-banner__chevron{transition:transform .2s ease;flex-shrink:0}
.pending-banner--collapsed .pending-banner__chevron{transform:rotate(-90deg)}
.pending-banner__title{flex:1}
.pending-banner__table{width:100%;border-collapse:collapse;font-size:.74rem}
.pending-banner__table th{text-align:left;padding:.3rem .5rem;font-weight:600;color:var(--color-text-secondary);border-bottom:1px solid var(--color-border-primary)}
.pending-banner__table td{padding:.3rem .5rem;border-bottom:1px solid var(--color-border-primary)}
.pending-banner__row--expiring{background:color-mix(in srgb, var(--color-status-warning) 8%, transparent)}
/* Gate & urgency & status chips */
.gate-chip{display:inline-block;padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);text-transform:capitalize}
.gate-chip[data-gate="gated"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
.gate-chip[data-gate="policy"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
.gate-chip[data-gate="ops"]{background:var(--color-surface-secondary);color:var(--color-text-secondary)}
.gate-chip[data-gate="security"]{background:var(--color-status-warning-bg);color:var(--color-status-warning-text)}
.urgency-chip{display:inline-block;padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);text-transform:capitalize}
.urgency-chip[data-urgency="critical"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
.urgency-chip[data-urgency="high"]{background:var(--color-status-warning-bg);color:var(--color-status-warning-text)}
.urgency-chip[data-urgency="normal"]{background:var(--color-surface-secondary);color:var(--color-text-secondary)}
.urgency-chip[data-urgency="low"]{background:var(--color-surface-secondary);color:var(--color-text-muted)}
.status-chip{display:inline-block;padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);text-transform:capitalize}
.status-chip[data-status="pending"]{background:var(--color-status-warning-bg);color:var(--color-status-warning-text)}
.status-chip[data-status="approved"]{background:var(--color-status-success-bg);color:var(--color-status-success-text)}
.status-chip[data-status="rejected"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
.status-chip[data-status="expired"]{background:var(--color-surface-secondary);color:var(--color-text-muted)}
.text-warning{color:var(--color-status-warning-text)}
/* Gate type toggle bar */
.approvals-gate-toggles{display:flex;gap:.35rem;flex-wrap:wrap;margin-bottom:.75rem}
.gate-toggle{padding:.25rem .6rem;font-size:.72rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-full);background:transparent;color:var(--color-text-secondary);cursor:pointer;transition:all .15s ease}
.gate-toggle:hover{border-color:var(--color-brand-primary);color:var(--color-text-primary)}
.gate-toggle--active{background:var(--color-brand-primary);border-color:var(--color-brand-primary);color:#fff}
@media (max-width: 768px) {
.activity-filters { gap: 0.375rem; }
.filter-search { flex: 1 1 100%; }
@@ -228,19 +389,61 @@ function deriveOutcomeIcon(status: string): string {
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleasesActivityComponent {
export class ReleasesActivityComponent implements OnInit, OnDestroy {
private readonly dateFmt = inject(DateFormatService);
private readonly pageAction = inject(PageActionService);
private readonly http = inject(HttpClient);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly context = inject(PlatformContextStore);
private readonly approvalApi = inject(APPROVAL_API);
ngOnInit(): void {
this.pageAction.set({ label: 'Create Deployment', route: '/releases/deployments/new' });
}
ngOnDestroy(): void {
this.pageAction.clear();
}
readonly viewModeTabs = VIEW_MODE_TABS;
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rows = signal<ReleaseActivityProjection[]>([]);
readonly viewMode = signal<'timeline' | 'table' | 'correlations'>('timeline');
readonly viewMode = signal<'timeline' | 'table' | 'correlations' | 'approvals'>('timeline');
// ── Pending approvals banner ──────────────────────────────────────
readonly pendingApprovals = signal<ApprovalRequest[]>([]);
readonly pendingBannerCollapsed = signal(false);
// ── Approvals tab state ───────────────────────────────────────────
readonly allApprovals = signal<ApprovalRequest[]>([]);
readonly approvalsLoading = signal(false);
private _approvalsFetched = false;
readonly gateToggles = [
{ id: 'gated', label: 'Gated' },
{ id: 'policy', label: 'Policy' },
{ id: 'ops', label: 'Ops' },
{ id: 'security', label: 'Security' },
] as const;
readonly gateToggleState = signal<Record<string, boolean>>({
gated: true,
policy: false,
ops: false,
security: false,
});
readonly filteredApprovals = computed(() => {
const toggles = this.gateToggleState();
const activeGates = Object.entries(toggles).filter(([, v]) => v).map(([k]) => k);
if (activeGates.length === 0) return this.allApprovals();
return this.allApprovals().filter((apr) => activeGates.includes(this.deriveGateType(apr)));
});
// Lane filter reads from global context (header toggle)
// ── Filter-chip options ──────────────────────────────────────────────
@@ -252,11 +455,6 @@ export class ReleasesActivityComponent {
{ 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' },
@@ -271,7 +469,7 @@ export class ReleasesActivityComponent {
];
readonly statusFilter = signal('');
readonly laneFilter = signal('');
readonly laneFilter = computed(() => this.context.releaseLane());
readonly envFilter = signal('');
readonly outcomeFilter = signal('');
readonly searchQuery = signal('');
@@ -296,7 +494,7 @@ export class ReleasesActivityComponent {
if (statusF !== '') {
rows = rows.filter((item) => item.status.toLowerCase() === statusF);
}
if (laneF !== '') {
if (laneF) {
rows = rows.filter((item) => this.deriveLane(item) === laneF);
}
if (envF !== '') {
@@ -365,24 +563,28 @@ export class ReleasesActivityComponent {
this.route.data.subscribe((data) => {
const lane = (data['defaultLane'] as string | undefined) ?? null;
if (lane === 'hotfix') {
this.laneFilter.set('hotfix');
this.context.setReleaseLane('hotfix');
}
});
this.route.queryParamMap.subscribe((params) => {
const view = (params.get('view') ?? 'timeline').toLowerCase();
if (view === 'timeline' || view === 'table' || view === 'correlations') {
if (view === 'timeline' || view === 'table' || view === 'correlations' || view === 'approvals') {
this.viewMode.set(view);
if (view === 'approvals') this.loadApprovals();
} else {
this.viewMode.set('timeline');
}
if (params.get('status')) this.statusFilter.set(params.get('status')!);
if (params.get('lane')) this.laneFilter.set(params.get('lane')!);
if (params.get('lane')) this.context.setReleaseLane(params.get('lane') as 'standard' | 'hotfix');
if (params.get('env')) this.envFilter.set(params.get('env')!);
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!);
});
// Load pending approvals for the banner
this.loadPendingApprovals();
effect(() => {
this.context.contextVersion();
this.load();
@@ -393,7 +595,7 @@ export class ReleasesActivityComponent {
return {
view: next['view'] ?? this.viewMode(),
status: this.statusFilter() !== '' ? this.statusFilter() : null,
lane: this.laneFilter() !== '' ? this.laneFilter() : null,
lane: this.laneFilter() || null,
env: this.envFilter() !== '' ? this.envFilter() : null,
outcome: this.outcomeFilter() !== '' ? this.outcomeFilter() : null,
};
@@ -443,6 +645,62 @@ export class ReleasesActivityComponent {
return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
onTabChange(tab: string): void {
this.viewMode.set(tab as 'timeline' | 'table' | 'correlations' | 'approvals');
if (tab === 'approvals') this.loadApprovals();
this.applyFilters();
}
toggleGate(id: string): void {
const current = this.gateToggleState();
this.gateToggleState.set({ ...current, [id]: !current[id] });
}
deriveGateType(approval: ApprovalRequest): 'gated' | 'policy' | 'ops' | 'security' {
if (!approval.gatesPassed) return 'gated';
const name = approval.releaseName.toLowerCase();
if (name.includes('policy')) return 'policy';
if (approval.urgency === 'critical' || approval.urgency === 'high') return 'security';
return 'ops';
}
timeRemaining(expiresAt: string): string {
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'expired';
const hours = Math.floor(ms / 3600000);
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
const minutes = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${minutes}m`;
}
isExpiringSoon(expiresAt: string): boolean {
const ms = new Date(expiresAt).getTime() - Date.now();
return ms > 0 && ms <= 4 * 60 * 60 * 1000;
}
private loadPendingApprovals(): void {
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(take(1)).subscribe({
next: (approvals) => this.pendingApprovals.set(approvals),
error: () => this.pendingApprovals.set([]),
});
}
private loadApprovals(): void {
if (this._approvalsFetched) return;
this._approvalsFetched = true;
this.approvalsLoading.set(true);
this.approvalApi.listApprovals().pipe(take(1)).subscribe({
next: (approvals) => {
this.allApprovals.set(approvals);
this.approvalsLoading.set(false);
},
error: () => {
this.allApprovals.set([]);
this.approvalsLoading.set(false);
},
});
}
private load(): void {
this.loading.set(true);
this.error.set(null);

View File

@@ -72,7 +72,6 @@ export interface PipelineRelease {
<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>
@@ -133,7 +132,7 @@ export interface PipelineRelease {
<!-- Release -->
<td>
<div class="rup__release-cell">
<a class="rup__release-name" [routerLink]="['/releases/versions', r.id]">{{ r.name }}</a>
<a class="rup__release-name" [routerLink]="['/releases/detail', r.id, 'overview']">{{ r.name }}</a>
<span class="rup__release-version">{{ r.version }}</span>
<span class="rup__lane-badge" [class.rup__lane-badge--hotfix]="r.lane === 'hotfix'">
{{ r.lane === 'hotfix' ? 'Hotfix' : 'Standard' }}
@@ -196,13 +195,13 @@ export interface PipelineRelease {
</a>
}
@if (r.gateStatus === 'block') {
<a class="decision-capsule decision-capsule--review" [routerLink]="['/releases/versions', r.id]" [queryParams]="{ tab: 'gates' }">
<a class="decision-capsule decision-capsule--review" [routerLink]="['/releases/detail', r.id, 'gates']">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Review Gates
</a>
}
@if (r.evidencePosture === 'partial' || r.evidencePosture === 'missing') {
<a class="decision-capsule decision-capsule--evidence" [routerLink]="['/releases/versions', r.id]" [queryParams]="{ tab: 'evidence' }">
<a class="decision-capsule decision-capsule--evidence" [routerLink]="['/releases/detail', r.id, 'evidence']">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
View Evidence
</a>
@@ -247,7 +246,8 @@ export interface 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__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
.rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: nowrap; overflow-x: auto; }
@media (max-width: 768px) { .rup__filters { 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 {
@@ -289,7 +289,7 @@ export interface PipelineRelease {
.rup__badge--success { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
.rup__badge--warning { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
.rup__badge--error { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
.rup__badge-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: currentColor; color: #fff; font-size: 0.5625rem; font-weight: 700; }
.rup__badge-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: rgba(0,0,0,0.15); font-size: 0.5625rem; font-weight: 700; }
.rup__risk-badge[data-tier="critical"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
.rup__risk-badge[data-tier="high"] { background: #FFF3E0; color: #E65100; }
@@ -314,21 +314,18 @@ export interface PipelineRelease {
.decision-capsule {
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem;
border-radius: var(--radius-full, 9999px); font-size: 0.6875rem;
font-weight: var(--font-weight-semibold, 600); cursor: pointer;
border: 1px solid transparent; transition: all 150ms ease;
text-decoration: none; white-space: nowrap; line-height: 1.3; background: transparent;
border-radius: var(--radius-sm); font-size: 0.65rem;
font-weight: 500; cursor: pointer;
border: 1px solid var(--color-border-primary); transition: all 150ms ease;
text-decoration: none; white-space: nowrap; line-height: 1.3;
background: transparent; color: var(--color-text-secondary);
}
.decision-capsule--deploy { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); border-color: var(--color-status-success, #2E7D32); }
.decision-capsule--deploy:hover { background: var(--color-status-success, #2E7D32); color: #fff; }
.decision-capsule--approve { background: #E3F2FD; color: #1565C0; border-color: #1565C0; }
.decision-capsule--approve:hover { background: #1565C0; color: #fff; }
.decision-capsule--review { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); border-color: var(--color-status-warning, #C89820); }
.decision-capsule--review:hover { background: var(--color-status-warning, #C89820); color: #fff; }
.decision-capsule--evidence { background: #F3E5F5; color: #7B1FA2; border-color: #7B1FA2; }
.decision-capsule--evidence:hover { background: #7B1FA2; color: #fff; }
.decision-capsule--promote { background: #E0F2F1; color: #00695C; border-color: #00695C; }
.decision-capsule--promote:hover { background: #00695C; color: #fff; }
.decision-capsule:hover { background: var(--color-surface-tertiary); border-color: var(--color-brand-primary); color: var(--color-text-primary); }
.decision-capsule--deploy { color: var(--color-status-success-text); }
.decision-capsule--approve { color: var(--color-status-info-text); }
.decision-capsule--review { color: var(--color-status-warning-text); }
.decision-capsule--evidence { color: var(--color-text-secondary); }
.decision-capsule--promote { color: var(--color-status-success-text); }
.decision-capsule--progress { cursor: default; background: var(--color-surface-secondary); border-color: var(--color-border-primary); gap: 0.375rem; }
.decision-capsule__progress-track { width: 48px; height: 6px; border-radius: 3px; background: var(--color-border-primary); overflow: hidden; }
.decision-capsule__progress-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-brand-primary, #4F46E5); transition: width 300ms ease; }
@@ -337,10 +334,10 @@ export interface PipelineRelease {
/* Sortable column headers */
.rup__th--sortable { cursor: pointer; user-select: none; transition: color 150ms ease; }
.rup__th--sortable:hover { color: var(--color-text-primary); }
.rup__th--sorted { color: var(--color-text-link); }
.rup__th--sorted { color: var(--color-text-heading); border-bottom-color: var(--color-text-heading); }
.rup__th-content { display: flex; align-items: center; gap: 0.375rem; }
.rup__sort-icon { display: flex; align-items: center; opacity: 0.5; transition: opacity 150ms ease; }
.rup__sort-icon--active { opacity: 1; color: var(--color-text-link); }
.rup__sort-icon--active { opacity: 1; color: var(--color-text-heading); }
.rup__sort-icon--inactive { opacity: 0.3; }
/* Right-aligned pagination */
@@ -375,11 +372,7 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
// ── Filter-chip options ──────────────────────────────────────────────
readonly laneOptions: FilterChipOption[] = [
{ id: '', label: 'All Lanes' },
{ id: 'standard', label: 'Standard' },
{ id: 'hotfix', label: 'Hotfix' },
];
// Lane filter reads from global context (header toggle)
readonly statusOptions: FilterChipOption[] = [
{ id: '', label: 'All Status' },
{ id: 'draft', label: 'Draft' },
@@ -431,7 +424,7 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
}));
});
readonly searchQuery = signal('');
readonly laneFilter = signal('');
readonly laneFilter = computed(() => this.context.releaseLane());
readonly statusFilter = signal('');
readonly gateFilter = signal('');
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
@@ -456,7 +449,7 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
r.digest.toLowerCase().includes(q),
);
}
if (lane !== '') {
if (lane) {
list = list.filter((r) => r.lane === lane);
}
if (status !== '') {

View File

@@ -678,6 +678,21 @@ export class AppSidebarComponent implements AfterViewInit {
*/
readonly navSections: NavSection[] = [
// ── Group 1: Release Control ─────────────────────────────────────
{
id: 'deployments',
label: 'Deployments',
icon: 'clock',
route: '/releases/deployments',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
badge$: () => {
const combined = this.failedRunsCount() + this.pendingApprovalsCount();
return combined > 0 ? combined : null;
},
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
],
},
{
id: 'releases',
label: 'Releases',
@@ -704,33 +719,7 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.RELEASE_WRITE,
],
},
{
id: 'approvals',
label: 'Approvals',
icon: 'check-circle',
route: '/releases/approvals',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
badge$: () => this.pendingApprovalsCount(),
requireAnyScope: [
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.EXCEPTION_APPROVE,
],
},
{
id: 'activity',
label: 'Activity',
icon: 'clock',
route: '/releases/runs',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
badge$: () => this.failedRunsCount(),
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
],
},
// Approvals nav item removed — merged into Deployments page as a tab
// ── Group 2: Security ────────────────────────────────────────────
{
id: 'vulnerabilities',
@@ -1247,7 +1236,7 @@ export class AppSidebarComponent implements AfterViewInit {
private shouldForcePendingApprovalsRefresh(url: string): boolean {
const path = (url || '').split('?')[0] ?? '';
return path.startsWith('/releases/approvals') || path.startsWith('/releases/promotions');
return path.startsWith('/releases/approvals') || path.startsWith('/releases/promotions') || path.startsWith('/releases/deployments');
}
/**

View File

@@ -1,4 +1,7 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, ElementRef, HostListener } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
@@ -66,6 +69,22 @@ import { StellaFilterMultiComponent, FilterMultiOption } from '../../shared/comp
[options]="stageChipOptions"
(valueChange)="selectStage($event)"
/>
<!-- Release lane toggle (only on release routes) -->
@if (showLaneToggle()) {
<div class="ctx__segmented" role="radiogroup" aria-label="Lane">
@for (opt of laneOptions; track opt.value) {
<button
type="button"
role="radio"
class="ctx__seg-btn"
[class.ctx__seg-btn--active]="context.releaseLane() === opt.value"
[attr.aria-checked]="context.releaseLane() === opt.value"
(click)="selectLane(opt.value)"
>{{ opt.label }}</button>
}
</div>
}
</div>
@if (context.error()) {
@@ -200,6 +219,26 @@ import { StellaFilterMultiComponent, FilterMultiOption } from '../../shared/comp
})
export class ContextChipsComponent {
readonly context = inject(PlatformContextStore);
private readonly router = inject(Router);
private readonly currentUrl = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map((e) => e.urlAfterRedirects),
startWith(this.router.url),
),
{ initialValue: this.router.url },
);
readonly showLaneToggle = computed(() => {
const url = (this.currentUrl() ?? '').split('?')[0];
return url.startsWith('/releases');
});
readonly laneOptions = [
{ value: 'standard' as const, label: 'Releases' },
{ value: 'hotfix' as const, label: 'Hotfix' },
];
readonly windowOptions = [
{ value: '1h', label: '1h' },
@@ -274,4 +313,8 @@ export class ContextChipsComponent {
selectStage(value: string): void {
this.context.setStage(value);
}
selectLane(value: 'standard' | 'hotfix'): void {
this.context.setReleaseLane(value);
}
}

View File

@@ -58,6 +58,24 @@ export const RELEASES_ROUTES: Routes = [
loadComponent: () =>
import('../features/releases/release-ops-overview-page.component').then((m) => m.ReleaseOpsOverviewPageComponent),
},
{
path: 'detail/:releaseId',
title: 'Release Detail',
data: { breadcrumb: 'Release Detail' },
loadComponent: () =>
import('../features/releases/release-detail-page.component').then(
(m) => m.ReleaseDetailPageComponent,
),
},
{
path: 'detail/:releaseId/:tab',
title: 'Release Detail',
data: { breadcrumb: 'Release Detail' },
loadComponent: () =>
import('../features/releases/release-detail-page.component').then(
(m) => m.ReleaseDetailPageComponent,
),
},
{
path: 'versions',
title: 'Release Versions',
@@ -113,12 +131,28 @@ export const RELEASES_ROUTES: Routes = [
),
},
{
path: 'runs',
title: 'Release Runs',
data: { breadcrumb: 'Release Runs', semanticObject: 'run' },
path: 'deployments',
title: 'Deployments',
data: { breadcrumb: 'Deployments', semanticObject: 'run' },
loadComponent: () =>
import('../features/releases/releases-activity.component').then((m) => m.ReleasesActivityComponent),
},
{
path: 'deployments/new',
title: 'Create Deployment',
data: { breadcrumb: 'Create Deployment' },
loadComponent: () =>
import('../features/release-orchestrator/releases/create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent,
),
},
{
path: 'runs',
title: 'Deployments',
data: { breadcrumb: 'Deployments', semanticObject: 'run' },
pathMatch: 'full',
redirectTo: '/releases/deployments',
},
{
path: 'runs/:runId',
title: 'Release Run Detail',

View File

@@ -0,0 +1,124 @@
/**
* Context variable definitions + Monaco completion provider for deployment scripts.
* Variables are injected as environment variables by the Stella agent at runtime.
*/
import type { ScriptLanguage } from './script-templates';
export interface ContextVariable {
name: string;
envName: string;
description: string;
}
export interface LibraryFunction {
name: string;
description: string;
signature: string;
languages: ScriptLanguage[];
}
export const CONTEXT_VARIABLES: ContextVariable[] = [
{ name: 'RELEASE_ID', envName: 'STELLA_RELEASE_ID', description: 'Release identifier (Guid)' },
{ name: 'RELEASE_NAME', envName: 'STELLA_RELEASE_NAME', description: 'Release name' },
{ name: 'RELEASE_VERSION', envName: 'STELLA_RELEASE_VERSION', description: 'Version string' },
{ name: 'RELEASE_TYPE', envName: 'STELLA_RELEASE_TYPE', description: 'standard | hotfix' },
{ name: 'COMPONENT_NAME', envName: 'STELLA_COMPONENT_NAME', description: 'Current component name' },
{ name: 'TARGET_ENVIRONMENT', envName: 'STELLA_TARGET_ENVIRONMENT', description: 'Target environment ID' },
{ name: 'TARGET_REGION', envName: 'STELLA_TARGET_REGION', description: 'Target region ID' },
{ name: 'DEPLOYMENT_STRATEGY', envName: 'STELLA_DEPLOYMENT_STRATEGY', description: 'rolling | canary | blue_green | recreate | ab-release' },
{ name: 'TENANT_ID', envName: 'STELLA_TENANT_ID', description: 'Current tenant' },
{ name: 'ACTOR_ID', envName: 'STELLA_ACTOR_ID', description: 'Who initiated the deployment' },
{ name: 'TIMESTAMP', envName: 'STELLA_TIMESTAMP', description: 'ISO 8601 timestamp' },
{ name: 'TIMEOUT_SECONDS', envName: 'STELLA_TIMEOUT_SECONDS', description: 'Script timeout in seconds' },
{ name: 'WORKING_DIR', envName: 'STELLA_WORKING_DIR', description: 'Working directory path' },
{ name: 'AGENT_TYPE', envName: 'STELLA_AGENT_TYPE', description: 'compose | ssh | winrm | docker | ecs | nomad' },
{ name: 'COMPOSE_LOCK', envName: 'STELLA_COMPOSE_LOCK', description: 'Compose lock file path (compose agent only)' },
{ name: 'VERSION_STICKER', envName: 'STELLA_VERSION_STICKER', description: 'Version sticker for rollback reference' },
];
export const LIBRARY_FUNCTIONS: LibraryFunction[] = [
// Bash functions
{ name: 'stella_log', description: 'Log a deployment message', signature: 'stella_log "message"', languages: ['shell'] },
{ name: 'stella_status', description: 'Report step status', signature: 'stella_status "running|success|failed"', languages: ['shell'] },
{ name: 'stella_health_http', description: 'HTTP health check', signature: 'stella_health_http "url" [expected_code]', languages: ['shell'] },
{ name: 'stella_health_tcp', description: 'TCP port check', signature: 'stella_health_tcp "host" "port"', languages: ['shell'] },
{ name: 'stella_evidence', description: 'Record evidence key-value', signature: 'stella_evidence "key" "value"', languages: ['shell'] },
// C# functions
{ name: 'StellaOps.Deploy.Log', description: 'Structured log line', signature: 'StellaOps.Deploy.Log(message)', languages: ['csharp'] },
{ name: 'StellaOps.Deploy.SetStatus', description: 'Report step status', signature: 'StellaOps.Deploy.SetStatus(status)', languages: ['csharp'] },
{ name: 'StellaOps.Deploy.GetVariable', description: 'Read context variable', signature: 'StellaOps.Deploy.GetVariable(name)', languages: ['csharp'] },
{ name: 'StellaOps.Health.CheckHttp', description: 'HTTP GET health check', signature: 'StellaOps.Health.CheckHttp(url, expectedStatus)', languages: ['csharp'] },
{ name: 'StellaOps.Health.CheckTcp', description: 'TCP port check', signature: 'StellaOps.Health.CheckTcp(host, port)', languages: ['csharp'] },
{ name: 'StellaOps.Evidence.Record', description: 'Record evidence key-value', signature: 'StellaOps.Evidence.Record(key, value)', languages: ['csharp'] },
{ name: 'StellaOps.Shell.Exec', description: 'Run shell command', signature: 'StellaOps.Shell.Exec(command)', languages: ['csharp'] },
// PowerShell functions
{ name: 'Write-StellaLog', description: 'Structured log', signature: 'Write-StellaLog "message"', languages: ['powershell'] },
{ name: 'Set-StellaStatus', description: 'Report step status', signature: 'Set-StellaStatus "running|success|failed"', languages: ['powershell'] },
{ name: 'Test-StellaHealthHttp', description: 'HTTP health check', signature: 'Test-StellaHealthHttp "url" [-ExpectedStatus 200]', languages: ['powershell'] },
{ name: 'Test-StellaHealthTcp', description: 'TCP port check', signature: 'Test-StellaHealthTcp "host" "port"', languages: ['powershell'] },
{ name: 'Add-StellaEvidence', description: 'Record evidence', signature: 'Add-StellaEvidence "key" "value"', languages: ['powershell'] },
];
/**
* Register Monaco completion providers for Stella context variables and library functions.
* Call once per language after Monaco is loaded. Safe to call multiple times (idempotent via disposable tracking).
*/
export function registerScriptCompletions(
monaco: typeof import('monaco-editor'),
language: ScriptLanguage,
): void {
const funcs = LIBRARY_FUNCTIONS.filter(f => f.languages.includes(language));
monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: language === 'csharp' ? ['$', '.'] : ['$'],
provideCompletionItems(model, position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
// Check character before current word for $ prefix
const lineContent = model.getLineContent(position.lineNumber);
const charBefore = lineContent[word.startColumn - 2] ?? '';
const suggestions: any[] = [];
// Context variables — triggered by $
if (charBefore === '$' || word.word.startsWith('$') || word.word.startsWith('STELLA')) {
for (const v of CONTEXT_VARIABLES) {
const insertText = language === 'csharp'
? `env:STELLA_${v.name}`
: language === 'powershell'
? `env:STELLA_${v.name}`
: `{STELLA_${v.name}}`;
suggestions.push({
label: `$STELLA_${v.name}`,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: language === 'shell' ? `\${STELLA_${v.name}}` : `$env:STELLA_${v.name}`,
detail: v.description,
range,
});
}
}
// Library functions
for (const fn of funcs) {
suggestions.push({
label: fn.name,
kind: monaco.languages.CompletionItemKind.Function,
insertText: fn.name,
detail: fn.description,
documentation: fn.signature,
range,
});
}
return { suggestions };
},
});
}

View File

@@ -0,0 +1,271 @@
import {
Component,
ElementRef,
ViewChild,
AfterViewInit,
OnDestroy,
input,
output,
signal,
effect,
inject,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MonacoLoaderService } from '../../../features/policy-studio/editor/monaco-loader.service';
import {
SCRIPT_LANGUAGES,
SCRIPT_TEMPLATES,
type ScriptLanguage,
type ScriptLanguageOption,
} from './script-templates';
import {
CONTEXT_VARIABLES,
LIBRARY_FUNCTIONS,
registerScriptCompletions,
} from './script-context';
type MonacoNamespace = typeof import('monaco-editor');
@Component({
selector: 'app-script-editor',
standalone: true,
imports: [FormsModule],
template: `
<div class="script-editor">
<!-- Info banner -->
<div class="script-editor__info">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span>Scripts execute on the deployment agent (Linux: bash, Windows: PowerShell, Docker: container exec).
Context variables are injected as environment variables by the agent at runtime.</span>
</div>
<!-- Language selector -->
<div class="script-editor__toolbar">
<label class="field">
<span class="field__label">Language</span>
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)">
@for (lang of languages; track lang.id) {
<option [value]="lang.id">{{ lang.label }} ({{ lang.extension }})</option>
}
</select>
</label>
</div>
<!-- Monaco host -->
<div #editorHost class="script-editor__host"></div>
<!-- Context panel -->
<div class="script-editor__context">
<details>
<summary>Context Variables</summary>
<div class="context-list">
@for (v of contextVars; track v.envName) {
<button type="button" class="context-item" (click)="insertAtCursor(varSnippet(v.envName))" [title]="'Insert ' + v.envName">
<code>{{ '$' + v.envName }}</code>
<span>{{ v.description }}</span>
</button>
}
</div>
</details>
<details>
<summary>Library Functions ({{ currentLanguageLabel() }})</summary>
<div class="context-list">
@for (fn of currentFunctions(); track fn.name) {
<button type="button" class="context-item" (click)="insertAtCursor(fn.name)" [title]="fn.signature">
<code>{{ fn.name }}</code>
<span>{{ fn.description }}</span>
</button>
}
</div>
</details>
</div>
</div>
`,
styles: [`
.script-editor { display: grid; gap: 0.5rem; }
.script-editor__info {
display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-secondary); font-size: var(--font-size-xs, 0.6875rem);
color: var(--color-text-secondary); line-height: 1.5;
}
.script-editor__info svg { flex-shrink: 0; margin-top: 0.1rem; }
.script-editor__toolbar { display: flex; align-items: flex-end; gap: 0.75rem; }
.script-editor__toolbar .field { display: grid; gap: 0.2rem; min-width: 160px; }
.script-editor__toolbar .field__label {
font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.script-editor__toolbar select {
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.35rem 0.5rem; font-size: var(--font-size-sm, 0.75rem); font-family: inherit;
}
.script-editor__toolbar select:focus {
outline: none; border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.script-editor__host {
height: 400px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
overflow: hidden;
}
.script-editor__context { display: grid; gap: 0.3rem; }
.script-editor__context details {
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
overflow: hidden;
}
.script-editor__context summary {
padding: 0.4rem 0.6rem; font-size: var(--font-size-sm, 0.75rem);
font-weight: var(--font-weight-semibold); color: var(--color-text-secondary);
cursor: pointer; user-select: none;
background: var(--color-surface-secondary);
}
.script-editor__context summary:hover { color: var(--color-text-primary); }
.context-list { display: grid; gap: 0.15rem; padding: 0.35rem; }
.context-item {
display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; align-items: center;
padding: 0.25rem 0.4rem; border: none; border-radius: var(--radius-sm);
background: transparent; text-align: left; cursor: pointer;
font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-primary);
transition: background var(--motion-duration-sm, 140ms) ease;
}
.context-item:hover { background: var(--color-surface-elevated); }
.context-item code {
font-family: var(--font-family-mono, ui-monospace, monospace);
font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-link);
white-space: nowrap;
}
.context-item span { color: var(--color-text-secondary); }
`],
})
export class ScriptEditorComponent implements AfterViewInit, OnDestroy {
@ViewChild('editorHost', { static: true }) editorHost!: ElementRef<HTMLDivElement>;
/** Initial content — if not set, uses the language template */
readonly initialContent = input<string>('');
/** Fires on every editor content change */
readonly contentChange = output<string>();
readonly language = signal<ScriptLanguage>('shell');
readonly languages: ScriptLanguageOption[] = SCRIPT_LANGUAGES;
readonly contextVars = CONTEXT_VARIABLES;
private readonly monacoLoader = inject(MonacoLoaderService);
private monaco: MonacoNamespace | null = null;
private editor: import('monaco-editor').editor.IStandaloneCodeEditor | null = null;
private model: import('monaco-editor').editor.ITextModel | null = null;
private registeredLanguages = new Set<ScriptLanguage>();
private contentDisposable: { dispose(): void } | null = null;
currentLanguageLabel(): string {
const lang = this.languages.find(l => l.id === this.language());
return lang?.label ?? 'Bash';
}
currentFunctions() {
return LIBRARY_FUNCTIONS.filter(f => f.languages.includes(this.language()));
}
varSnippet(envName: string): string {
const lang = this.language();
if (lang === 'shell') return `\${${envName}}`;
if (lang === 'powershell') return `$env:${envName}`;
return `Environment.GetEnvironmentVariable("${envName}")`;
}
async ngAfterViewInit(): Promise<void> {
this.monaco = await this.monacoLoader.load();
// Import language contributions for syntax highlighting
await Promise.all([
import('monaco-editor/esm/vs/basic-languages/csharp/csharp.contribution'),
import('monaco-editor/esm/vs/basic-languages/shell/shell.contribution'),
import('monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution'),
]).catch(() => {
// Language contributions may fail in test environments — fall back to plain text
});
const initialLang = this.language();
const content = this.initialContent() || SCRIPT_TEMPLATES[initialLang];
this.model = this.monaco.editor.createModel(content, initialLang);
this.editor = this.monaco.editor.create(this.editorHost.nativeElement, {
model: this.model,
theme: 'vs-dark',
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
padding: { top: 8 },
});
// Register completions for initial language
this.ensureCompletions(initialLang);
// Emit content changes
this.contentDisposable = this.editor.onDidChangeModelContent(() => {
this.contentChange.emit(this.model?.getValue() ?? '');
});
}
onLanguageChange(lang: ScriptLanguage): void {
this.language.set(lang);
if (!this.monaco || !this.model || !this.editor) return;
// If content is a template (unchanged from default), swap to new template
const currentContent = this.model.getValue();
const oldLang = [...this.registeredLanguages].find(
l => SCRIPT_TEMPLATES[l] === currentContent
);
const newContent = oldLang ? SCRIPT_TEMPLATES[lang] : currentContent;
this.monaco.editor.setModelLanguage(this.model, lang);
if (oldLang) {
this.model.setValue(newContent);
}
this.ensureCompletions(lang);
}
insertAtCursor(text: string): void {
if (!this.editor) return;
const selection = this.editor.getSelection();
if (!selection) return;
this.editor.executeEdits('stella-context', [{
range: selection,
text,
forceMoveMarkers: true,
}]);
this.editor.focus();
}
getContent(): string {
return this.model?.getValue() ?? '';
}
getCurrentExtension(): string {
const lang = this.languages.find(l => l.id === this.language());
return lang?.extension ?? '.sh';
}
private ensureCompletions(lang: ScriptLanguage): void {
if (!this.monaco || this.registeredLanguages.has(lang)) return;
registerScriptCompletions(this.monaco, lang);
this.registeredLanguages.add(lang);
}
ngOnDestroy(): void {
this.contentDisposable?.dispose();
this.editor?.dispose();
this.model?.dispose();
}
}

View File

@@ -0,0 +1,109 @@
/**
* Starter code templates per language for deployment scripts.
* Each template shows realistic use with correct OS context and Stella agent SDK.
*/
export type ScriptLanguage = 'shell' | 'csharp' | 'powershell';
export interface ScriptLanguageOption {
id: ScriptLanguage;
label: string;
extension: string;
}
export const SCRIPT_LANGUAGES: ScriptLanguageOption[] = [
{ id: 'shell', label: 'Bash', extension: '.sh' },
{ id: 'csharp', label: 'C#', extension: '.csx' },
{ id: 'powershell', label: 'PowerShell', extension: '.ps1' },
];
export const SCRIPT_TEMPLATES: Record<ScriptLanguage, string> = {
shell: `#!/bin/bash
set -euo pipefail
# Deployment health check script
# Runs on: Linux agent (bash), Docker exec, or SSH target
stella_log "Starting deployment verification..."
stella_status "running"
# Check target service health
TARGET_URL="http://\${STELLA_TARGET_ENVIRONMENT}:\${STELLA_HEALTH_PORT:-80}/health"
stella_health_http "\$TARGET_URL" 200
# Verify container is running
if command -v docker &>/dev/null; then
CONTAINER_ID=$(docker ps -q --filter "name=\${STELLA_COMPONENT_NAME}" | head -1)
if [ -n "\$CONTAINER_ID" ]; then
stella_log "Container \$CONTAINER_ID is running"
stella_evidence "container_id" "\$CONTAINER_ID"
else
stella_log "WARNING: Container not found for \${STELLA_COMPONENT_NAME}"
fi
fi
stella_status "success"
stella_log "Deployment verification complete"
`,
csharp: `#r "nuget: StellaOps.Agent.Sdk, 1.0.0"
using StellaOps;
// Deployment health check script (.csx)
// Runs on: Agent via dotnet-script / Roslyn scripting
// Runtime: .NET 10 (self-contained agent)
StellaOps.Deploy.Log("Starting deployment verification...");
StellaOps.Deploy.SetStatus("running");
var targetEnv = StellaOps.Deploy.GetVariable("STELLA_TARGET_ENVIRONMENT");
var componentName = StellaOps.Deploy.GetVariable("STELLA_COMPONENT_NAME");
// HTTP health check
var healthUrl = $"http://{targetEnv}/health";
var healthResult = StellaOps.Health.CheckHttp(healthUrl, 200);
StellaOps.Deploy.Log($"Health check: {healthUrl} => {(healthResult ? "OK" : "FAILED")}");
// TCP port check
StellaOps.Health.CheckTcp(targetEnv, 80);
// Record evidence
StellaOps.Evidence.Record("health_check", healthResult ? "passed" : "failed");
StellaOps.Evidence.Record("component", componentName);
StellaOps.Deploy.SetStatus("success");
StellaOps.Deploy.Log("Deployment verification complete");
`,
powershell: `#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
# Deployment health check script (.ps1)
# Runs on: Windows agent (WinRM) or cross-platform pwsh 7+
Write-StellaLog "Starting deployment verification..."
Set-StellaStatus "running"
$TargetEnv = $env:STELLA_TARGET_ENVIRONMENT
$ComponentName = $env:STELLA_COMPONENT_NAME
# HTTP health check
$HealthUrl = "http://$TargetEnv/health"
Test-StellaHealthHttp $HealthUrl -ExpectedStatus 200
# TCP port check
Test-StellaHealthTcp $TargetEnv 80
# Check Windows service if on Windows
if ($IsWindows -or $env:OS -eq 'Windows_NT') {
$svc = Get-Service -Name "Stella*" -ErrorAction SilentlyContinue
if ($svc) {
Write-StellaLog "Service '$($svc.Name)' status: $($svc.Status)"
Add-StellaEvidence "service_status" $svc.Status.ToString()
}
}
Set-StellaStatus "success"
Write-StellaLog "Deployment verification complete"
`,
};

View File

@@ -12,14 +12,15 @@
// </table>
//
// Modifiers:
// .stella-table--striped even-row background
// .stella-table--striped subtle even-row tint
// .stella-table--hoverable row hover highlight
// .stella-table--bordered outer border + rounded container
// =============================================================================
.stella-table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
// ---- Header cells ----
th {
@@ -28,12 +29,32 @@
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
text-align: left;
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
background: color-mix(in srgb, var(--color-surface-tertiary) 100%, var(--color-border-primary) 12%);
border-bottom: 2px solid var(--color-border-primary);
white-space: nowrap;
vertical-align: middle;
user-select: none;
transition: color 150ms ease;
}
// Sortable header interaction
th[aria-sort],
th.rup__th--sortable {
cursor: pointer;
&:hover {
color: var(--color-text-primary);
}
}
// Active sorted column header
th.rup__th--sorted,
th[aria-sort="ascending"],
th[aria-sort="descending"] {
color: var(--color-text-heading);
border-bottom-color: var(--color-text-heading);
}
// ---- Body cells ----
@@ -41,9 +62,10 @@
padding: 0.625rem 0.875rem;
font-size: 0.8125rem;
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border-primary);
border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 50%, transparent);
text-align: left;
vertical-align: middle;
transition: background 120ms ease;
}
// Remove bottom border on last row
@@ -51,6 +73,12 @@
border-bottom: none;
}
// ---- Row hover baseline (transparent bg by default) ----
tbody tr {
background: transparent;
transition: background 120ms ease;
}
// ---- Links inside table ----
td a {
color: var(--color-text-link);
@@ -64,7 +92,7 @@
// ---- Modifier: striped rows ----
&--striped tbody tr:nth-child(even) {
background: var(--color-surface-secondary);
background: var(--color-surface-tertiary);
}
// ---- Modifier: hoverable rows ----
@@ -81,7 +109,142 @@
// ---- Code cells ----
td code {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', monospace;
font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, 'JetBrains Mono', monospace);
font-size: 0.75rem;
}
}
// =============================================================================
// Inline badges / tag chips used inside table cells
// These provide consistent status/posture/risk styling across all tables.
// =============================================================================
.stella-table-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.125rem 0.4rem;
font-size: 0.625rem;
font-weight: 600;
line-height: 1.4;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
vertical-align: middle;
// Status variants
&--pass,
&--success,
&--verified,
&--deployed {
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
}
&--warn,
&--warning,
&--pending {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
&--block,
&--error,
&--failed,
&--critical {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
&--info,
&--ready,
&--draft {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
&--missing,
&--none,
&--neutral {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
// Risk severity (maps to severity tokens)
&--risk-critical {
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical-text);
}
&--risk-high {
background: var(--color-severity-high-bg);
color: var(--color-severity-high-text);
}
&--risk-medium {
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium-text);
}
&--risk-low {
background: var(--color-severity-low-bg);
color: var(--color-severity-low-text);
}
// Compact sub-count (e.g., the "1" in "WARN 1")
.badge-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
font-size: 0.5625rem;
font-weight: 700;
border-radius: var(--radius-full, 9999px);
background: rgba(0, 0, 0, 0.12);
color: inherit;
}
}
// =============================================================================
// Action links row (decision capsules / action buttons in last column)
// =============================================================================
.stella-table-actions {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.stella-table-action {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
font-size: 0.65rem;
font-weight: 500;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
text-decoration: none;
white-space: nowrap;
transition: all 150ms ease;
cursor: pointer;
&:hover {
background: var(--color-surface-tertiary);
border-color: var(--color-brand-primary);
color: var(--color-text-primary);
text-decoration: none;
}
svg, img {
width: 12px;
height: 12px;
flex-shrink: 0;
}
}