feat(release-editor): visual pipeline editor with smart defaults and strategy visualization
Backend:
- Add GET /releases/latest-by-name endpoint for smart defaults (clone from previous release)
- Add GET /releases/suggest-version endpoint with semver auto-increment
- Add BumpVersion() logic: patch bump, prerelease increment, date-based build bump
- Add ReleaseDefaultsDto with components, strategy, targetEnvironment for pre-fill
Frontend — Pipeline Model (release-pipeline.models.ts):
- ReleasePipeline, PipelinePhase, DeployConfig discriminated union types
- 7 phase types: preflight, gate, approval, deploy, test, promote, seal
- 5 deployment strategies: rolling, canary, blue-green, recreate, A/B release
- 5 test types: smoke, health-check, integration, canary-metrics, manual
- FallbackConfig with behavior (rollback/pause/continue/abort) + autoRollback
- PHASE_CATALOG with icons and default configs for drag palette
- createDefaultPipeline() generates phase sequence based on release type + strategy
Frontend — Pipeline Editor (release-pipeline-editor.component.ts):
- Horizontal phase strip with START/END nodes and arrow connectors
- Color-coded phase nodes (deploy=blue, test=amber, gate=red, approval=purple, seal=green)
- Phase palette dropdown (add preflight/gate/approval/deploy/test/seal phases)
- Click-to-configure: deploy strategy selector, test type, approval count, gate toggles
- Strategy visualizers:
- Rolling: batch nodes with health check arrows
- Canary: staged traffic bars (5% → 25% → 50% → 100%) with duration labels
- Blue-Green: swim lanes with switch indicator
- A/B: variant bars with metrics + winner
- Fallback branch visualization (dashed red lines below deploy nodes)
- Auto-rollback toggle per phase
Frontend — Create Release Wizard Enhancement:
- Smart defaults: debounced name lookup (500ms) → pre-fill strategy, target, components
- Version suggestion badge ("Use 1.3.1") from previous release version
- Clone banner ("Based on Platform Release 1.2.3")
- Pipeline editor embedded in Contract step (collapsible "Deployment Pipeline" section)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,20 @@ public static class ReleaseEndpoints
|
||||
targets.WithName("Release_AvailableEnvironments");
|
||||
}
|
||||
|
||||
var latestByName = group.MapGet("/latest-by-name", GetLatestByName)
|
||||
.WithDescription("Return the most recent release with the given name, including structured metadata for smart defaults when creating a new version. Used by the release creation wizard to auto-fill from the previous version.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
latestByName.WithName("Release_LatestByName");
|
||||
}
|
||||
|
||||
var suggestVersion = group.MapGet("/suggest-version", SuggestNextVersion)
|
||||
.WithDescription("Suggest the next semantic version for a release name based on the latest existing version. Returns the suggested version string and the source release ID.");
|
||||
if (includeRouteNames)
|
||||
{
|
||||
suggestVersion.WithName("Release_SuggestVersion");
|
||||
}
|
||||
|
||||
var activity = group.MapGet("/activity", ListActivity)
|
||||
.WithDescription("Return a paginated feed of release activities across all releases, optionally filtered by environment, outcome, and time window.");
|
||||
if (includeRouteNames)
|
||||
@@ -642,6 +656,27 @@ public static class ReleaseEndpoints
|
||||
public required string Version { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReleaseDefaultsDto
|
||||
{
|
||||
public required string SourceReleaseId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string SuggestedNextVersion { get; init; }
|
||||
public string DeploymentStrategy { get; init; } = "rolling";
|
||||
public string? TargetEnvironment { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public List<ReleaseDefaultComponentDto> Components { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record ReleaseDefaultComponentDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string ImageRef { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required string Type { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AddComponentDto
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
@@ -704,6 +739,103 @@ public static class ReleaseEndpoints
|
||||
return Results.Ok(new { items, total = sorted.Count });
|
||||
}
|
||||
|
||||
private static IResult GetLatestByName([FromQuery] string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "name query parameter is required" });
|
||||
}
|
||||
|
||||
var latest = SeedData.Releases
|
||||
.Where(r => string.Equals(r.Name, name.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (latest is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "no_releases_found", name });
|
||||
}
|
||||
|
||||
var components = SeedData.Components.TryGetValue(latest.Id, out var comps)
|
||||
? comps : new List<ReleaseComponentDto>();
|
||||
|
||||
return Results.Ok(new ReleaseDefaultsDto
|
||||
{
|
||||
SourceReleaseId = latest.Id,
|
||||
Name = latest.Name,
|
||||
Version = latest.Version,
|
||||
SuggestedNextVersion = BumpVersion(latest.Version, latest.Status == "deployed"),
|
||||
DeploymentStrategy = latest.DeploymentStrategy,
|
||||
TargetEnvironment = latest.TargetEnvironment ?? latest.CurrentEnvironment,
|
||||
Description = latest.Description,
|
||||
Components = components.Select(c => new ReleaseDefaultComponentDto
|
||||
{
|
||||
Name = c.Name,
|
||||
ImageRef = c.ImageRef,
|
||||
Tag = c.Tag,
|
||||
Version = c.Version,
|
||||
Type = c.Type,
|
||||
}).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult SuggestNextVersion([FromQuery] string? name, [FromQuery] string? currentVersion)
|
||||
{
|
||||
string? baseVersion = currentVersion;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseVersion) && !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var latest = SeedData.Releases
|
||||
.Where(r => string.Equals(r.Name, name.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
baseVersion = latest?.Version;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseVersion))
|
||||
{
|
||||
return Results.Ok(new { suggestedVersion = "1.0.0", source = (string?)null });
|
||||
}
|
||||
|
||||
return Results.Ok(new { suggestedVersion = BumpVersion(baseVersion, true), source = baseVersion });
|
||||
}
|
||||
|
||||
private static string BumpVersion(string version, bool isStable)
|
||||
{
|
||||
// Handle prerelease: 1.3.0-rc1 → 1.3.0-rc2
|
||||
var dashIndex = version.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
var prerelease = version[(dashIndex + 1)..];
|
||||
var match = System.Text.RegularExpressions.Regex.Match(prerelease, @"^(.+?)(\d+)$");
|
||||
if (match.Success)
|
||||
{
|
||||
var prefix = match.Groups[1].Value;
|
||||
var num = int.Parse(match.Groups[2].Value) + 1;
|
||||
return $"{version[..dashIndex]}-{prefix}{num}";
|
||||
}
|
||||
// No numeric suffix — strip prerelease (promote to stable)
|
||||
return version[..dashIndex];
|
||||
}
|
||||
|
||||
// Handle semantic: 1.2.3 → 1.2.4
|
||||
var parts = version.Split('.');
|
||||
if (parts.Length >= 3 && int.TryParse(parts[^1], out var patch))
|
||||
{
|
||||
parts[^1] = (patch + 1).ToString();
|
||||
return string.Join('.', parts);
|
||||
}
|
||||
|
||||
// Handle date-based: 2026.02.20.1 → 2026.02.20.2
|
||||
if (parts.Length >= 2 && int.TryParse(parts[^1], out var build))
|
||||
{
|
||||
parts[^1] = (build + 1).ToString();
|
||||
return string.Join('.', parts);
|
||||
}
|
||||
|
||||
return version + ".1";
|
||||
}
|
||||
|
||||
// ---- Seed Data ----
|
||||
|
||||
internal static class SeedData
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, debounceTime, distinctUntilChanged, finalize, map, of, Subject, switchMap, throwError } from 'rxjs';
|
||||
|
||||
import { ReleaseManagementStore } from '../release.store';
|
||||
import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from '../../../../core/api/release-management.models';
|
||||
@@ -11,10 +12,14 @@ import {
|
||||
type ReleaseControlBundleVersionDetailDto,
|
||||
} from '../../../bundles/bundle-organizer.api';
|
||||
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
||||
import { ScriptPickerComponent } from '../../../../shared/components/script-picker/script-picker.component';
|
||||
import { ReleasePipelineEditorComponent } from './release-pipeline-editor.component';
|
||||
import { type ReleasePipeline, type ReleaseDefaults, createDefaultPipeline } from './release-pipeline.models';
|
||||
import type { Script } from '../../../../core/api/scripts.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-release',
|
||||
imports: [FormsModule, RouterModule],
|
||||
imports: [FormsModule, RouterModule, ScriptPickerComponent, ReleasePipelineEditorComponent],
|
||||
template: `
|
||||
<div class="create-release">
|
||||
<header class="wizard-header">
|
||||
@@ -61,14 +66,28 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
<p>Define the canonical identity fields for this version. All fields marked * are required.</p>
|
||||
</div>
|
||||
|
||||
@if (releaseDefaults()) {
|
||||
<div class="clone-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Based on <strong>{{ releaseDefaults()!.name }} {{ releaseDefaults()!.version }}</strong> — defaults pre-filled. Edit any field to customize.</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Release version name <abbr title="required">*</abbr></span>
|
||||
<input type="text" [(ngModel)]="form.name" placeholder="Checkout API" />
|
||||
<input type="text" [(ngModel)]="form.name" placeholder="Checkout API" (ngModelChange)="onNameChanged($event)" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Version <abbr title="required">*</abbr></span>
|
||||
<input type="text" [(ngModel)]="form.version" placeholder="2026.02.20.1" />
|
||||
<div class="version-input-row">
|
||||
<input type="text" [(ngModel)]="form.version" placeholder="2026.02.20.1" />
|
||||
@if (suggestedVersion() && !form.version) {
|
||||
<button type="button" class="version-suggest" (click)="acceptSuggestedVersion()">
|
||||
Use {{ suggestedVersion() }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -660,6 +679,36 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
<span class="field__hint">Optional JSON to merge into the release contract</span>
|
||||
</label>
|
||||
|
||||
<details class="strategy-config" open>
|
||||
<summary class="strategy-config__summary">
|
||||
<svg 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"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
|
||||
Lifecycle Scripts
|
||||
</summary>
|
||||
<div class="strategy-config__body">
|
||||
<p class="field__hint" style="margin: 0 0 0.5rem">Attach scripts from the registry to run at each stage of the deployment lifecycle.</p>
|
||||
<div class="lifecycle-hooks">
|
||||
<app-script-picker label="Pre-deploy" [selectedScriptId]="lifecycleHooks.preDeployScriptId" (scriptSelected)="onHookSelected('preDeploy', $event)" />
|
||||
<app-script-picker label="Post-deploy" [selectedScriptId]="lifecycleHooks.postDeployScriptId" (scriptSelected)="onHookSelected('postDeploy', $event)" />
|
||||
<app-script-picker label="Health check" [selectedScriptId]="lifecycleHooks.healthCheckScriptId" (scriptSelected)="onHookSelected('healthCheck', $event)" />
|
||||
<app-script-picker label="Rollback" [selectedScriptId]="lifecycleHooks.rollbackScriptId" (scriptSelected)="onHookSelected('rollback', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ─── Release Pipeline ─── -->
|
||||
<details class="strategy-config" open>
|
||||
<summary>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Deployment Pipeline
|
||||
</summary>
|
||||
<div class="strategy-config__body">
|
||||
<p class="field__hint" style="margin: 0 0 0.75rem">Define the phases your release will go through: gate checks, approvals, deployment, testing, and evidence sealing. Click a phase to configure it.</p>
|
||||
<app-release-pipeline-editor
|
||||
[pipeline]="releasePipeline()"
|
||||
(pipelineChange)="releasePipeline.set($event)" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" [(ngModel)]="contract.requireReplayParity" />
|
||||
<span>Require replay parity for promotion</span>
|
||||
@@ -784,6 +833,29 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (hasAnyLifecycleHook()) {
|
||||
<div class="review-card">
|
||||
<div class="review-card__header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>
|
||||
<h3>Lifecycle scripts</h3>
|
||||
</div>
|
||||
<dl class="review-card__dl">
|
||||
@if (lifecycleHooks.preDeployScriptName) {
|
||||
<dt>Pre-deploy</dt><dd>{{ lifecycleHooks.preDeployScriptName }}</dd>
|
||||
}
|
||||
@if (lifecycleHooks.postDeployScriptName) {
|
||||
<dt>Post-deploy</dt><dd>{{ lifecycleHooks.postDeployScriptName }}</dd>
|
||||
}
|
||||
@if (lifecycleHooks.healthCheckScriptName) {
|
||||
<dt>Health check</dt><dd>{{ lifecycleHooks.healthCheckScriptName }}</dd>
|
||||
}
|
||||
@if (lifecycleHooks.rollbackScriptName) {
|
||||
<dt>Rollback</dt><dd>{{ lifecycleHooks.rollbackScriptName }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<label class="seal-confirm">
|
||||
@@ -1318,6 +1390,8 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.lifecycle-hooks { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
|
||||
/* ─── Toggle pair (radio button pair) ─── */
|
||||
.toggle-pair {
|
||||
display: inline-flex;
|
||||
@@ -1798,6 +1872,27 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
border: 1px solid var(--color-status-warning-border, rgba(245, 158, 11, 0.25));
|
||||
}
|
||||
|
||||
/* ─── Smart defaults ─── */
|
||||
.clone-banner {
|
||||
display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.85rem;
|
||||
background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.08));
|
||||
border: 1px solid var(--color-brand-primary-20, rgba(245, 166, 35, 0.25));
|
||||
border-radius: var(--radius-md); margin-bottom: 0.75rem;
|
||||
font-size: var(--font-size-sm); color: var(--color-text-secondary);
|
||||
}
|
||||
.clone-banner svg { flex-shrink: 0; color: var(--color-brand-primary); }
|
||||
.clone-banner strong { color: var(--color-text-primary); }
|
||||
|
||||
.version-input-row { display: flex; gap: 0.35rem; align-items: stretch; }
|
||||
.version-input-row input { flex: 1; }
|
||||
.version-suggest {
|
||||
padding: 0.3rem 0.65rem; border: 1px solid var(--color-brand-primary);
|
||||
border-radius: var(--radius-md); background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.08));
|
||||
color: var(--color-brand-primary); font-size: var(--font-size-xs); font-weight: 600;
|
||||
cursor: pointer; white-space: nowrap; transition: all 120ms;
|
||||
}
|
||||
.version-suggest:hover { background: var(--color-brand-primary); color: #fff; }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 720px) {
|
||||
.form-row-2 { grid-template-columns: 1fr; }
|
||||
@@ -1810,6 +1905,7 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
|
||||
export class CreateReleaseComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||
readonly store = inject(ReleaseManagementStore);
|
||||
@@ -1820,6 +1916,14 @@ export class CreateReleaseComponent implements OnInit {
|
||||
readonly submitError = signal<string | null>(null);
|
||||
readonly submitting = signal(false);
|
||||
|
||||
// ─── Smart defaults ───
|
||||
readonly releaseDefaults = signal<ReleaseDefaults | null>(null);
|
||||
readonly suggestedVersion = signal<string | null>(null);
|
||||
private readonly nameSearch$ = new Subject<string>();
|
||||
|
||||
// ─── Pipeline ───
|
||||
readonly releasePipeline = signal<ReleasePipeline>(createDefaultPipeline('standard', 'rolling'));
|
||||
|
||||
// ─── Targets step ───
|
||||
readonly targetRegions = signal<string[]>([]);
|
||||
readonly targetEnvironments = signal<string[]>([]);
|
||||
@@ -1869,6 +1973,17 @@ export class CreateReleaseComponent implements OnInit {
|
||||
requireReplayParity: false,
|
||||
};
|
||||
|
||||
readonly lifecycleHooks = {
|
||||
preDeployScriptId: null as string | null,
|
||||
preDeployScriptName: null as string | null,
|
||||
postDeployScriptId: null as string | null,
|
||||
postDeployScriptName: null as string | null,
|
||||
healthCheckScriptId: null as string | null,
|
||||
healthCheckScriptName: null as string | null,
|
||||
rollbackScriptId: null as string | null,
|
||||
rollbackScriptName: null as string | null,
|
||||
};
|
||||
|
||||
readonly strategyConfig = {
|
||||
rolling: {
|
||||
batchSize: 1,
|
||||
@@ -1945,6 +2060,59 @@ export class CreateReleaseComponent implements OnInit {
|
||||
this.form.targetPathIntent = 'hotfix-prod';
|
||||
}
|
||||
}
|
||||
|
||||
// Smart defaults: debounced name lookup
|
||||
this.nameSearch$.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged(),
|
||||
switchMap(name => {
|
||||
if (!name || name.trim().length < 2) {
|
||||
return of(null);
|
||||
}
|
||||
return this.http.get<ReleaseDefaults>(
|
||||
`/api/release-orchestrator/releases/latest-by-name?name=${encodeURIComponent(name.trim())}`,
|
||||
).pipe(catchError(() => of(null)));
|
||||
}),
|
||||
).subscribe(defaults => {
|
||||
this.releaseDefaults.set(defaults);
|
||||
if (defaults) {
|
||||
this.suggestedVersion.set(defaults.suggestedNextVersion);
|
||||
// Auto-fill fields from previous release (user can override)
|
||||
if (!this.form.targetEnvironment) {
|
||||
this.form.targetEnvironment = defaults.targetEnvironment ?? '';
|
||||
}
|
||||
this.contract.deploymentStrategy = (defaults.deploymentStrategy as DeploymentStrategy) ?? 'rolling';
|
||||
// Update pipeline to match strategy
|
||||
this.releasePipeline.set(createDefaultPipeline(
|
||||
this.form.releaseType as 'standard' | 'hotfix',
|
||||
this.contract.deploymentStrategy,
|
||||
));
|
||||
// Pre-fill components (without digests — user must pick new images)
|
||||
if (this.components.length === 0 && defaults.components.length > 0) {
|
||||
this.components = defaults.components.map(c => ({
|
||||
name: c.name,
|
||||
imageRef: c.imageRef,
|
||||
digest: '', // Must be re-selected
|
||||
tag: c.tag,
|
||||
version: '',
|
||||
type: c.type as 'container' | 'helm' | 'script',
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.suggestedVersion.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onNameChanged(name: string): void {
|
||||
this.nameSearch$.next(name);
|
||||
}
|
||||
|
||||
acceptSuggestedVersion(): void {
|
||||
const suggested = this.suggestedVersion();
|
||||
if (suggested) {
|
||||
this.form.version = suggested;
|
||||
}
|
||||
}
|
||||
|
||||
canContinueStep(): boolean {
|
||||
@@ -2084,6 +2252,16 @@ export class CreateReleaseComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
onHookSelected(hook: 'preDeploy' | 'postDeploy' | 'healthCheck' | 'rollback', script: Script | null): void {
|
||||
this.lifecycleHooks[`${hook}ScriptId`] = script?.id ?? null;
|
||||
this.lifecycleHooks[`${hook}ScriptName`] = script?.name ?? null;
|
||||
}
|
||||
|
||||
hasAnyLifecycleHook(): boolean {
|
||||
return !!(this.lifecycleHooks.preDeployScriptId || this.lifecycleHooks.postDeployScriptId ||
|
||||
this.lifecycleHooks.healthCheckScriptId || this.lifecycleHooks.rollbackScriptId);
|
||||
}
|
||||
|
||||
getActiveStrategyConfigJson(): string {
|
||||
switch (this.contract.deploymentStrategy) {
|
||||
case 'rolling': return JSON.stringify(this.strategyConfig.rolling);
|
||||
@@ -2179,6 +2357,12 @@ export class CreateReleaseComponent implements OnInit {
|
||||
`targetStage=${this.targetStage || 'none'}`,
|
||||
`draftIdentity=${this.draftIdentityPreview()}`,
|
||||
`strategyConfig=${this.getActiveStrategyConfigJson()}`,
|
||||
this.hasAnyLifecycleHook() ? `lifecycleHooks=${JSON.stringify({
|
||||
preDeploy: this.lifecycleHooks.preDeployScriptId,
|
||||
postDeploy: this.lifecycleHooks.postDeployScriptId,
|
||||
healthCheck: this.lifecycleHooks.healthCheckScriptId,
|
||||
rollback: this.lifecycleHooks.rollbackScriptId,
|
||||
})}` : '',
|
||||
].filter((item) => item.length > 0);
|
||||
|
||||
const bundleSlug = this.toSlug(this.form.name.trim());
|
||||
@@ -2237,7 +2421,16 @@ export class CreateReleaseComponent implements OnInit {
|
||||
}
|
||||
|
||||
private toBundleComponents() {
|
||||
return this.components.map((component) => ({
|
||||
const hooks = this.hasAnyLifecycleHook() ? {
|
||||
lifecycleHooks: {
|
||||
preDeploy: this.lifecycleHooks.preDeployScriptId,
|
||||
postDeploy: this.lifecycleHooks.postDeployScriptId,
|
||||
healthCheck: this.lifecycleHooks.healthCheckScriptId,
|
||||
rollback: this.lifecycleHooks.rollbackScriptId,
|
||||
},
|
||||
} : {};
|
||||
|
||||
return this.components.map((component, index) => ({
|
||||
componentName: component.name,
|
||||
componentVersionId: `${component.name}@${component.version}`,
|
||||
imageDigest: component.digest,
|
||||
@@ -2246,6 +2439,7 @@ export class CreateReleaseComponent implements OnInit {
|
||||
imageRef: component.imageRef,
|
||||
tag: component.tag ?? null,
|
||||
type: component.type,
|
||||
...(index === 0 ? hooks : {}),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { Component, input, output, signal, computed } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
type ReleasePipeline, type PipelinePhase, type PipelinePhaseType,
|
||||
type DeployConfig, type DeploymentStrategy, type TestConfig, type FallbackConfig,
|
||||
type ApprovalConfig, type GateConfig, type CanaryDeployConfig, type RollingDeployConfig,
|
||||
type BlueGreenDeployConfig, type AbDeployConfig,
|
||||
PHASE_CATALOG, createDefaultPipeline,
|
||||
} from './release-pipeline.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-pipeline-editor',
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="pipeline-editor">
|
||||
<!-- ─── Phase strip (horizontal flow) ─── -->
|
||||
<div class="phase-strip">
|
||||
<div class="phase-strip__start">START</div>
|
||||
@for (phase of pipeline().phases; track phase.id; let i = $index) {
|
||||
<svg class="phase-strip__arrow" width="28" height="24" viewBox="0 0 28 24">
|
||||
<path d="M0 12h24m-6-6l6 6-6 6" stroke="var(--color-border-primary)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<button type="button" class="phase-node"
|
||||
[class.phase-node--active]="selectedPhaseId() === phase.id"
|
||||
[class.phase-node--disabled]="!phase.enabled"
|
||||
[class.phase-node--deploy]="phase.type === 'deploy'"
|
||||
[class.phase-node--test]="phase.type === 'test'"
|
||||
[class.phase-node--gate]="phase.type === 'gate'"
|
||||
[class.phase-node--approval]="phase.type === 'approval'"
|
||||
[class.phase-node--seal]="phase.type === 'seal'"
|
||||
(click)="selectPhase(phase.id)">
|
||||
<svg class="phase-node__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path [attr.d]="phaseIcon(phase.type)"/>
|
||||
</svg>
|
||||
<span class="phase-node__label">{{ phase.label }}</span>
|
||||
@if (phase.type === 'deploy') {
|
||||
<span class="phase-node__badge">{{ deployStrategyLabel(phase) }}</span>
|
||||
}
|
||||
@if (phase.fallback?.autoRollback) {
|
||||
<span class="phase-node__fallback-indicator" title="Auto-rollback on failure">↩</span>
|
||||
}
|
||||
</button>
|
||||
@if (phase.fallback && phase.type === 'deploy') {
|
||||
<div class="fallback-branch">
|
||||
<svg class="fallback-branch__line" width="1" height="28" viewBox="0 0 1 28">
|
||||
<line x1="0" y1="0" x2="0" y2="28" stroke="var(--color-status-error-text, #ef4444)" stroke-width="1.5" stroke-dasharray="3,2"/>
|
||||
</svg>
|
||||
<span class="fallback-branch__label">{{ phase.fallback.behavior }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<svg class="phase-strip__arrow" width="28" height="24" viewBox="0 0 28 24">
|
||||
<path d="M0 12h24m-6-6l6 6-6 6" stroke="var(--color-border-primary)" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="phase-strip__end">END</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Add phase button ─── -->
|
||||
<div class="add-phase-row">
|
||||
<button type="button" class="add-phase-btn" (click)="showPalette.set(!showPalette())">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Add Phase
|
||||
</button>
|
||||
@if (showPalette()) {
|
||||
<div class="phase-palette">
|
||||
@for (item of paletteCatalog; track item.type) {
|
||||
<button type="button" class="phase-palette__item" (click)="addPhase(item.type)">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path [attr.d]="item.icon"/></svg>
|
||||
<span>{{ item.label }}</span>
|
||||
<small>{{ item.description }}</small>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ─── Selected phase config ─── -->
|
||||
@if (selectedPhase(); as phase) {
|
||||
<div class="phase-config">
|
||||
<div class="phase-config__header">
|
||||
<h3>{{ phase.label }}</h3>
|
||||
<div class="phase-config__actions">
|
||||
<label class="toggle-inline">
|
||||
<input type="checkbox" [checked]="phase.enabled" (change)="togglePhaseEnabled(phase.id)"/>
|
||||
<span>{{ phase.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
</label>
|
||||
@if (canRemovePhase(phase)) {
|
||||
<button type="button" class="btn-icon btn-icon--danger" (click)="removePhase(phase.id)" title="Remove phase">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (phase.type) {
|
||||
@case ('deploy') {
|
||||
<!-- Deploy phase config -->
|
||||
<div class="deploy-config">
|
||||
<label class="field">
|
||||
<span class="field__label">Strategy</span>
|
||||
<select [ngModel]="getDeployStrategy(phase)" (ngModelChange)="changeDeployStrategy(phase.id, $event)">
|
||||
<option value="rolling">Rolling</option>
|
||||
<option value="canary">Canary</option>
|
||||
<option value="blue_green">Blue-Green</option>
|
||||
<option value="recreate">Recreate (All-at-once)</option>
|
||||
<option value="ab-release">A/B Release</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Strategy visualization -->
|
||||
<div class="strategy-viz">
|
||||
@switch (getDeployStrategy(phase)) {
|
||||
@case ('rolling') {
|
||||
<div class="strategy-viz__rolling">
|
||||
@for (i of rollingBatchCount(phase); track i) {
|
||||
<div class="batch-node">
|
||||
<div class="batch-node__header">Batch {{ i + 1 }}</div>
|
||||
<div class="batch-node__bar"></div>
|
||||
</div>
|
||||
@if (i < rollingBatchCount(phase).length - 1) {
|
||||
<div class="batch-arrow">
|
||||
<svg width="20" height="24" viewBox="0 0 20 24"><path d="M2 12h16m-4-4l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>
|
||||
<small>health</small>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('canary') {
|
||||
<div class="strategy-viz__canary">
|
||||
@for (stage of getCanaryStages(phase); track $index; let i = $index) {
|
||||
<div class="canary-stage">
|
||||
<div class="canary-stage__bar" [style.height.%]="stage.trafficPercent"></div>
|
||||
<span class="canary-stage__label">{{ stage.trafficPercent }}%</span>
|
||||
<small>{{ stage.durationMinutes }}m</small>
|
||||
</div>
|
||||
@if (i < getCanaryStages(phase).length - 1) {
|
||||
<div class="canary-arrow">
|
||||
<svg width="16" height="20" viewBox="0 0 16 20"><path d="M2 10h12m-4-3l4 3-4 3" stroke="currentColor" stroke-width="1" fill="none"/></svg>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="canary-stage canary-stage--full">
|
||||
<div class="canary-stage__bar" style="height:100%"></div>
|
||||
<span class="canary-stage__label">100%</span>
|
||||
<small>full</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('blue_green') {
|
||||
<div class="strategy-viz__bluegreen">
|
||||
<div class="bg-lane bg-lane--blue">
|
||||
<span>Blue (current)</span>
|
||||
<div class="bg-lane__bar bg-lane__bar--active"></div>
|
||||
</div>
|
||||
<div class="bg-lane bg-lane--green">
|
||||
<span>Green (new)</span>
|
||||
<div class="bg-lane__bar"></div>
|
||||
</div>
|
||||
<div class="bg-switch">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
|
||||
<small>switch</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('ab-release') {
|
||||
<div class="strategy-viz__ab">
|
||||
<div class="ab-variant ab-variant--a">
|
||||
<span>Variant A</span>
|
||||
<div class="ab-variant__bar"></div>
|
||||
</div>
|
||||
<div class="ab-variant ab-variant--b">
|
||||
<span>Variant B</span>
|
||||
<div class="ab-variant__bar"></div>
|
||||
</div>
|
||||
<div class="ab-metrics">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
<small>metrics</small>
|
||||
</div>
|
||||
<div class="ab-winner">winner → 100%</div>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<div class="strategy-viz__simple">
|
||||
<div class="simple-node">Deploy all targets</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Fallback config -->
|
||||
<div class="fallback-section">
|
||||
<h4>On Failure</h4>
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Behavior</span>
|
||||
<select [ngModel]="phase.fallback?.behavior ?? 'rollback'" (ngModelChange)="updateFallbackBehavior(phase.id, $event)">
|
||||
<option value="rollback">Auto-rollback</option>
|
||||
<option value="pause">Pause & alert</option>
|
||||
<option value="continue">Continue (ignore)</option>
|
||||
<option value="abort">Abort</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle-inline">
|
||||
<input type="checkbox" [checked]="phase.fallback?.autoRollback ?? true" (change)="toggleAutoRollback(phase.id)"/>
|
||||
<span>Auto-rollback on threshold breach</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('test') {
|
||||
<div class="test-config">
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Test type</span>
|
||||
<select [ngModel]="getTestType(phase)" (ngModelChange)="updateTestType(phase.id, $event)">
|
||||
<option value="smoke">Smoke Test</option>
|
||||
<option value="health-check">Health Check</option>
|
||||
<option value="integration">Integration Test</option>
|
||||
<option value="canary-metrics">Canary Metrics</option>
|
||||
<option value="manual">Manual Verification</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Timeout (seconds)</span>
|
||||
<input type="number" [ngModel]="getTestTimeout(phase)" (ngModelChange)="updateTestTimeout(phase.id, $event)" min="10" max="3600"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('approval') {
|
||||
<div class="approval-config">
|
||||
<div class="form-row-2">
|
||||
<label class="field">
|
||||
<span class="field__label">Required approvals</span>
|
||||
<input type="number" [ngModel]="getApprovalCount(phase)" (ngModelChange)="updateApprovalCount(phase.id, $event)" min="1" max="10"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Timeout (hours)</span>
|
||||
<input type="number" [ngModel]="getApprovalTimeout(phase)" (ngModelChange)="updateApprovalTimeout(phase.id, $event)" min="1" max="168"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('gate') {
|
||||
<div class="gate-config">
|
||||
<p class="hint">Gates are evaluated from the active policy pack. Toggle which gate types are required to pass.</p>
|
||||
@for (gate of getGates(phase); track gate.id) {
|
||||
<label class="toggle-inline">
|
||||
<input type="checkbox" [checked]="gate.required" (change)="toggleGateRequired(phase.id, gate.id)"/>
|
||||
<span>{{ gate.name }} ({{ gate.type }})</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<p class="hint">No additional configuration required for this phase.</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.pipeline-editor { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
/* ─── Phase strip ─── */
|
||||
.phase-strip {
|
||||
display: flex; align-items: flex-start; gap: 0.25rem; padding: 1rem;
|
||||
background: var(--color-surface-subtle, #fafafa); border: 1px solid var(--color-border-secondary);
|
||||
border-radius: var(--radius-lg); overflow-x: auto; position: relative;
|
||||
}
|
||||
.phase-strip__start, .phase-strip__end {
|
||||
display: flex; align-items: center; justify-content: center; min-width: 48px; height: 40px;
|
||||
border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); font-weight: 700;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
}
|
||||
.phase-strip__start { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); }
|
||||
.phase-strip__end { background: var(--color-surface-secondary); color: var(--color-text-tertiary); border: 1px solid var(--color-border-secondary); }
|
||||
.phase-strip__arrow { flex-shrink: 0; align-self: center; color: var(--color-border-primary); }
|
||||
|
||||
/* ─── Phase node ─── */
|
||||
.phase-node {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.2rem;
|
||||
min-width: 80px; padding: 0.5rem 0.65rem; border-radius: var(--radius-lg);
|
||||
border: 2px solid var(--color-border-secondary); background: var(--color-surface-primary);
|
||||
cursor: pointer; transition: all 120ms; position: relative; font-size: var(--font-size-xs, 0.6875rem);
|
||||
}
|
||||
.phase-node:hover { border-color: var(--color-brand-primary); }
|
||||
.phase-node--active { border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-brand-primary-20, rgba(245, 166, 35, 0.2)); }
|
||||
.phase-node--disabled { opacity: 0.4; }
|
||||
.phase-node--deploy { border-left: 3px solid var(--color-status-info-text, #3b82f6); }
|
||||
.phase-node--test { border-left: 3px solid var(--color-status-warning-text, #f59e0b); }
|
||||
.phase-node--gate { border-left: 3px solid var(--color-status-error-text, #ef4444); }
|
||||
.phase-node--approval { border-left: 3px solid #8b5cf6; }
|
||||
.phase-node--seal { border-left: 3px solid var(--color-status-success-text, #22c55e); }
|
||||
.phase-node__icon { color: var(--color-text-secondary); }
|
||||
.phase-node__label { font-weight: 600; white-space: nowrap; }
|
||||
.phase-node__badge {
|
||||
font-size: 0.6rem; padding: 0.1rem 0.3rem; border-radius: var(--radius-sm);
|
||||
background: var(--color-status-info-bg); color: var(--color-status-info-text);
|
||||
}
|
||||
.phase-node__fallback-indicator {
|
||||
position: absolute; bottom: -6px; right: -4px; font-size: 0.65rem;
|
||||
background: var(--color-status-error-bg); color: var(--color-status-error-text);
|
||||
border-radius: var(--radius-full); width: 16px; height: 16px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* ─── Fallback branch ─── */
|
||||
.fallback-branch { display: flex; flex-direction: column; align-items: center; margin-top: 2px; }
|
||||
.fallback-branch__label { font-size: 0.55rem; color: var(--color-status-error-text); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
/* ─── Add phase ─── */
|
||||
.add-phase-row { position: relative; }
|
||||
.add-phase-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.75rem;
|
||||
border: 1px dashed var(--color-border-secondary); border-radius: var(--radius-md);
|
||||
background: transparent; color: var(--color-text-secondary); cursor: pointer;
|
||||
font-size: var(--font-size-sm); transition: all 120ms;
|
||||
}
|
||||
.add-phase-btn:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); }
|
||||
.phase-palette {
|
||||
position: absolute; top: 100%; left: 0; margin-top: 0.25rem; z-index: 10;
|
||||
background: var(--color-surface-primary); border: 1px solid var(--color-border-secondary);
|
||||
border-radius: var(--radius-lg); box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||
padding: 0.25rem; min-width: 280px;
|
||||
}
|
||||
.phase-palette__item {
|
||||
display: grid; grid-template-columns: 20px 1fr; gap: 0.25rem 0.5rem; align-items: start;
|
||||
width: 100%; padding: 0.5rem; border: none; background: transparent; cursor: pointer;
|
||||
text-align: left; border-radius: var(--radius-md); transition: background 80ms;
|
||||
}
|
||||
.phase-palette__item:hover { background: var(--color-surface-subtle); }
|
||||
.phase-palette__item span { font-weight: 600; font-size: var(--font-size-sm); grid-column: 2; }
|
||||
.phase-palette__item small { grid-column: 2; font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
|
||||
.phase-palette__item svg { grid-row: 1 / 3; align-self: center; color: var(--color-text-secondary); }
|
||||
|
||||
/* ─── Phase config panel ─── */
|
||||
.phase-config {
|
||||
border: 1px solid var(--color-border-secondary); border-radius: var(--radius-lg);
|
||||
padding: 1rem; background: var(--color-surface-primary);
|
||||
}
|
||||
.phase-config__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.phase-config__header h3 { margin: 0; font-size: var(--font-size-base); }
|
||||
.phase-config__actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
|
||||
/* ─── Strategy visualizations ─── */
|
||||
.strategy-viz { padding: 0.75rem; background: var(--color-surface-subtle); border-radius: var(--radius-md); margin: 0.75rem 0; }
|
||||
.strategy-viz__rolling, .strategy-viz__canary, .strategy-viz__bluegreen, .strategy-viz__ab, .strategy-viz__simple {
|
||||
display: flex; align-items: center; gap: 0.5rem; justify-content: center; flex-wrap: wrap;
|
||||
}
|
||||
.batch-node { text-align: center; }
|
||||
.batch-node__header { font-size: var(--font-size-xs); font-weight: 600; margin-bottom: 0.2rem; }
|
||||
.batch-node__bar { width: 48px; height: 6px; background: var(--color-status-info-text); border-radius: 3px; }
|
||||
.batch-arrow { display: flex; flex-direction: column; align-items: center; color: var(--color-text-tertiary); }
|
||||
.batch-arrow small { font-size: 0.55rem; }
|
||||
|
||||
.canary-stage { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
|
||||
.canary-stage__bar { width: 32px; min-height: 4px; background: var(--color-status-warning-text); border-radius: 2px; transition: height 200ms; }
|
||||
.canary-stage__label { font-size: var(--font-size-xs); font-weight: 700; }
|
||||
.canary-stage small { font-size: 0.55rem; color: var(--color-text-tertiary); }
|
||||
.canary-stage--full .canary-stage__bar { background: var(--color-status-success-text); }
|
||||
.canary-arrow { align-self: center; color: var(--color-text-tertiary); }
|
||||
|
||||
.bg-lane { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.bg-lane span { font-size: var(--font-size-xs); font-weight: 600; min-width: 80px; }
|
||||
.bg-lane__bar { width: 80px; height: 8px; border-radius: 4px; }
|
||||
.bg-lane--blue .bg-lane__bar { background: var(--color-status-info-text); }
|
||||
.bg-lane--green .bg-lane__bar { background: var(--color-status-success-text); }
|
||||
.bg-lane__bar--active { opacity: 1; }
|
||||
.bg-switch { display: flex; flex-direction: column; align-items: center; color: var(--color-text-secondary); }
|
||||
.bg-switch small { font-size: 0.55rem; }
|
||||
|
||||
.strategy-viz__bluegreen { flex-direction: column; gap: 0.5rem; }
|
||||
.strategy-viz__ab { flex-direction: column; gap: 0.35rem; }
|
||||
.ab-variant { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ab-variant span { font-size: var(--font-size-xs); font-weight: 600; min-width: 70px; }
|
||||
.ab-variant__bar { width: 80px; height: 8px; border-radius: 4px; }
|
||||
.ab-variant--a .ab-variant__bar { background: var(--color-status-info-text); }
|
||||
.ab-variant--b .ab-variant__bar { background: #8b5cf6; }
|
||||
.ab-metrics { display: flex; align-items: center; gap: 0.3rem; color: var(--color-text-tertiary); }
|
||||
.ab-metrics small { font-size: 0.55rem; }
|
||||
.ab-winner { font-size: var(--font-size-xs); font-weight: 700; color: var(--color-status-success-text); }
|
||||
|
||||
.simple-node {
|
||||
padding: 0.5rem 1rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm); text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Shared form elements ─── */
|
||||
.form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.field__label { font-size: var(--font-size-xs); font-weight: 600; color: var(--color-text-secondary); }
|
||||
.field select, .field input {
|
||||
padding: 0.4rem 0.5rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary); font-size: var(--font-size-sm);
|
||||
}
|
||||
.toggle-inline { display: flex; align-items: center; gap: 0.4rem; font-size: var(--font-size-sm); cursor: pointer; }
|
||||
.hint { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin: 0 0 0.5rem; }
|
||||
.fallback-section { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border-secondary); }
|
||||
.fallback-section h4 { margin: 0 0 0.5rem; font-size: var(--font-size-sm); color: var(--color-text-secondary); }
|
||||
.btn-icon { padding: 0.25rem; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-sm); background: transparent; cursor: pointer; }
|
||||
.btn-icon--danger:hover { border-color: var(--color-status-error-text); color: var(--color-status-error-text); }
|
||||
|
||||
@media (max-width: 720px) { .form-row-2 { grid-template-columns: 1fr; } }
|
||||
`],
|
||||
})
|
||||
export class ReleasePipelineEditorComponent {
|
||||
readonly pipeline = input.required<ReleasePipeline>();
|
||||
readonly pipelineChange = output<ReleasePipeline>();
|
||||
|
||||
readonly selectedPhaseId = signal<string | null>(null);
|
||||
readonly showPalette = signal(false);
|
||||
|
||||
readonly paletteCatalog = PHASE_CATALOG;
|
||||
|
||||
readonly selectedPhase = computed(() => {
|
||||
const id = this.selectedPhaseId();
|
||||
return id ? this.pipeline().phases.find(p => p.id === id) ?? null : null;
|
||||
});
|
||||
|
||||
phaseIcon(type: PipelinePhaseType): string {
|
||||
return PHASE_CATALOG.find(p => p.type === type)?.icon ?? '';
|
||||
}
|
||||
|
||||
deployStrategyLabel(phase: PipelinePhase): string {
|
||||
const config = phase.config as DeployConfig;
|
||||
return config.strategy?.replace('_', '-') ?? 'rolling';
|
||||
}
|
||||
|
||||
selectPhase(id: string): void {
|
||||
this.selectedPhaseId.set(this.selectedPhaseId() === id ? null : id);
|
||||
}
|
||||
|
||||
addPhase(type: PipelinePhaseType): void {
|
||||
const catalog = PHASE_CATALOG.find(p => p.type === type);
|
||||
if (!catalog) return;
|
||||
const newPhase: PipelinePhase = {
|
||||
id: `ph-${type}-${Date.now()}`,
|
||||
type,
|
||||
label: catalog.label,
|
||||
enabled: true,
|
||||
config: catalog.defaultConfig(),
|
||||
fallback: type === 'deploy' ? { behavior: 'rollback', autoRollback: true, notifyOnFallback: true } : undefined,
|
||||
};
|
||||
const updated = { ...this.pipeline(), phases: [...this.pipeline().phases, newPhase] };
|
||||
this.pipelineChange.emit(updated);
|
||||
this.showPalette.set(false);
|
||||
this.selectedPhaseId.set(newPhase.id);
|
||||
}
|
||||
|
||||
removePhase(id: string): void {
|
||||
const updated = { ...this.pipeline(), phases: this.pipeline().phases.filter(p => p.id !== id) };
|
||||
this.pipelineChange.emit(updated);
|
||||
if (this.selectedPhaseId() === id) this.selectedPhaseId.set(null);
|
||||
}
|
||||
|
||||
canRemovePhase(phase: PipelinePhase): boolean {
|
||||
return phase.type !== 'deploy'; // Must always have at least one deploy phase
|
||||
}
|
||||
|
||||
togglePhaseEnabled(id: string): void {
|
||||
this.updatePhase(id, p => ({ ...p, enabled: !p.enabled }));
|
||||
}
|
||||
|
||||
getDeployStrategy(phase: PipelinePhase): DeploymentStrategy {
|
||||
return (phase.config as DeployConfig).strategy ?? 'rolling';
|
||||
}
|
||||
|
||||
changeDeployStrategy(id: string, strategy: DeploymentStrategy): void {
|
||||
const defaultDeploy = createDefaultPipeline('standard', strategy).phases.find(p => p.type === 'deploy');
|
||||
if (defaultDeploy) {
|
||||
this.updatePhase(id, p => ({ ...p, config: defaultDeploy.config }));
|
||||
}
|
||||
}
|
||||
|
||||
rollingBatchCount(phase: PipelinePhase): number[] {
|
||||
const config = (phase.config as DeployConfig).config as RollingDeployConfig;
|
||||
const count = config?.batchSize > 0 ? Math.min(config.batchSize, 6) : 3;
|
||||
return Array.from({ length: count }, (_, i) => i);
|
||||
}
|
||||
|
||||
getCanaryStages(phase: PipelinePhase): Array<{ trafficPercent: number; durationMinutes: number; healthThreshold: number }> {
|
||||
const config = (phase.config as DeployConfig).config as CanaryDeployConfig;
|
||||
return config?.stages ?? [];
|
||||
}
|
||||
|
||||
getTestType(phase: PipelinePhase): string {
|
||||
return (phase.config as TestConfig).testType ?? 'smoke';
|
||||
}
|
||||
|
||||
updateTestType(id: string, testType: string): void {
|
||||
this.updatePhase(id, p => ({ ...p, config: { ...(p.config as TestConfig), testType: testType as TestConfig['testType'] } }));
|
||||
}
|
||||
|
||||
getTestTimeout(phase: PipelinePhase): number {
|
||||
return (phase.config as TestConfig).timeoutSeconds ?? 300;
|
||||
}
|
||||
|
||||
updateTestTimeout(id: string, timeout: number): void {
|
||||
this.updatePhase(id, p => ({ ...p, config: { ...(p.config as TestConfig), timeoutSeconds: timeout } }));
|
||||
}
|
||||
|
||||
getApprovalCount(phase: PipelinePhase): number {
|
||||
return (phase.config as ApprovalConfig).requiredApprovals ?? 2;
|
||||
}
|
||||
|
||||
updateApprovalCount(id: string, count: number): void {
|
||||
this.updatePhase(id, p => ({ ...p, config: { ...(p.config as ApprovalConfig), requiredApprovals: count } }));
|
||||
}
|
||||
|
||||
getApprovalTimeout(phase: PipelinePhase): number {
|
||||
return (phase.config as ApprovalConfig).timeoutHours ?? 48;
|
||||
}
|
||||
|
||||
updateApprovalTimeout(id: string, hours: number): void {
|
||||
this.updatePhase(id, p => ({ ...p, config: { ...(p.config as ApprovalConfig), timeoutHours: hours } }));
|
||||
}
|
||||
|
||||
getGates(phase: PipelinePhase): Array<{ id: string; name: string; type: string; required: boolean }> {
|
||||
return (phase.config as GateConfig).gates ?? [];
|
||||
}
|
||||
|
||||
toggleGateRequired(phaseId: string, gateId: string): void {
|
||||
this.updatePhase(phaseId, p => {
|
||||
const config = p.config as GateConfig;
|
||||
return { ...p, config: { ...config, gates: config.gates.map(g => g.id === gateId ? { ...g, required: !g.required } : g) } };
|
||||
});
|
||||
}
|
||||
|
||||
updateFallbackBehavior(id: string, behavior: string): void {
|
||||
this.updatePhase(id, p => ({ ...p, fallback: { ...(p.fallback ?? { behavior: 'rollback', autoRollback: true, notifyOnFallback: true }), behavior: behavior as FallbackConfig['behavior'] } }));
|
||||
}
|
||||
|
||||
toggleAutoRollback(id: string): void {
|
||||
this.updatePhase(id, p => ({
|
||||
...p,
|
||||
fallback: { ...(p.fallback ?? { behavior: 'rollback', autoRollback: true, notifyOnFallback: true }), autoRollback: !(p.fallback?.autoRollback ?? true) },
|
||||
}));
|
||||
}
|
||||
|
||||
private updatePhase(id: string, updater: (phase: PipelinePhase) => PipelinePhase): void {
|
||||
const updated = {
|
||||
...this.pipeline(),
|
||||
phases: this.pipeline().phases.map(p => p.id === id ? updater(p) : p),
|
||||
};
|
||||
this.pipelineChange.emit(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Release Pipeline Model
|
||||
*
|
||||
* A release pipeline is a sequence of phases that the workflow engine executes
|
||||
* when a release is promoted. Each phase maps to one or more workflow steps.
|
||||
*/
|
||||
|
||||
// ── Phase Types ──
|
||||
|
||||
export type PipelinePhaseType =
|
||||
| 'preflight'
|
||||
| 'gate'
|
||||
| 'approval'
|
||||
| 'deploy'
|
||||
| 'test'
|
||||
| 'promote'
|
||||
| 'seal';
|
||||
|
||||
export type DeploymentStrategy = 'rolling' | 'canary' | 'blue_green' | 'recreate' | 'ab-release';
|
||||
export type TestType = 'smoke' | 'health-check' | 'integration' | 'canary-metrics' | 'manual';
|
||||
export type FallbackBehavior = 'rollback' | 'pause' | 'continue' | 'abort';
|
||||
|
||||
// ── Phase Configs ──
|
||||
|
||||
export interface PreflightConfig {
|
||||
verifyDigests: boolean;
|
||||
checkTagDrift: boolean;
|
||||
}
|
||||
|
||||
export interface GateConfig {
|
||||
policyPackId?: string;
|
||||
gates: Array<{ id: string; name: string; type: 'security' | 'compliance' | 'quality'; required: boolean }>;
|
||||
}
|
||||
|
||||
export interface ApprovalConfig {
|
||||
roles: string[];
|
||||
requiredApprovals: number;
|
||||
timeoutHours: number;
|
||||
}
|
||||
|
||||
export interface RollingDeployConfig {
|
||||
batchSize: number;
|
||||
batchSizeType: 'count' | 'percentage';
|
||||
batchDelay: number;
|
||||
stabilizationTime: number;
|
||||
maxFailedBatches: number;
|
||||
healthCheckType: 'http' | 'tcp' | 'command';
|
||||
}
|
||||
|
||||
export interface CanaryDeployConfig {
|
||||
stages: Array<{ trafficPercent: number; durationMinutes: number; healthThreshold: number }>;
|
||||
autoProgression: boolean;
|
||||
errorRateThreshold: number;
|
||||
latencyThresholdMs: number;
|
||||
}
|
||||
|
||||
export interface BlueGreenDeployConfig {
|
||||
switchoverMode: 'instant' | 'gradual';
|
||||
gradualStages: Array<{ trafficPercent: number }>;
|
||||
warmupPeriodSeconds: number;
|
||||
blueKeepaliveMinutes: number;
|
||||
validationCommand: string;
|
||||
}
|
||||
|
||||
export interface RecreateDeployConfig {
|
||||
maxConcurrency: number;
|
||||
failureBehavior: FallbackBehavior;
|
||||
healthCheckTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface AbDeployConfig {
|
||||
subType: 'target-group' | 'router-based';
|
||||
stages: Array<{
|
||||
name: string;
|
||||
aPercent: number;
|
||||
bPercent: number;
|
||||
durationMinutes: number;
|
||||
healthThreshold: number;
|
||||
requiresApproval: boolean;
|
||||
}>;
|
||||
metricsCollectionHours: number;
|
||||
}
|
||||
|
||||
export type DeployConfig =
|
||||
| { strategy: 'rolling'; config: RollingDeployConfig }
|
||||
| { strategy: 'canary'; config: CanaryDeployConfig }
|
||||
| { strategy: 'blue_green'; config: BlueGreenDeployConfig }
|
||||
| { strategy: 'recreate'; config: RecreateDeployConfig }
|
||||
| { strategy: 'ab-release'; config: AbDeployConfig };
|
||||
|
||||
export interface TestConfig {
|
||||
testType: TestType;
|
||||
scriptId?: string;
|
||||
scriptName?: string;
|
||||
timeoutSeconds: number;
|
||||
retries: number;
|
||||
thresholds?: {
|
||||
errorRatePercent?: number;
|
||||
latencyP99Ms?: number;
|
||||
successRatePercent?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FallbackConfig {
|
||||
behavior: FallbackBehavior;
|
||||
rollbackScriptId?: string;
|
||||
rollbackScriptName?: string;
|
||||
autoRollback: boolean;
|
||||
notifyOnFallback: boolean;
|
||||
}
|
||||
|
||||
export interface SealConfig {
|
||||
signEvidence: boolean;
|
||||
exportFormat: 'slsa' | 'in-toto' | 'stella';
|
||||
}
|
||||
|
||||
// ── Pipeline Phase ──
|
||||
|
||||
export interface PipelinePhase {
|
||||
id: string;
|
||||
type: PipelinePhaseType;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
config:
|
||||
| PreflightConfig
|
||||
| GateConfig
|
||||
| ApprovalConfig
|
||||
| DeployConfig
|
||||
| TestConfig
|
||||
| SealConfig;
|
||||
fallback?: FallbackConfig;
|
||||
}
|
||||
|
||||
// ── Pipeline ──
|
||||
|
||||
export interface ReleasePipeline {
|
||||
phases: PipelinePhase[];
|
||||
defaultFallback: FallbackConfig;
|
||||
}
|
||||
|
||||
// ── Smart Defaults Response ──
|
||||
|
||||
export interface ReleaseDefaults {
|
||||
sourceReleaseId: string;
|
||||
name: string;
|
||||
version: string;
|
||||
suggestedNextVersion: string;
|
||||
deploymentStrategy: DeploymentStrategy;
|
||||
targetEnvironment?: string;
|
||||
description?: string;
|
||||
components: Array<{
|
||||
name: string;
|
||||
imageRef: string;
|
||||
tag?: string;
|
||||
version: string;
|
||||
type: 'container' | 'helm' | 'script';
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Phase metadata for the palette ──
|
||||
|
||||
export const PHASE_CATALOG: Array<{
|
||||
type: PipelinePhaseType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
defaultConfig: () => PipelinePhase['config'];
|
||||
}> = [
|
||||
{
|
||||
type: 'preflight',
|
||||
label: 'Pre-flight',
|
||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
description: 'Verify image digests, check tag drift, validate prerequisites',
|
||||
defaultConfig: () => ({ verifyDigests: true, checkTagDrift: true } satisfies PreflightConfig),
|
||||
},
|
||||
{
|
||||
type: 'gate',
|
||||
label: 'Gate Check',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
description: 'Evaluate security, compliance, and quality policy gates',
|
||||
defaultConfig: () => ({ gates: [
|
||||
{ id: 'g-security', name: 'Security', type: 'security', required: true },
|
||||
{ id: 'g-compliance', name: 'Compliance', type: 'compliance', required: true },
|
||||
{ id: 'g-quality', name: 'Quality', type: 'quality', required: false },
|
||||
]} satisfies GateConfig),
|
||||
},
|
||||
{
|
||||
type: 'approval',
|
||||
label: 'Approval',
|
||||
icon: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z',
|
||||
description: 'Require human approval before proceeding',
|
||||
defaultConfig: () => ({ roles: ['release-approver'], requiredApprovals: 2, timeoutHours: 48 } satisfies ApprovalConfig),
|
||||
},
|
||||
{
|
||||
type: 'deploy',
|
||||
label: 'Deploy',
|
||||
icon: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12',
|
||||
description: 'Execute deployment using the configured strategy',
|
||||
defaultConfig: () => ({
|
||||
strategy: 'rolling',
|
||||
config: { batchSize: 1, batchSizeType: 'count', batchDelay: 0, stabilizationTime: 30, maxFailedBatches: 0, healthCheckType: 'http' },
|
||||
} satisfies DeployConfig),
|
||||
},
|
||||
{
|
||||
type: 'test',
|
||||
label: 'Test',
|
||||
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
|
||||
description: 'Run smoke tests, health checks, or collect canary metrics',
|
||||
defaultConfig: () => ({ testType: 'smoke', timeoutSeconds: 300, retries: 0 } satisfies TestConfig),
|
||||
},
|
||||
{
|
||||
type: 'seal',
|
||||
label: 'Evidence Seal',
|
||||
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
|
||||
description: 'Generate and sign attestation evidence for the release',
|
||||
defaultConfig: () => ({ signEvidence: true, exportFormat: 'stella' } satisfies SealConfig),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Default pipeline templates ──
|
||||
|
||||
export function createDefaultPipeline(releaseType: 'standard' | 'hotfix', strategy: DeploymentStrategy): ReleasePipeline {
|
||||
const phases: PipelinePhase[] = [
|
||||
{
|
||||
id: 'ph-preflight',
|
||||
type: 'preflight',
|
||||
label: 'Pre-flight',
|
||||
enabled: true,
|
||||
config: { verifyDigests: true, checkTagDrift: true },
|
||||
},
|
||||
{
|
||||
id: 'ph-gate',
|
||||
type: 'gate',
|
||||
label: 'Gate Check',
|
||||
enabled: true,
|
||||
config: {
|
||||
gates: [
|
||||
{ id: 'g-security', name: 'Security', type: 'security', required: true },
|
||||
{ id: 'g-compliance', name: 'Compliance', type: 'compliance', required: true },
|
||||
{ id: 'g-quality', name: 'Quality', type: 'quality', required: releaseType === 'standard' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Hotfix skips approval
|
||||
if (releaseType === 'standard') {
|
||||
phases.push({
|
||||
id: 'ph-approval',
|
||||
type: 'approval',
|
||||
label: 'Approval',
|
||||
enabled: true,
|
||||
config: { roles: ['release-approver'], requiredApprovals: 2, timeoutHours: 48 },
|
||||
});
|
||||
}
|
||||
|
||||
phases.push({
|
||||
id: 'ph-deploy',
|
||||
type: 'deploy',
|
||||
label: 'Deploy',
|
||||
enabled: true,
|
||||
config: createDefaultDeployConfig(strategy),
|
||||
fallback: { behavior: 'rollback', autoRollback: true, notifyOnFallback: true },
|
||||
});
|
||||
|
||||
phases.push({
|
||||
id: 'ph-test',
|
||||
type: 'test',
|
||||
label: 'Smoke Test',
|
||||
enabled: true,
|
||||
config: { testType: 'smoke', timeoutSeconds: 300, retries: 1 },
|
||||
fallback: { behavior: 'pause', autoRollback: false, notifyOnFallback: true },
|
||||
});
|
||||
|
||||
phases.push({
|
||||
id: 'ph-seal',
|
||||
type: 'seal',
|
||||
label: 'Evidence Seal',
|
||||
enabled: true,
|
||||
config: { signEvidence: true, exportFormat: 'stella' },
|
||||
});
|
||||
|
||||
return {
|
||||
phases,
|
||||
defaultFallback: { behavior: 'rollback', autoRollback: true, notifyOnFallback: true },
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultDeployConfig(strategy: DeploymentStrategy): DeployConfig {
|
||||
switch (strategy) {
|
||||
case 'rolling':
|
||||
return { strategy: 'rolling', config: { batchSize: 1, batchSizeType: 'count', batchDelay: 0, stabilizationTime: 30, maxFailedBatches: 0, healthCheckType: 'http' } };
|
||||
case 'canary':
|
||||
return { strategy: 'canary', config: { stages: [{ trafficPercent: 5, durationMinutes: 10, healthThreshold: 99 }, { trafficPercent: 25, durationMinutes: 10, healthThreshold: 99 }, { trafficPercent: 50, durationMinutes: 10, healthThreshold: 95 }], autoProgression: true, errorRateThreshold: 5, latencyThresholdMs: 500 } };
|
||||
case 'blue_green':
|
||||
return { strategy: 'blue_green', config: { switchoverMode: 'instant', gradualStages: [{ trafficPercent: 25 }, { trafficPercent: 50 }, { trafficPercent: 100 }], warmupPeriodSeconds: 60, blueKeepaliveMinutes: 30, validationCommand: '' } };
|
||||
case 'recreate':
|
||||
return { strategy: 'recreate', config: { maxConcurrency: 0, failureBehavior: 'rollback', healthCheckTimeoutSeconds: 120 } };
|
||||
case 'ab-release':
|
||||
return { strategy: 'ab-release', config: { subType: 'target-group', stages: [{ name: 'Canary', aPercent: 100, bPercent: 10, durationMinutes: 15, healthThreshold: 99, requiresApproval: false }, { name: 'Full', aPercent: 0, bPercent: 100, durationMinutes: 0, healthThreshold: 99, requiresApproval: false }], metricsCollectionHours: 24 } };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user