release control ui simplificatiosn
This commit is contained in:
@@ -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
|
||||
|
||||
1094
src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts
Normal file
1094
src/Web/StellaOps.Web/e2e/workflows/release-wizards.e2e.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface AddComponentRequest {
|
||||
version: string;
|
||||
type: ComponentType;
|
||||
configOverrides?: Record<string, string>;
|
||||
scriptContent?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseFilter {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user