diff --git a/docs/usage.md b/docs/usage.md
index bc651ee6..7c40868a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -97,6 +97,61 @@ requires explicitly choosing **Latest preview** or **Latest tagged** before
saving or creating another snapshot; it never silently includes staged content.
Historical draft-only provenance retains its original meaning.
+Virtual-track history offers **Drafts only**, **Releases only**, and **All
+releases** (the default). All releases includes both drafts and tagged releases;
+Releases only never inserts a current draft into the results. The paginator loads
+25 entries per page, newest first.
+
+The smaller summary below the selector shows **Tagged releases**, **Drafts**, and
+**Total** for the selected view across all matching pages, not just the visible
+page. For example, a track with two tags and three drafts reports `2 / 3 / 5` in
+All releases, `2 / 0 / 2` in Releases only, and `0 / 3 / 3` in Drafts only.
+Zero-result views still show all three counters.
+
+History refreshes automatically every 30 seconds while the Releases tab is
+visible, and immediately when entering that tab or returning browser focus or
+visibility. This picks up cron-created snapshots and other users' changes
+without a Refresh history button. Refresh pauses while editing, while a dialog
+is open, or while an operation/request is in flight. It preserves the selected
+filter, page and scroll position; if cleanup removes the last page, it moves to
+the last remaining page. Local changes still refresh immediately.
+
+Automatic refresh reads lightweight history and cleanup status rather than
+reloading full snapshot contents or configuration. The **LATEST** pill and
+latest-only actions use unfiltered server identities, so an older filtered page
+is not mistaken for the current snapshot.
+
+**Create Draft** offers administrators **Delete older drafts after creating this
+draft**, off by default every time the dialog opens. A positive whole-number
+limit applies to that request only; it is never saved or inherited from a
+schedule.
+
+To configure persistent retention, click **Edit Config** and select **Recurring**
+schedule mode. **Recurring draft retention** is read-only outside edit mode;
+administrators can change its limit while editing. **Cancel** restores the saved
+policy. **Save Config** sends schedule-only changes without creating a content
+draft or deleting anything immediately. Actual cron runs apply this policy;
+manual creation and dated schedules do not. The former global policy has been
+removed, so configure the recurring policy explicitly.
+
+Both policies count all untagged snapshots across the track, regardless of how
+they were created. Tagged releases and protected sources survive. Configuration,
+metadata, composition and quarantine edits never trigger retention.
+
+The virtual release preview offers administrators an unchecked **Delete earlier
+drafts after tagging** option. It shows eligible/protected counts and the strict
+interval since the preceding release (or track creation). This permanently
+deletes history, not content; the selected release and newer snapshots survive.
+If the reviewed deletion set changes, fetch a fresh preview.
+
+If a release commits but cleanup fails, the page says so and exposes **Retry
+cleanup**. This repairs the existing operation without tagging again. Pending
+operations are rediscovered when reopening the track. Completed cleanup appears
+as a floating notification with an **X** dismiss button; dismissal survives
+ordinary history refreshes. Pending/failed operations retain their repair
+controls. Large cleanups can need more than one retry. If a draft is removed
+while you are viewing it, the automatic history refresh lets you select a surviving snapshot.
+
The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles.
The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. For virtual tracks, each snapshot card shows its own Composition Resolution provenance: the exact component track snapshot, tagged version, snapshot creation timestamp, resolution strategy and filters, and source/filter/contribution counts used for that materialization. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. A standard release preserves its exact pre-release draft, which remains hidden while the release exists. Administrators can convert the most recent tagged release back to a draft from the Releases tab by confirming its version. Standard tracks restore the preserved pre-release draft; virtual tracks retain the same snapshot and composition provenance while removing its tag and publication metadata. Conversion is blocked when any downstream virtual snapshot resolved that release. Tagged releases cannot be deleted directly. Editors can separately delete the current draft, provided it is not the track's only snapshot, a preserved source of a tagged release, or a resolved component of a downstream virtual snapshot. Administrators can also correct a tagged snapshot's version from its card when the replacement remains valid between adjacent releases. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. Virtual-track schedules are configured in the Config tab as manual, recurring, or specific dates; recurring schedules use guided cadence/day/time controls that generate a five-field UTC cron expression, and specific dates use controlled future UTC date and time inputs. Scheduled drafts run only when the connected REST API has its global scheduler enabled. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent tagged release offers Convert to draft, and only the current draft offers Delete draft, a progress bar with a status message appears under the page header while a long operation runs, and deleting an entire track lives in the danger zone at the bottom of the Config tab.
diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts
index 58315368..88fdf355 100644
--- a/src/app/classes/release-tracks/api.ts
+++ b/src/app/classes/release-tracks/api.ts
@@ -6,7 +6,11 @@ import {
type ExportFormatType,
type ReleasePreviewFormatType,
} from './enums';
-import type { SnapshotSchedule } from './release-track';
+import type {
+ DraftRetention,
+ DraftSquashPreview,
+ SnapshotSchedule,
+} from './release-track';
import type { SnapshotCreationCause } from './snapshot-creation-cause';
import type { SnapshotCreationActor } from './snapshot-creation-actor';
@@ -22,6 +26,11 @@ export interface CreateReleaseTrackPayload {
snapshot_schedule?: SnapshotSchedule;
}
+export interface CreateVirtualSnapshotPayload {
+ description?: string;
+ draft_retention?: DraftRetention | null;
+}
+
export interface StixBundlePayload {
type: 'bundle';
id?: string;
@@ -43,7 +52,11 @@ export type ReleasePayload = (
| { increment: 'major' | 'minor'; version?: never }
| { increment?: never; version: string }
| { increment?: undefined; version?: undefined }
-) & { description?: string };
+) & {
+ description?: string;
+ squash_drafts?: boolean;
+ squash_fingerprint?: string;
+};
export interface RetagReleasePayload {
version: string;
@@ -77,6 +90,22 @@ export interface SnapshotHistoryOptions {
offset?: number;
}
+/** Counts cover the selected tagged filter before pagination. */
+export interface SnapshotHistoryCounts {
+ tagged: number;
+ drafts: number;
+ total: number;
+}
+
+export interface SnapshotHistoryResponse {
+ data: ReleaseTrackSnapshotHistoryItem[];
+ pagination: { total: number; limit: number; offset: number };
+ counts: SnapshotHistoryCounts;
+ /** Unfiltered live identities, even when neither snapshot is on this page. */
+ latest_snapshot_modified: string | null;
+ latest_tagged_snapshot_modified: string | null;
+}
+
export interface SnapshotContentStatistics {
primary_count: number;
secondary_count: number;
@@ -151,6 +180,7 @@ export interface StandardReleasePreviewSummary extends ReleasePreviewSummaryBase
export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase {
type: ReleaseTrackType.Virtual;
+ draft_squash?: DraftSquashPreview;
previous_release: {
version: string;
modified: string;
diff --git a/src/app/classes/release-tracks/release-track.ts b/src/app/classes/release-tracks/release-track.ts
index ee408366..b664ddc0 100644
--- a/src/app/classes/release-tracks/release-track.ts
+++ b/src/app/classes/release-tracks/release-track.ts
@@ -1,4 +1,4 @@
-import { ReleaseTrackType, SnapshotScheduleModeType } from './enums';
+import { ReleaseTrackType } from './enums';
export interface ReleaseTrack {
track_id: string;
@@ -19,8 +19,41 @@ export interface ReleaseTrack {
snapshot_schedule?: SnapshotSchedule;
}
-export interface SnapshotSchedule {
- mode?: SnapshotScheduleModeType;
- cron?: string | null;
- dates?: (Date | string)[];
+export type SnapshotSchedule =
+ | { mode: 'manual'; cron?: never; dates?: never; draft_retention?: never }
+ | {
+ mode: 'dates';
+ dates: (Date | string)[];
+ cron?: never;
+ draft_retention?: never;
+ }
+ | {
+ mode: 'cron';
+ cron: string;
+ dates?: never;
+ draft_retention?: DraftRetention;
+ };
+
+export interface DraftRetention {
+ max_drafts: number | null;
+}
+
+export interface DraftCleanupResult {
+ operation_id: string;
+ status: 'pending' | 'completed' | 'failed';
+ kind: 'retention' | 'squash';
+ eligible_count: number;
+ deleted_count: number;
+ protected_count: number;
+ target_modified?: string;
+ release_committed?: boolean;
+ error?: string;
+}
+
+export interface DraftSquashPreview {
+ lower_bound: string | null;
+ upper_bound: string;
+ eligible_count: number;
+ protected_count: number;
+ fingerprint: string;
}
diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts
index 557794e3..b7a6dde5 100644
--- a/src/app/classes/release-tracks/snapshot.ts
+++ b/src/app/classes/release-tracks/snapshot.ts
@@ -5,7 +5,7 @@ import { ReleaseTrackType } from './enums';
import { SnapshotCreationCause } from './snapshot-creation-cause';
import { SnapshotCreationActor } from './snapshot-creation-actor';
import { VersionHistoryEntry } from './history';
-import { SnapshotSchedule } from './release-track';
+import { DraftCleanupResult, SnapshotSchedule } from './release-track';
import {
CandidateEntry,
MemberEntry,
@@ -49,6 +49,9 @@ export class ReleaseTrackSnapshot {
public composition?: LoadedComposition;
public composition_resolution?: CompositionResolution;
public snapshot_schedule?: SnapshotSchedule;
+ public draft_cleanup?: DraftCleanupResult;
+ public snapshot_count?: number;
+ public tagged_release_count?: number;
constructor(raw?: any) {
if (raw) this.deserialize(raw);
@@ -181,6 +184,10 @@ export class ReleaseTrackSnapshot {
if ('snapshot_schedule' in raw)
this.snapshot_schedule = raw.snapshot_schedule;
+ if ('draft_cleanup' in raw) this.draft_cleanup = raw.draft_cleanup;
+ if ('snapshot_count' in raw) this.snapshot_count = raw.snapshot_count;
+ if ('tagged_release_count' in raw)
+ this.tagged_release_count = raw.tagged_release_count;
if ('composition_resolution' in raw && raw.composition_resolution) {
const cr = raw.composition_resolution;
diff --git a/src/app/components/create-draft-dialog/create-draft-dialog.component.spec.ts b/src/app/components/create-draft-dialog/create-draft-dialog.component.spec.ts
new file mode 100644
index 00000000..41047986
--- /dev/null
+++ b/src/app/components/create-draft-dialog/create-draft-dialog.component.spec.ts
@@ -0,0 +1,64 @@
+import { TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { CreateDraftDialogComponent } from './create-draft-dialog.component';
+
+describe('CreateDraftDialogComponent', () => {
+ const close = vi.fn();
+
+ beforeEach(async () => {
+ close.mockReset();
+ await TestBed.configureTestingModule({
+ imports: [CreateDraftDialogComponent, NoopAnimationsModule],
+ providers: [
+ { provide: MatDialogRef, useValue: { close } },
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ trackName: 'Virtual track',
+ description: 'Existing notes',
+ canManageRetention: true,
+ },
+ },
+ ],
+ }).compileComponents();
+ });
+
+ it('requires a fresh opt-in for every draft, and submits a validated limit only for that draft', () => {
+ const fixture = TestBed.createComponent(CreateDraftDialogComponent);
+ const component = fixture.componentInstance;
+ fixture.detectChanges();
+ component.save();
+ expect(close).toHaveBeenLastCalledWith({ description: 'Existing notes' });
+ close.mockClear();
+ component.retentionEnabled = true;
+ for (const limit of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
+ component.maxDrafts = limit;
+ component.save();
+ }
+ expect(close).not.toHaveBeenCalled();
+ component.maxDrafts = 2;
+ component.save();
+ expect(close).toHaveBeenCalledWith({
+ description: 'Existing notes',
+ draft_retention: { max_drafts: 2 },
+ });
+ fixture.destroy();
+ const reopened = TestBed.createComponent(CreateDraftDialogComponent);
+ reopened.componentInstance.save();
+ expect(close).toHaveBeenLastCalledWith({ description: 'Existing notes' });
+ });
+
+ it('does not render or submit a destructive option for non-administrators', () => {
+ TestBed.overrideProvider(MAT_DIALOG_DATA, {
+ useValue: { trackName: 'Virtual track', canManageRetention: false },
+ });
+ const fixture = TestBed.createComponent(CreateDraftDialogComponent);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull();
+ fixture.componentInstance.retentionEnabled = true;
+ fixture.componentInstance.maxDrafts = 1;
+ fixture.componentInstance.save();
+ expect(close).toHaveBeenCalledWith({ description: '' });
+ });
+});
diff --git a/src/app/components/create-draft-dialog/create-draft-dialog.component.ts b/src/app/components/create-draft-dialog/create-draft-dialog.component.ts
new file mode 100644
index 00000000..7e32a795
--- /dev/null
+++ b/src/app/components/create-draft-dialog/create-draft-dialog.component.ts
@@ -0,0 +1,131 @@
+import { CommonModule } from '@angular/common';
+import { Component, Inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogModule,
+ MatDialogRef,
+} from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSlideToggleModule } from '@angular/material/slide-toggle';
+import { CreateVirtualSnapshotPayload } from 'src/app/classes/release-tracks';
+
+@Component({
+ selector: 'app-create-draft-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSlideToggleModule,
+ ],
+ template: `
+
Create draft snapshot
+
+
+ Create a new snapshot for {{ data.trackName }}. Add optional notes for
+ other analysts.
+
+
+ Snapshot notes
+
+
+
+ Delete older drafts after creating this draft
+
+ Off by default. This applies only to this request and does not change
+ or inherit the recurring schedule's policy.
+
+
+
+ Maximum retained drafts
+
+
+
+ Older eligible drafts and their notes will be permanently deleted
+ across this track's entire draft history. Tagged releases, the
+ latest snapshot, and protected source drafts are preserved.
+ Protected drafts may exceed the limit.
+
+
+ Enter a positive safe whole number.
+
+
+
+
+
+ Cancel
+
+ Create draft
+
+
+ `,
+ styles: [
+ 'mat-form-field { display: block; width: 100%; } section { margin-top: 1rem; }',
+ ],
+})
+export class CreateDraftDialogComponent {
+ public description: string;
+ public retentionEnabled = false;
+ public maxDrafts = 10;
+
+ constructor(
+ public dialogRef: MatDialogRef<
+ CreateDraftDialogComponent,
+ CreateVirtualSnapshotPayload
+ >,
+ @Inject(MAT_DIALOG_DATA)
+ public data: {
+ trackName: string;
+ description?: string;
+ canManageRetention: boolean;
+ }
+ ) {
+ this.description = data.description || '';
+ }
+
+ public get isRetentionValid(): boolean {
+ return (
+ !this.data.canManageRetention ||
+ !this.retentionEnabled ||
+ (Number.isSafeInteger(this.maxDrafts) && this.maxDrafts > 0)
+ );
+ }
+
+ public get isInvalid(): boolean {
+ return this.description.length > 4000 || !this.isRetentionValid;
+ }
+
+ public save(): void {
+ if (this.isInvalid) return;
+ const payload: CreateVirtualSnapshotPayload = {
+ description: this.description.trim(),
+ };
+ if (this.data.canManageRetention && this.retentionEnabled) {
+ payload.draft_retention = { max_drafts: this.maxDrafts };
+ }
+ this.dialogRef.close(payload);
+ }
+}
diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.html b/src/app/components/new-track-dialog/new-track-dialog.component.html
index 7bf6ddb8..d93c78cb 100644
--- a/src/app/components/new-track-dialog/new-track-dialog.component.html
+++ b/src/app/components/new-track-dialog/new-track-dialog.component.html
@@ -288,7 +288,7 @@
Mode
{{
- formatOption(opt)
+ opt === 'cron' ? 'Recurring' : formatOption(opt)
}}
Select mode for snapshot scheduling
@@ -305,6 +305,41 @@
Cron expression (UTC) used when mode is 'cron'
+
+ Recurring draft retention
+
+ Limit retained drafts
+
+
+ Disabled by default (unlimited). Applies only after recurring
+ scheduled snapshots, not initial creation or manual drafts. Counts all
+ draft history across the track. Older eligible drafts and their notes
+ are permanently deleted. Tagged releases and protected drafts are
+ preserved; protected drafts may exceed the limit.
+
+
+ Maximum retained drafts
+
+ Positive whole number; suggested starting limit: 10.
+
+
+ Enter a positive safe whole number.
+
+
diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts b/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts
index 16ede3b6..5ceaf9b2 100644
--- a/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts
+++ b/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
import { NewTrackDialogComponent } from './new-track-dialog.component';
import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service';
+import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service';
import {
DeduplicationStrategy,
ReleaseTrackType,
@@ -66,6 +67,10 @@ describe('NewTrackDialogComponent', () => {
useValue: { type: ReleaseTrackType.Virtual },
},
{ provide: ReleaseTracksConnectorService, useValue: mockConnector },
+ {
+ provide: AuthenticationService,
+ useValue: { isAuthorized: vi.fn(() => true) },
+ },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
@@ -75,6 +80,59 @@ describe('NewTrackDialogComponent', () => {
component.ngOnInit();
});
+ it('rejects unsafe draft limits before creating a retained virtual track', () => {
+ component.form.patchValue({
+ name: 'Retained virtual track',
+ retentionEnabled: true,
+ snapshotSchedule: { mode: SnapshotScheduleMode.Cron, cron: '0 * * * *' },
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+ for (const maxDrafts of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
+ component.form.patchValue({ maxDrafts });
+ component.handleCreate();
+ }
+ expect(mockConnector.createReleaseTrack).not.toHaveBeenCalled();
+
+ component.form.patchValue({ maxDrafts: 1 });
+ component.handleCreate();
+ expect(
+ mockConnector.createReleaseTrack.mock.calls[0][0].snapshot_schedule
+ .draft_retention
+ ).toEqual({ max_drafts: 1 });
+ });
+
+ it('does not submit destructive retention settings for non-administrators', () => {
+ component.form.patchValue({
+ name: 'Unretained virtual track',
+ retentionEnabled: true,
+ maxDrafts: 10,
+ snapshotSchedule: { mode: SnapshotScheduleMode.Cron, cron: '0 * * * *' },
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+ vi.mocked(
+ TestBed.inject(AuthenticationService).isAuthorized
+ ).mockReturnValue(false);
+ component.handleCreate();
+ expect(
+ mockConnector.createReleaseTrack.mock.calls[0][0].snapshot_schedule
+ .draft_retention
+ ).toBeUndefined();
+ });
+
+ it('drops recurring retention when creating a manual track', () => {
+ component.form.patchValue({
+ name: 'Manual virtual track',
+ retentionEnabled: true,
+ maxDrafts: 0,
+ snapshotSchedule: { mode: SnapshotScheduleMode.Manual },
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+ component.handleCreate();
+ const payload = mockConnector.createReleaseTrack.mock.calls[0][0];
+ expect(payload.snapshot_schedule).toEqual({ mode: 'manual' });
+ expect(payload.draft_retention).toBeUndefined();
+ });
+
it('should list standard tracks even when they have no tagged snapshots', () => {
expect(mockConnector.listReleaseTracks).toHaveBeenCalledWith();
expect(component.componentTrackOptions).toEqual([
diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.ts b/src/app/components/new-track-dialog/new-track-dialog.component.ts
index 0535eeb9..78a777fc 100644
--- a/src/app/components/new-track-dialog/new-track-dialog.component.ts
+++ b/src/app/components/new-track-dialog/new-track-dialog.component.ts
@@ -26,6 +26,8 @@ import {
StixTypeToAttackType,
} from 'src/app/utils/type-mappings';
import { finalize, take } from 'rxjs/operators';
+import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service';
+import { Role } from 'src/app/classes/authn/role';
const OBJECT_FILTER_OPTIONS: StixType[] = [
'attack-pattern',
@@ -89,16 +91,34 @@ export class NewTrackDialogComponent implements OnInit {
return this.mode === ReleaseTrackType.Virtual;
}
+ public get canConfigureRetention(): boolean {
+ return this.authenticationService.isAuthorized([Role.ADMIN]);
+ }
+
+ public get isRetentionValid(): boolean {
+ const count = this.form.get('maxDrafts')?.value;
+ return (
+ !this.canConfigureRetention ||
+ this.form.get('snapshotSchedule.mode')?.value !==
+ SnapshotScheduleMode.Cron ||
+ !this.form.get('retentionEnabled')?.value ||
+ (Number.isSafeInteger(count) && count > 0)
+ );
+ }
+
constructor(
public dialogRef: MatDialogRef,
@Inject(MAT_DIALOG_DATA) public data: any,
private fb: FormBuilder,
- private connector: ReleaseTracksConnectorService
+ private connector: ReleaseTracksConnectorService,
+ private authenticationService: AuthenticationService
) {
this.form = this.fb.group({
name: ['', [Validators.required]],
description: [''],
snapshotDescription: ['', [Validators.maxLength(4000)]],
+ retentionEnabled: [false],
+ maxDrafts: [10],
autoPromote: [false],
candidacyThreshold: [{ value: WorkflowStatus.Reviewed, disabled: true }],
memberSync: [MemberSyncStrategy.TrackLatest],
@@ -148,7 +168,10 @@ export class NewTrackDialogComponent implements OnInit {
if (this.isVirtual) {
const tracks = this.selectedComponentTracks;
return (
- nameValid && tracks.length > 0 && hasValidComponentPriorities(tracks)
+ nameValid &&
+ tracks.length > 0 &&
+ hasValidComponentPriorities(tracks) &&
+ this.isRetentionValid
);
}
return this.form.valid && nameValid;
@@ -326,6 +349,15 @@ export class NewTrackDialogComponent implements OnInit {
const payload: any = { mode };
if (mode === SnapshotScheduleMode.Cron && cron) payload.cron = cron;
+ if (
+ mode === SnapshotScheduleMode.Cron &&
+ this.canConfigureRetention &&
+ this.form.get('retentionEnabled')?.value
+ ) {
+ payload.draft_retention = {
+ max_drafts: this.form.get('maxDrafts')?.value,
+ };
+ }
return payload;
}
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.html b/src/app/components/release-preview-dialog/release-preview-dialog.component.html
index 5f5d300f..93d6060a 100644
--- a/src/app/components/release-preview-dialog/release-preview-dialog.component.html
+++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html
@@ -123,6 +123,27 @@
{{ snapshotDescription.length }}/{{ snapshotDescriptionMaxLength }}
+
+
+
+ Delete earlier drafts after tagging
+
+
+ {{ squash.eligible_count }} eligible drafts;
+ {{ squash.protected_count }}
+ protected drafts will be preserved. Only drafts strictly
+
+ after {{ squash.lower_bound }} and
+
+ before {{ squash.upper_bound }} will be deleted.
+ since track creation and
+
+
+ This permanently deletes earlier draft history, including notes and
+ quarantine choices. It does not merge content. The selected snapshot,
+ all releases, and newer snapshots are preserved.
+
+
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss
index e9f532a5..e9d9080a 100644
--- a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss
+++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss
@@ -456,3 +456,18 @@
flex-shrink: 0;
}
}
+
+.release-draft-squash {
+ margin: 1.5rem 0 0;
+ padding: 1rem;
+ border: 1px solid currentColor;
+ border-radius: 4px;
+
+ code {
+ overflow-wrap: anywhere;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts
index 11e46630..a78cd910 100644
--- a/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts
+++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.spec.ts
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
@@ -65,6 +66,7 @@ describe('ReleasePreviewDialogComponent', () => {
CommonModule,
FormsModule,
MatButtonModule,
+ MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
@@ -82,6 +84,58 @@ describe('ReleasePreviewDialogComponent', () => {
fixture.detectChanges();
});
+ it('requires fresh explicit admin consent before sending a virtual squash fingerprint', () => {
+ data.canSquashDrafts = true;
+ data.previewSummary = {
+ type: 'virtual',
+ draft_squash: {
+ lower_bound: '2026-09-01T00:00:00.000Z',
+ upper_bound: '2026-09-20T00:00:00.000Z',
+ eligible_count: 5,
+ protected_count: 2,
+ fingerprint: 'reviewed-interval',
+ },
+ };
+ fixture.detectChanges();
+ const checkbox: HTMLInputElement = fixture.nativeElement.querySelector(
+ '.release-draft-squash input'
+ );
+ expect(checkbox.checked).toBe(false);
+ component.tagVersion('minor');
+ expect(dialogRef.close).toHaveBeenLastCalledWith({
+ increment: 'minor',
+ description: '',
+ });
+
+ checkbox.click();
+ fixture.detectChanges();
+ component.exactVersion = '2.1';
+ component.tagExactVersion();
+ expect(dialogRef.close).toHaveBeenLastCalledWith({
+ version: '2.1',
+ description: '',
+ squash_drafts: true,
+ squash_fingerprint: 'reviewed-interval',
+ });
+
+ const reopened = new ReleasePreviewDialogComponent(dialogRef, data);
+ reopened.tagVersion('minor');
+ expect(dialogRef.close).toHaveBeenLastCalledWith({
+ increment: 'minor',
+ description: '',
+ });
+ data.canSquashDrafts = false;
+ component.tagVersion('minor');
+ expect(dialogRef.close).toHaveBeenLastCalledWith({
+ increment: 'minor',
+ description: '',
+ });
+ fixture.detectChanges();
+ expect(
+ fixture.nativeElement.querySelector('.release-draft-squash')
+ ).toBeNull();
+ });
+
it('should calculate the draft snapshot summary from track contents', () => {
expect(component.includedObjects).toHaveLength(2);
expect(component.newObjectCount).toBe(1);
diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts
index 59139383..81ce3cf8 100644
--- a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts
+++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts
@@ -2,18 +2,24 @@ import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { VersionNumber } from 'src/app/classes/version-number';
+import { DraftSquashPreview } from 'src/app/classes/release-tracks';
export interface ReleasePreviewDialogData {
track: any;
conflicts?: any[];
proposedMinorVersion?: string;
previewSummary?: any;
+ canSquashDrafts?: boolean;
}
export type ReleasePreviewSelection = (
| { increment: 'minor' | 'major'; version?: never }
| { increment?: never; version: string }
-) & { description: string };
+) & {
+ description: string;
+ squash_drafts?: boolean;
+ squash_fingerprint?: string;
+};
interface ReleaseTrackObject {
object_ref?: string;
@@ -46,6 +52,25 @@ export class ReleasePreviewDialogComponent {
public readonly snapshotDescriptionMaxLength = 4000;
public snapshotDescription: string;
public exactVersion = '';
+ public squashDrafts = false;
+
+ public get draftSquash(): DraftSquashPreview | null {
+ return this.isVirtualTrack && this.data.canSquashDrafts
+ ? (this.data.previewSummary?.draft_squash ?? null)
+ : null;
+ }
+
+ private get squashSelection(): {
+ squash_drafts?: boolean;
+ squash_fingerprint?: string;
+ } {
+ return this.squashDrafts && this.draftSquash
+ ? {
+ squash_drafts: true,
+ squash_fingerprint: this.draftSquash.fingerprint,
+ }
+ : {};
+ }
constructor(
public dialogRef: MatDialogRef,
@@ -227,6 +252,7 @@ export class ReleasePreviewDialogComponent {
this.dialogRef.close({
increment: type,
description: this.snapshotDescription.trim(),
+ ...this.squashSelection,
} satisfies ReleasePreviewSelection);
}
@@ -266,6 +292,7 @@ export class ReleasePreviewDialogComponent {
this.dialogRef.close({
version: this.normalizedExactVersion,
description: this.snapshotDescription.trim(),
+ ...this.squashSelection,
} satisfies ReleasePreviewSelection);
}
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html
index 9a967f45..34ae8c85 100644
--- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html
+++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html
@@ -1,4 +1,8 @@
-
+
{{ detailsLabel | uppercase }}
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.spec.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.spec.ts
new file mode 100644
index 00000000..c9bda968
--- /dev/null
+++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.spec.ts
@@ -0,0 +1,58 @@
+import { CommonModule } from '@angular/common';
+import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { MatTabsModule } from '@angular/material/tabs';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { StixPageTabsComponent } from './stix-page-tabs.component';
+
+@Component({
+ standalone: false,
+ template: `
+
+ Board contents
+ Release contents
+ `,
+})
+class TabsHostComponent {
+ selectedIndex = 0;
+}
+
+describe('StixPageTabsComponent', () => {
+ it('notifies its page when the user enters and leaves a custom tab', async () => {
+ await TestBed.configureTestingModule({
+ declarations: [TabsHostComponent, StixPageTabsComponent],
+ imports: [CommonModule, MatTabsModule, NoopAnimationsModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+ const fixture = TestBed.createComponent(TabsHostComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]');
+ tabs[1].click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.selectedIndex).toBe(1);
+ expect(
+ fixture.nativeElement.querySelector(
+ '[role="tabpanel"].mat-mdc-tab-body-active'
+ ).textContent
+ ).toContain('Release contents');
+ tabs[0].click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.selectedIndex).toBe(0);
+ expect(
+ fixture.nativeElement.querySelector(
+ '[role="tabpanel"].mat-mdc-tab-body-active'
+ ).textContent
+ ).toContain('Board contents');
+ fixture.destroy();
+ });
+});
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts
index d3fb775c..451c2133 100644
--- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts
+++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts
@@ -1,5 +1,11 @@
import { ViewEncapsulation } from '@angular/core';
-import { Component, Input, TemplateRef } from '@angular/core';
+import {
+ Component,
+ EventEmitter,
+ Input,
+ Output,
+ TemplateRef,
+} from '@angular/core';
import { StixViewConfig } from 'src/app/views/stix/stix-view-page';
interface CustomTab {
@@ -23,4 +29,5 @@ export class StixPageTabsComponent {
@Input() showHistory = true;
@Input() showNotes = true;
@Input() showMembership = true;
+ @Output() selectedIndexChange = new EventEmitter();
}
diff --git a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
index f2c910d2..8f82753e 100644
--- a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
+++ b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
@@ -95,6 +95,9 @@ describe('ReleaseTracksConnectorService', () => {
limit: 200,
offset: 0,
},
+ counts: { tagged: 0, drafts: 1, total: 1 },
+ latest_snapshot_modified: '2024-05-21T07:00:00.000Z',
+ latest_tagged_snapshot_modified: null,
};
http.get.mockReturnValue(of(response));
diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts
index 699fafaa..e0aeec02 100644
--- a/src/app/services/connectors/rest-api/release-tracks.service.ts
+++ b/src/app/services/connectors/rest-api/release-tracks.service.ts
@@ -14,6 +14,8 @@ import type {
ClonePayload,
Composition,
CreateReleaseTrackPayload,
+ DraftCleanupResult,
+ CreateVirtualSnapshotPayload,
ExportFormatType,
PromoteQuarantinePayload,
ReleasePayload,
@@ -24,6 +26,7 @@ import type {
ReleaseTrackSnapshotOptions,
ReviewPayload,
SnapshotHistoryOptions,
+ SnapshotHistoryResponse,
SnapshotSchedule,
StixBundlePayload,
StixObjectRef,
@@ -43,6 +46,7 @@ export type {
ReleaseTrackSnapshotOptions,
ReviewPayload,
SnapshotHistoryOptions,
+ SnapshotHistoryResponse,
StixBundlePayload,
StixObjectRef,
UpdateMetadataPayload,
@@ -223,7 +227,7 @@ export class ReleaseTracksConnectorService extends ApiConnector {
public listSnapshots(
id: string,
options: SnapshotHistoryOptions = {}
- ): Observable> {
+ ): Observable {
const params = this.buildHttpParams({
limit: 200,
offset: 0,
@@ -231,16 +235,14 @@ export class ReleaseTracksConnectorService extends ApiConnector {
});
const url = `${this.apiUrl}/release-tracks/${id}/snapshots`;
return this.http
- .get>(url, {
+ .get(url, {
params,
})
.pipe(
tap(result =>
logger.log(`retrieved snapshots for track ${id}`, result)
),
- catchError(
- this.handleError_raise>()
- ),
+ catchError(this.handleError_raise()),
share()
);
}
@@ -471,7 +473,7 @@ export class ReleaseTracksConnectorService extends ApiConnector {
const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/release`;
return this.http.post(url, body).pipe(
tap(result => logger.log(`released snapshot ${modified}`, result)),
- catchError(this.handleError_raise()),
+ catchError(this.handleError_raise(false)),
share()
);
}
@@ -553,7 +555,7 @@ export class ReleaseTracksConnectorService extends ApiConnector {
*/
public createVirtualSnapshot(
id: string,
- body?: { description?: string }
+ body?: CreateVirtualSnapshotPayload
): Observable {
const url = `${this.apiUrl}/release-tracks/${id}/virtual/snapshots/create`;
return this.http.post(url, body || {}).pipe(
@@ -845,6 +847,33 @@ export class ReleaseTracksConnectorService extends ApiConnector {
);
}
+ public listDraftCleanup(
+ id: string
+ ): Observable<{ data: DraftCleanupResult[] }> {
+ const url = `${this.apiUrl}/release-tracks/${id}/virtual/draft-cleanup`;
+ return this.http
+ .get<{ data: DraftCleanupResult[] }>(url)
+ .pipe(
+ catchError(
+ this.handleError_raise<{ data: DraftCleanupResult[] }>(false)
+ ),
+ share()
+ );
+ }
+
+ public retryDraftCleanup(
+ id: string,
+ operationId: string
+ ): Observable {
+ const url = `${this.apiUrl}/release-tracks/${id}/virtual/draft-cleanup/${encodeURIComponent(operationId)}/retry`;
+ return this.http
+ .post(url, {})
+ .pipe(
+ catchError(this.handleError_raise(false)),
+ share()
+ );
+ }
+
// -----------------------------------------------------------------------------
// Version Management
// -----------------------------------------------------------------------------
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
index d46842f4..fca1ec6e 100644
--- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
+++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
@@ -1,3 +1,19 @@
+
+
+
+ Draft cleanup completed: {{ operation.deleted_count }} drafts deleted;
+ {{ operation.protected_count }} protected drafts and all tagged releases
+ preserved.
+
+
+ close
+
+
+
+
+ {{ cleanupMessage }}
+
+
+
{{
+ operation.kind === 'squash' ? 'Release-time' : 'Retention'
+ }}
+ cleanup: {{ operation.status }}
+
+ {{ operation.deleted_count }} deleted of
+ {{ operation.eligible_count }} eligible drafts;
+ {{ operation.protected_count }} protected drafts preserved. Releases
+ are preserved.
+
+
+ Release committed. Do not tag again to repair cleanup.
+
+
{{ operation.error }}
+
Operation: {{ operation.operation_id }}
+
+
+ {{
+ retryingCleanup.has(operation.operation_id)
+ ? 'Repairing cleanup'
+ : 'Retry cleanup'
+ }}
+
+
+
[showHistory]="false"
[showNotes]="false"
[showMembership]="false"
+ (selectedIndexChange)="onSelectedTabChange($event)"
[customTabs]="[
{ label: 'Releases', template: historyTpl },
{ label: 'Config', template: configTpl },
@@ -550,6 +611,23 @@ {{ getVirtualObjectTitle(item) }}
+
+
+ History view
+
+ Drafts only
+ Releases only
+ All releases
+
+
+
+
+ {{ historyMessage }}
+
@@ -559,20 +637,22 @@
{{ getVirtualObjectTitle(item) }}
- No tagged releases or current draft snapshot
+ {{
+ isVirtualReleaseTrack
+ ? 'No snapshots in this history view'
+ : 'No tagged releases or current draft snapshot'
+ }}
-
-
Tagged releases: {{ taggedSnapshotCount }}
-
Current draft:
- {{ hasCurrentDraftSnapshot ? 'available' : 'none' }}
+
+ Tagged releases: {{ historyCounts.tagged }}
+ Drafts: {{ historyCounts.drafts }}
+ Total: {{ historyCounts.total }}
+
+
Unknown
@@ -1574,6 +1664,60 @@
Snapshot Schedule
getVirtualScheduleValue('cron')
}}
+
+
Recurring draft retention
+
+ Applies only after scheduled recurring snapshots. Counts all
+ drafts across this track, regardless of how they were
+ created. Older eligible drafts and their notes are
+ permanently deleted; tagged releases and protected drafts
+ are preserved. Protected drafts may exceed the limit. Saving
+ the policy creates no snapshot and deletes nothing
+ immediately.
+
+
+
+ Limit retained drafts
+
+
+ Maximum retained drafts
+
+ Positive whole number; suggested: 10.
+
+
+ Enter a positive safe whole number.
+
+
+
+
+ {{
+ (virtualSnapshotSchedule.draft_retention?.max_drafts ??
+ null) === null
+ ? 'Disabled (unlimited drafts)'
+ : 'Keep newest ' +
+ virtualSnapshotSchedule.draft_retention.max_drafts +
+ ' drafts'
+ }}
+
+
+ Only application administrators can change draft
+ retention.
+
+
+
+> & {
+ pagination?: Partial;
+};
+
+function historyResponse(
+ input: ReleaseTrackSnapshotHistoryItem[] | SnapshotHistoryFixture
+): SnapshotHistoryResponse {
+ const response: SnapshotHistoryFixture = Array.isArray(input)
+ ? { data: input }
+ : input;
+ const data = response.data ?? [];
+ const modified = (item: ReleaseTrackSnapshotHistoryItem) => {
+ const value = item.modified ?? item.snapshot_id ?? item.stix?.modified;
+ return value instanceof Date
+ ? value.toISOString()
+ : value
+ ? new Date(value).toISOString()
+ : '';
+ };
+ const sorted = [...data].sort((a, b) =>
+ modified(b).localeCompare(modified(a))
+ );
+ const tagged = data.filter(item => !!item.version).length;
+ return {
+ data,
+ ...response,
+ pagination: {
+ total: data.length,
+ limit: 25,
+ offset: 0,
+ ...response.pagination,
+ },
+ counts: response.counts ?? {
+ tagged,
+ drafts: data.length - tagged,
+ total: data.length,
+ },
+ latest_snapshot_modified:
+ response.latest_snapshot_modified !== undefined
+ ? response.latest_snapshot_modified
+ : sorted.length
+ ? modified(sorted[0])
+ : null,
+ latest_tagged_snapshot_modified:
+ response.latest_tagged_snapshot_modified !== undefined
+ ? response.latest_tagged_snapshot_modified
+ : sorted.some(item => !!item.version)
+ ? modified(sorted.find(item => !!item.version)!)
+ : null,
+ };
+}
+
describe('ReleaseTrackPageComponent', () => {
it('formats creator names without attributing automated or historical snapshots to a user', () => {
const name = ReleaseTrackPageComponent.prototype.getSnapshotCreatorName;
@@ -64,17 +120,14 @@ describe('ReleaseTrackPageComponent', () => {
let mockAuthenticationService: any;
let mockSnackbar: any;
let mockClipboard: any;
+ let routeParams: Subject>;
beforeEach(async () => {
+ routeParams = new Subject>();
mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({
getLatestSnapshot: vi.fn(() => createAsyncObservable(null)),
listReleaseTracks: vi.fn(() => createAsyncObservable({ data: [] })),
- listSnapshots: vi.fn(() =>
- createAsyncObservable({
- data: [],
- pagination: { total: 0, limit: 50, offset: 0 },
- })
- ),
+ listSnapshots: vi.fn(() => createAsyncObservable(historyResponse([]))),
exportLatestSnapshot: vi.fn(() => createAsyncObservable({})),
exportSnapshotByModified: vi.fn(() => createAsyncObservable({})),
retrieveSnapshotByModified: vi.fn(() => createAsyncObservable(null)),
@@ -96,12 +149,17 @@ describe('ReleaseTrackPageComponent', () => {
deleteReleaseTrack: vi.fn(() => createAsyncObservable({})),
deleteSnapshotByModified: vi.fn(() => createAsyncObservable(undefined)),
convertReleaseToDraft: vi.fn(() => createAsyncObservable({})),
+ listDraftCleanup: vi.fn(() => of({ data: [] })),
+ retryDraftCleanup: vi.fn(() => of({})),
});
mockDialog = {
open: vi.fn(),
+ openDialogs: [],
};
mockSnackbar = {
open: vi.fn(),
+ openFromTemplate: vi.fn(),
+ dismiss: vi.fn(),
};
mockClipboard = {
copy: vi.fn(() => true),
@@ -167,7 +225,7 @@ describe('ReleaseTrackPageComponent', () => {
{
provide: ActivatedRoute,
useValue: {
- params: of({}),
+ params: routeParams,
queryParams: of({}),
snapshot: {},
},
@@ -181,17 +239,966 @@ describe('ReleaseTrackPageComponent', () => {
fixture.detectChanges();
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ describe('automatic release history', () => {
+ const latest = '2026-09-20T00:00:00.000Z';
+ const released = '2026-09-19T00:00:00.000Z';
+ const draft = {
+ type: ReleaseTrackType.Virtual,
+ modified: latest,
+ version: null,
+ };
+ const release = {
+ type: ReleaseTrackType.Virtual,
+ modified: released,
+ version: '1.0',
+ };
+
+ beforeEach(() => {
+ fixture.destroy();
+ vi.useFakeTimers();
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
+ fixture = TestBed.createComponent(ReleaseTrackPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ component.id = 'release-track--virtual';
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ ...draft,
+ id: component.id,
+ snapshot_count: 999,
+ tagged_release_count: 999,
+ });
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([]))
+ );
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it.each([
+ {
+ filter: 'all' as const,
+ tagged: undefined,
+ data: [release, draft],
+ counts: { tagged: 26, drafts: 78, total: 104 },
+ },
+ {
+ filter: 'drafts' as const,
+ tagged: false,
+ data: [draft],
+ counts: { tagged: 0, drafts: 78, total: 78 },
+ },
+ {
+ filter: 'releases' as const,
+ tagged: true,
+ data: [release],
+ counts: { tagged: 26, drafts: 0, total: 26 },
+ },
+ ])(
+ 'uses exact $filter filtering and query-wide counts on later pages',
+ ({ filter, tagged, data, counts }) => {
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data,
+ counts,
+ pagination: { total: counts.total, offset: 25 },
+ latest_snapshot_modified: latest,
+ latest_tagged_snapshot_modified: released,
+ })
+ )
+ );
+ if (filter !== 'all') component.onHistoryFilterChange(filter);
+ component.onHistoryPageChange({
+ pageIndex: 1,
+ pageSize: 25,
+ length: counts.total,
+ });
+ expect(
+ mockReleaseTrackApiConnector.listSnapshots
+ ).toHaveBeenLastCalledWith(component.id, {
+ tagged,
+ limit: 25,
+ offset: 25,
+ });
+ expect(component.historyCounts).toEqual(counts);
+ expect(component.historyTotal).toBe(counts.total);
+ expect(component.snapshotHistory.map(item => item.modified)).toEqual(
+ filter === 'all'
+ ? [latest, released]
+ : filter === 'drafts'
+ ? [latest]
+ : [released]
+ );
+ expect(
+ component.snapshotHistory.filter(item => item.isCurrentDraft).length
+ ).toBe(filter === 'releases' ? 0 : 1);
+ expect(
+ component.snapshotHistory.find(item => item.isLatestRelease)?.modified
+ ).toBe(filter === 'drafts' ? undefined : released);
+ }
+ );
+
+ it.each(['all', 'drafts', 'releases'] as const)(
+ 'renders zero counters for empty %s results',
+ filter => {
+ component.onHistoryFilterChange(filter);
+ fixture.detectChanges();
+ const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs'));
+ const view = tabs.properties[
+ 'customTabs'
+ ][0].template.createEmbeddedView({});
+ try {
+ view.detectChanges();
+ const root = view.rootNodes.find(
+ (node: Node) => node instanceof HTMLElement
+ );
+ expect(
+ Array.from(
+ root.querySelectorAll('.snapshot-history-summary span')
+ ).map((node: HTMLElement) => node.textContent.trim().split(': ')[1])
+ ).toEqual(['0', '0', '0']);
+ expect(root.querySelector('.snapshot-history-entry')).toBeNull();
+ } finally {
+ view.destroy();
+ }
+ }
+ );
+
+ it('does not grant latest-only actions to the newest item on an older filtered page', () => {
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [release],
+ latest_snapshot_modified: latest,
+ latest_tagged_snapshot_modified: '2026-09-19T12:00:00.000Z',
+ })
+ )
+ );
+ component.onHistoryFilterChange('releases');
+ const item = component.snapshotHistory[0];
+ expect(item.isLatest).toBe(false);
+ expect(item.isLatestRelease).toBe(false);
+ component.onConvertReleaseToDraft(item);
+ component.onDeleteDraft(item);
+ expect(mockDialog.open).not.toHaveBeenCalled();
+ });
+
+ it('observes external creation, tagging and deletion without fetching track contents or config', () => {
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([release]))
+ );
+ component.onSelectedTabChange(1);
+ expect(component.snapshotHistory[0].isLatestRelease).toBe(true);
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([draft, release]))
+ );
+ vi.advanceTimersByTime(30_000);
+ expect(component.historyCounts).toEqual({
+ tagged: 1,
+ drafts: 1,
+ total: 2,
+ });
+ expect(component.snapshotHistory[0].isCurrentDraft).toBe(true);
+ const tagged = { ...draft, version: '2.0' };
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([tagged, release]))
+ );
+ vi.advanceTimersByTime(30_000);
+ expect(component.historyCounts).toEqual({
+ tagged: 2,
+ drafts: 0,
+ total: 2,
+ });
+ expect(component.snapshotHistory[0]).toEqual(
+ expect.objectContaining({
+ isLatest: true,
+ isLatestRelease: true,
+ isCurrentDraft: false,
+ })
+ );
+ const newer = { ...draft, modified: '2026-09-21T00:00:00.000Z' };
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([newer, tagged, release]))
+ );
+ vi.advanceTimersByTime(30_000);
+ expect(component.snapshotHistory[0].isCurrentDraft).toBe(true);
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([tagged, release]))
+ );
+ vi.advanceTimersByTime(30_000);
+ expect(component.snapshotHistory.map(item => item.modified)).toEqual([
+ latest,
+ released,
+ ]);
+ expect(component.snapshotHistory[0].isLatest).toBe(true);
+ expect(
+ mockReleaseTrackApiConnector.listDraftCleanup
+ ).toHaveBeenCalledTimes(5);
+ expect(
+ mockReleaseTrackApiConnector.getLatestSnapshot
+ ).not.toHaveBeenCalled();
+ expect(mockReleaseTrackApiConnector.getConfig).not.toHaveBeenCalled();
+ expect(
+ mockReleaseTrackApiConnector.listReleaseTracks
+ ).not.toHaveBeenCalled();
+ expect(mockRestApiConnector.getAllObjects).not.toHaveBeenCalled();
+ });
+
+ it('refreshes on visible tab entry and focus, and pauses for hidden documents and other tabs', () => {
+ vi.advanceTimersByTime(30_000);
+ window.dispatchEvent(new Event('focus'));
+ expect(mockReleaseTrackApiConnector.listSnapshots).not.toHaveBeenCalled();
+ component.onSelectedTabChange(1);
+ window.dispatchEvent(new Event('focus'));
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 2
+ );
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
+ document.dispatchEvent(new Event('visibilitychange'));
+ vi.advanceTimersByTime(60_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 2
+ );
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
+ document.dispatchEvent(new Event('visibilitychange'));
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 3
+ );
+ component.onSelectedTabChange(2);
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 3
+ );
+ });
+
+ it('pauses edits, dialogs and mutations and resumes without overlapping history or cleanup requests', () => {
+ component.onSelectedTabChange(1);
+ mockReleaseTrackApiConnector.listSnapshots.mockClear();
+ for (const field of [
+ 'isEditingConfig',
+ 'isEditingDescription',
+ 'isCreatingDraft',
+ 'isReleasing',
+ 'isDeleting',
+ 'isSavingConfig',
+ 'isSavingDescription',
+ 'isLoadingConfig',
+ ] as const) {
+ component[field] = true;
+ vi.advanceTimersByTime(30_000);
+ window.dispatchEvent(new Event('focus'));
+ component[field] = false;
+ }
+ mockDialog.openDialogs = [{}];
+ vi.advanceTimersByTime(30_000);
+ mockDialog.openDialogs = [];
+ expect(mockReleaseTrackApiConnector.listSnapshots).not.toHaveBeenCalled();
+ const pendingHistory = new Subject();
+ const pendingCleanup = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(
+ pendingHistory
+ );
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValueOnce(
+ pendingCleanup
+ );
+ vi.advanceTimersByTime(30_000);
+ vi.advanceTimersByTime(60_000);
+ window.dispatchEvent(new Event('focus'));
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 1
+ );
+ pendingHistory.next(historyResponse([draft]));
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 1
+ );
+ pendingCleanup.next({ data: [] });
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 2
+ );
+ });
+
+ it('waits for a local deletion and its authoritative refresh before polling again', () => {
+ const deletion = new Subject();
+ const trackRefresh = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([draft, release]))
+ );
+ component.onSelectedTabChange(1);
+ mockDialog.open.mockReturnValue({ afterClosed: () => of(true) });
+ mockReleaseTrackApiConnector.deleteSnapshotByModified.mockReturnValue(
+ deletion
+ );
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(
+ trackRefresh
+ );
+ component.onDeleteDraft(component.snapshotHistory[0]);
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 1
+ );
+ deletion.next();
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 1
+ );
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([release]))
+ );
+ trackRefresh.next(
+ new ReleaseTrackSnapshot({ ...release, id: component.id })
+ );
+ expect(component.snapshotHistory.map(item => item.modified)).toEqual([
+ released,
+ ]);
+ vi.advanceTimersByTime(30_000);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ 3
+ );
+ });
+
+ it('discards a background response if editing starts while it is in flight', () => {
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([release]))
+ );
+ component.onSelectedTabChange(1);
+ const pending = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(pending);
+ vi.advanceTimersByTime(30_000);
+ component.isEditingDescription = true;
+ pending.next(historyResponse([draft, release]));
+ expect(component.snapshotHistory.map(item => item.modified)).toEqual([
+ released,
+ ]);
+ component.isEditingDescription = false;
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([draft, release]))
+ );
+ vi.advanceTimersByTime(30_000);
+ expect(component.snapshotHistory[0].modified).toBe(latest);
+ });
+
+ it('preserves the filter, page, DOM row and scroll until a deletion requires page clamping', () => {
+ const scroll = vi.spyOn(window, 'scrollTo');
+ const response = historyResponse({
+ data: [draft],
+ counts: { tagged: 0, drafts: 80, total: 80 },
+ pagination: { total: 80, offset: 50 },
+ });
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(of(response));
+ component.onHistoryFilterChange('drafts');
+ component.onHistoryPageChange({ pageIndex: 2, pageSize: 25, length: 80 });
+ fixture.detectChanges();
+ const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs'));
+ const view = tabs.properties['customTabs'][0].template.createEmbeddedView(
+ {}
+ );
+ try {
+ view.detectChanges();
+ const root = view.rootNodes.find(
+ (node: Node) => node instanceof HTMLElement
+ );
+ const card = root.querySelector('.snapshot-history-entry');
+ component.onSelectedTabChange(1);
+ vi.advanceTimersByTime(30_000);
+ view.detectChanges();
+ expect(root.querySelector('.snapshot-history-entry')).toBe(card);
+ expect(component.historyPageIndex).toBe(2);
+ expect(component.historyFilter).toBe('drafts');
+ expect(scroll).not.toHaveBeenCalled();
+ mockReleaseTrackApiConnector.listSnapshots
+ .mockReturnValueOnce(
+ of(
+ historyResponse({
+ data: [],
+ counts: { tagged: 0, drafts: 1, total: 1 },
+ pagination: { total: 1, offset: 50 },
+ latest_snapshot_modified: latest,
+ })
+ )
+ )
+ .mockReturnValueOnce(of(historyResponse([draft])));
+ vi.advanceTimersByTime(30_000);
+ expect(component.historyPageIndex).toBe(0);
+ expect(component.snapshotHistory[0].modified).toBe(latest);
+ expect(
+ mockReleaseTrackApiConnector.listSnapshots
+ ).toHaveBeenLastCalledWith(component.id, {
+ tagged: false,
+ limit: 25,
+ offset: 0,
+ });
+ } finally {
+ view.destroy();
+ }
+ });
+
+ it('cancels stale filters, routes and destroyed requests and removes timer and focus listeners', () => {
+ const stale = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(stale);
+ component.onSelectedTabChange(1);
+ const filtered = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(filtered);
+ component.onHistoryFilterChange('releases');
+ stale.next(historyResponse([draft]));
+ expect(component.snapshotHistory).toEqual([]);
+ filtered.next(historyResponse([release]));
+ expect(component.snapshotHistory.map(item => item.modified)).toEqual([
+ released,
+ ]);
+ expect(stale.observed).toBe(false);
+ const previousRoute = new Subject();
+ const cleanup = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(
+ previousRoute
+ );
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValueOnce(
+ cleanup
+ );
+ vi.advanceTimersByTime(30_000);
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(
+ of(
+ new ReleaseTrackSnapshot({
+ id: 'release-track--next',
+ type: ReleaseTrackType.Virtual,
+ })
+ )
+ );
+ mockReleaseTrackApiConnector.getConfig.mockReturnValue(of({}));
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([]))
+ );
+ routeParams.next({ id: 'release-track--next' });
+ previousRoute.next(historyResponse([draft]));
+ expect(previousRoute.observed).toBe(false);
+ expect(cleanup.observed).toBe(false);
+ expect(component.id).toBe('release-track--next');
+ expect(component.snapshotHistory).toEqual([]);
+ const pending = new Subject();
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValueOnce(pending);
+ component.onSelectedTabChange(1);
+ fixture.destroy();
+ const calls =
+ mockReleaseTrackApiConnector.listSnapshots.mock.calls.length;
+ pending.next(historyResponse([draft]));
+ vi.advanceTimersByTime(60_000);
+ window.dispatchEvent(new Event('focus'));
+ document.dispatchEvent(new Event('visibilitychange'));
+ expect(pending.observed).toBe(false);
+ expect(component.snapshotHistory).toEqual([]);
+ expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledTimes(
+ calls
+ );
+ });
+
+ it('does not resurrect dismissed cleanup notifications on polling', () => {
+ const completed: DraftCleanupResult = {
+ operation_id: 'cleanup--done',
+ kind: 'retention',
+ status: 'completed',
+ eligible_count: 2,
+ deleted_count: 2,
+ protected_count: 0,
+ };
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue(
+ of({ data: [completed] })
+ );
+ component.onSelectedTabChange(1);
+ component.dismissCleanupNotification();
+ vi.advanceTimersByTime(60_000);
+ expect(mockSnackbar.openFromTemplate).toHaveBeenCalledTimes(1);
+ expect(component.draftCleanupResults).toEqual([]);
+ });
+ });
+
+ it('saves recurring retention only within config editing without creating a content draft', () => {
+ component.id = 'release-track--virtual';
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ snapshot_schedule: {
+ mode: 'cron',
+ cron: '0 * * * *',
+ draft_retention: { max_drafts: 10 },
+ },
+ });
+ const originalModified = component.releaseTrack.modified;
+ vi.spyOn(component, 'getReleaseTrack').mockImplementation(() => undefined);
+ vi.spyOn(component, 'getConfig').mockImplementation(() => undefined);
+ mockReleaseTrackApiConnector.updateSchedule.mockImplementation(
+ (_id, schedule) => of({ snapshot_schedule: schedule })
+ );
+ component.retentionForm.patchValue({ enabled: true, maxDrafts: 2 });
+ component.onSaveConfig();
+ expect(mockReleaseTrackApiConnector.updateSchedule).not.toHaveBeenCalled();
+
+ component.onEditConfig();
+ component.retentionForm.patchValue({ enabled: false, maxDrafts: 2 });
+ component.onCancelConfigEdit();
+ expect(component.retentionForm.value).toEqual({
+ enabled: true,
+ maxDrafts: 10,
+ });
+ component.onEditConfig();
+ component.retentionForm.patchValue({ enabled: true, maxDrafts: 0 });
+ component.onSaveConfig();
+ expect(mockReleaseTrackApiConnector.updateSchedule).not.toHaveBeenCalled();
+ component.retentionForm.patchValue({ maxDrafts: 2 });
+ component.onSaveConfig();
+ expect(component.releaseTrack.snapshot_schedule).toEqual({
+ mode: 'cron',
+ cron: '0 * * * *',
+ draft_retention: { max_drafts: 2 },
+ });
+ expect(component.releaseTrack.modified).toBe(originalModified);
+ expect(
+ mockReleaseTrackApiConnector.updateComposition
+ ).not.toHaveBeenCalled();
+ expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled();
+ expect(
+ mockReleaseTrackApiConnector.createVirtualSnapshot
+ ).not.toHaveBeenCalled();
+ });
+
+ it('preserves recurring retention for editor timing edits and removes it when leaving cron', () => {
+ component.id = 'release-track--virtual';
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ snapshot_schedule: {
+ mode: 'cron',
+ cron: '0 * * * *',
+ draft_retention: { max_drafts: 7 },
+ },
+ });
+ mockAuthenticationService.isAuthorized.mockReturnValue(false);
+ vi.spyOn(component, 'getReleaseTrack').mockImplementation(() => undefined);
+ vi.spyOn(component, 'getConfig').mockImplementation(() => undefined);
+ mockReleaseTrackApiConnector.updateSchedule.mockImplementation(
+ (_id, schedule) => of({ snapshot_schedule: schedule })
+ );
+ component.onEditConfig();
+ component.configForm.patchValue({ virtualCronMinute: 5 });
+ component.retentionForm.patchValue({ enabled: true, maxDrafts: 1 });
+ component.onSaveConfig();
+ expect(component.releaseTrack.snapshot_schedule).toEqual({
+ mode: 'cron',
+ cron: '5 * * * *',
+ draft_retention: { max_drafts: 7 },
+ });
+ component.onEditConfig();
+ component.configForm.patchValue({ virtualSnapshotScheduleMode: 'manual' });
+ component.onSaveConfig();
+ expect(component.releaseTrack.snapshot_schedule).toEqual({
+ mode: 'manual',
+ });
+ expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled();
+ expect(
+ mockReleaseTrackApiConnector.updateComposition
+ ).not.toHaveBeenCalled();
+ });
+
+ it('uses fresh authoritative counters after draft creation and ignores older overlapping refreshes', () => {
+ const oldLatest = new Subject();
+ const freshLatest = new Subject();
+ const oldHistory = new Subject();
+ const freshHistory = new Subject();
+ component.id = 'release-track--virtual';
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ snapshot_count: 80,
+ composition: { component_tracks: [{ track_id: 'source' }] },
+ });
+ mockReleaseTrackApiConnector.listSnapshots
+ .mockReturnValueOnce(oldHistory)
+ .mockReturnValueOnce(freshHistory);
+ component.getSnapshotHistory();
+ mockReleaseTrackApiConnector.getLatestSnapshot
+ .mockReturnValueOnce(oldLatest)
+ .mockReturnValueOnce(freshLatest);
+ component.getReleaseTrack();
+ mockDialog.open.mockReturnValue({
+ afterClosed: () =>
+ of({ description: 'New draft', draft_retention: { max_drafts: 2 } }),
+ });
+ mockReleaseTrackApiConnector.createVirtualSnapshot.mockReturnValue(of({}));
+ component.onDraft();
+ freshLatest.next(
+ new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ snapshot_count: 4,
+ tagged_release_count: 2,
+ modified: '2026-09-23T00:00:00Z',
+ })
+ );
+ freshHistory.next(
+ historyResponse({
+ data: [
+ {
+ type: ReleaseTrackType.Virtual,
+ modified: '2026-09-23T00:00:00.000Z',
+ },
+ ],
+ pagination: { total: 4 },
+ counts: { tagged: 2, drafts: 2, total: 4 },
+ })
+ );
+ oldLatest.next(
+ new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ snapshot_count: 80,
+ })
+ );
+ oldHistory.next(historyResponse({ data: [], pagination: { total: 80 } }));
+ expect(component.releaseTrack.snapshot_count).toBe(4);
+ expect(component.historyCounts.tagged).toBe(2);
+ expect(component.historyTotal).toBe(4);
+ expect(component.snapshotHistory[0].modified).toBe(
+ '2026-09-23T00:00:00.000Z'
+ );
+ });
+
+ it('dismisses completed cleanup from the overlay without resurrecting it on refresh', () => {
+ const completed: DraftCleanupResult = {
+ operation_id: 'cleanup--done',
+ kind: 'retention',
+ status: 'completed',
+ eligible_count: 8,
+ deleted_count: 8,
+ protected_count: 0,
+ };
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ type: ReleaseTrackType.Virtual,
+ });
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue(
+ of({ data: [completed] })
+ );
+ component.loadDraftCleanup();
+ const notification = mockSnackbar.openFromTemplate.mock.calls[0];
+ const view = notification[0].createEmbeddedView({
+ $implicit: notification[1].data,
+ });
+ view.detectChanges();
+ const notificationElement = view.rootNodes.find(
+ node => node.nodeType === 1
+ ) as HTMLElement;
+ notificationElement
+ .querySelector('button[aria-label]')!
+ .click();
+ expect(mockSnackbar.dismiss).toHaveBeenCalled();
+ component.loadDraftCleanup();
+ expect(mockSnackbar.openFromTemplate).toHaveBeenCalledTimes(1);
+ expect(component.draftCleanupResults).toEqual([]);
+ expect(component.cleanupMessage).toBe('');
+ view.destroy();
+ });
+
+ it('tags the reviewed historical snapshot and repairs partial cleanup without tagging twice', () => {
+ const target = '2026-09-10T00:00:00.000Z';
+ const current = new ReleaseTrackSnapshot({
+ id: 'release-track--virtual',
+ type: ReleaseTrackType.Virtual,
+ modified: '2026-09-20T00:00:00.000Z',
+ });
+ const failed: DraftCleanupResult = {
+ operation_id: 'cleanup--original',
+ kind: 'squash',
+ status: 'failed',
+ target_modified: target,
+ release_committed: true,
+ eligible_count: 7,
+ deleted_count: 3,
+ protected_count: 1,
+ error: 'Manifest repair interrupted',
+ };
+ component.id = current.id;
+ component.releaseTrack = current;
+ component.historyFilter = 'all';
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [
+ { type: ReleaseTrackType.Virtual, modified: target, version: null },
+ ],
+ pagination: { total: 1, limit: 25, offset: 0 },
+ })
+ )
+ );
+ component.getSnapshotHistory();
+ mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
+ of({
+ type: ReleaseTrackType.Virtual,
+ source_snapshot_modified: target,
+ draft_squash: {
+ lower_bound: null,
+ upper_bound: target,
+ eligible_count: 7,
+ protected_count: 1,
+ fingerprint: 'reviewed-set',
+ },
+ })
+ );
+ mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue(
+ of(
+ new ReleaseTrackSnapshot({
+ id: current.id,
+ type: ReleaseTrackType.Virtual,
+ modified: target,
+ })
+ )
+ );
+ mockDialog.open.mockReturnValue({
+ afterClosed: () =>
+ of({
+ version: '1.0',
+ description: '',
+ squash_drafts: true,
+ squash_fingerprint: 'reviewed-set',
+ }),
+ });
+ mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(
+ of({
+ modified: target,
+ version: '1.0',
+ draft_cleanup: failed,
+ })
+ );
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(
+ of(
+ new ReleaseTrackSnapshot({
+ ...current,
+ snapshot_count: 7,
+ tagged_release_count: 1,
+ })
+ )
+ );
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue(
+ of({ data: [failed] })
+ );
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [
+ {
+ type: ReleaseTrackType.Virtual,
+ modified: target,
+ version: '1.0',
+ },
+ ],
+ counts: { tagged: 1, drafts: 6, total: 7 },
+ pagination: { total: 7 },
+ latest_snapshot_modified: current.modified.toISOString(),
+ latest_tagged_snapshot_modified: target,
+ })
+ )
+ );
+
+ component.onTagSnapshot(component.snapshotHistory[0]);
+
+ expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledWith(
+ current.id,
+ target,
+ {
+ version: '1.0',
+ description: '',
+ squash_drafts: true,
+ squash_fingerprint: 'reviewed-set',
+ }
+ );
+ expect(component.draftCleanupResults[0].status).toBe('failed');
+ expect(component.draftCleanupResults[0].release_committed).toBe(true);
+ expect(component.releaseTrack?.snapshot_count).toBe(7);
+ expect(component.historyCounts.tagged).toBe(1);
+ const completed: DraftCleanupResult = {
+ ...failed,
+ status: 'completed',
+ deleted_count: 7,
+ error: undefined,
+ };
+ mockReleaseTrackApiConnector.retryDraftCleanup.mockReturnValue(
+ of(completed)
+ );
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue(
+ of({ data: [] })
+ );
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(
+ of(
+ new ReleaseTrackSnapshot({
+ ...current,
+ snapshot_count: 3,
+ tagged_release_count: 1,
+ })
+ )
+ );
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [
+ {
+ type: ReleaseTrackType.Virtual,
+ modified: target,
+ version: '1.0',
+ },
+ ],
+ counts: { tagged: 1, drafts: 2, total: 3 },
+ pagination: { total: 3 },
+ latest_snapshot_modified: current.modified.toISOString(),
+ latest_tagged_snapshot_modified: target,
+ })
+ )
+ );
+ component.historyPageIndex = 3;
+ component.retryDraftCleanup(failed);
+
+ expect(component.draftCleanupResults).toEqual([]);
+ expect(mockSnackbar.openFromTemplate).toHaveBeenCalledWith(
+ component.cleanupNotification,
+ expect.objectContaining({ data: completed })
+ );
+ expect(component.historyPageIndex).toBe(0);
+ expect(component.releaseTrack?.snapshot_count).toBe(3);
+ expect(component.historyCounts.tagged).toBe(1);
+ expect(mockReleaseTrackApiConnector.retryDraftCleanup).toHaveBeenCalledWith(
+ current.id,
+ failed.operation_id
+ );
+ expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled();
+ });
+
+ it('explains a disappeared draft instead of opening a release dialog', () => {
+ component.id = 'release-track--virtual';
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ type: ReleaseTrackType.Virtual,
+ modified: '2026-09-20T00:00:00.000Z',
+ });
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [{ modified: '2026-09-20T00:00:00.000Z', version: null }],
+ })
+ )
+ );
+ component.getSnapshotHistory();
+ mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
+ throwError(() => ({ status: 404 }))
+ );
+
+ component.onTagSnapshot(component.snapshotHistory[0]);
+
+ expect(mockDialog.open).not.toHaveBeenCalled();
+ expect(mockReleaseTrackApiConnector.releaseSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('requires a fresh preview after a stale squash and rediscovers a partially committed release', () => {
+ const current = new ReleaseTrackSnapshot({
+ id: 'release-track--virtual',
+ type: ReleaseTrackType.Virtual,
+ modified: '2026-09-20T00:00:00.000Z',
+ });
+ component.id = current.id;
+ component.releaseTrack = current;
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [current],
+ })
+ )
+ );
+ component.getSnapshotHistory();
+ mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
+ of({
+ type: ReleaseTrackType.Virtual,
+ draft_squash: {
+ lower_bound: null,
+ upper_bound: current.modified.toISOString(),
+ eligible_count: 2,
+ protected_count: 0,
+ fingerprint: 'old',
+ },
+ })
+ );
+ mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue(
+ of(current)
+ );
+ mockDialog.open.mockReturnValue({
+ afterClosed: () =>
+ of({
+ increment: 'minor',
+ description: '',
+ squash_drafts: true,
+ squash_fingerprint: 'old',
+ }),
+ });
+ mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(
+ throwError(() => ({
+ status: 409,
+ error: { message: 'Fingerprint changed' },
+ }))
+ );
+
+ component.onTagSnapshot(component.snapshotHistory[0]);
+
+ expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledTimes(
+ 1
+ );
+ expect(
+ mockReleaseTrackApiConnector.retryDraftCleanup
+ ).not.toHaveBeenCalled();
+ const pending: DraftCleanupResult = {
+ operation_id: 'cleanup--partial',
+ kind: 'squash',
+ status: 'pending',
+ release_committed: true,
+ eligible_count: 2,
+ deleted_count: 0,
+ protected_count: 0,
+ };
+ mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(
+ throwError(() => ({
+ status: 500,
+ error: {
+ operation_id: pending.operation_id,
+ release_committed: true,
+ draft_cleanup: pending,
+ },
+ }))
+ );
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(of(current));
+ mockReleaseTrackApiConnector.listDraftCleanup.mockReturnValue(
+ of({ data: [pending] })
+ );
+
+ component.onTagSnapshot(component.snapshotHistory[0]);
+
+ expect(component.draftCleanupResults[0]).toEqual(pending);
+ expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledTimes(
+ 2
+ );
+ expect(mockReleaseTrackApiConnector.listDraftCleanup).toHaveBeenCalledWith(
+ current.id
+ );
});
function withSnapshotHeader(
snapshot: any,
check: (header: HTMLElement) => void
) {
- (component as any).snapshotHistory = (
- component as any
- ).buildSnapshotHistory([snapshot]);
+ component.id = 'release-track--header';
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(historyResponse([snapshot]))
+ );
+ component.getSnapshotHistory();
fixture.detectChanges();
const tabs = fixture.debugElement.query(By.css('app-stix-page-tabs'));
const template = tabs.properties['customTabs'][0].template;
@@ -982,72 +1989,74 @@ describe('ReleaseTrackPageComponent', () => {
it('should load snapshot history from summary counts', () => {
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of({
- data: [
- {
- id: 'release-track--123',
- modified: '2024-05-21T07:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Standard,
- name: 'Enterprise',
- added_count: 2,
- modified_count: 1,
- candidates_count: 4,
- staged_count: 3,
- members_count: 20,
- },
- {
- id: 'release-track--456',
- version: '1.3',
- content_manifest_id: 'release-track-graph-manifest--sealed',
- tagged_at: '2024-04-15T06:30:00.000Z',
- modified: '2024-04-15T06:00:00.000Z',
- type: ReleaseTrackType.Virtual,
- name: 'Combined',
- members_count: 5,
- quarantine_count: 1,
- composition_resolution: {
- resolved_at: '2024-04-15T05:55:00.000Z',
- component_snapshots: [
- {
- track_id: 'release-track--component-one',
- track_name: 'Component One At Resolution',
- track_type: 'standard',
- resolved_snapshot_id: '2024-04-01T08:15:30.000Z',
- resolved_version: '2.4',
- strategy_used: 'latest_tagged',
- filters_applied: {
- object_types: ['attack-pattern'],
- domains: ['enterprise'],
+ of(
+ historyResponse({
+ data: [
+ {
+ id: 'release-track--123',
+ modified: '2024-05-21T07:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Standard,
+ name: 'Enterprise',
+ added_count: 2,
+ modified_count: 1,
+ candidates_count: 4,
+ staged_count: 3,
+ members_count: 20,
+ },
+ {
+ id: 'release-track--456',
+ version: '1.3',
+ content_manifest_id: 'release-track-graph-manifest--sealed',
+ tagged_at: '2024-04-15T06:30:00.000Z',
+ modified: '2024-04-15T06:00:00.000Z',
+ type: ReleaseTrackType.Virtual,
+ name: 'Combined',
+ members_count: 5,
+ quarantine_count: 1,
+ composition_resolution: {
+ resolved_at: '2024-04-15T05:55:00.000Z',
+ component_snapshots: [
+ {
+ track_id: 'release-track--component-one',
+ track_name: 'Component One At Resolution',
+ track_type: 'standard',
+ resolved_snapshot_id: '2024-04-01T08:15:30.000Z',
+ resolved_version: '2.4',
+ strategy_used: 'latest_tagged',
+ filters_applied: {
+ object_types: ['attack-pattern'],
+ domains: ['enterprise'],
+ },
+ total_objects_in_source: 30,
+ objects_after_filter: 12,
+ objects_contributed: 10,
},
- total_objects_in_source: 30,
- objects_after_filter: 12,
- objects_contributed: 10,
+ ],
+ deduplication: {
+ total_objects_before: 12,
+ total_objects_after: 10,
+ duplicates_found: 2,
+ conflicts_resolved: [],
+ },
+ summary: {
+ total_objects: 10,
+ quarantined_objects: 1,
},
- ],
- deduplication: {
- total_objects_before: 12,
- total_objects_after: 10,
- duplicates_found: 2,
- conflicts_resolved: [],
},
- summary: {
- total_objects: 10,
- quarantined_objects: 1,
+ content_statistics: {
+ primary_count: 5,
+ secondary_count: 8,
+ relationship_count: 12,
+ supporting_count: 2,
+ link_target_count: 1,
+ total_count: 28,
},
},
- content_statistics: {
- primary_count: 5,
- secondary_count: 8,
- relationship_count: 12,
- supporting_count: 2,
- link_target_count: 1,
- total_count: 28,
- },
- },
- ],
- pagination: { total: 2, limit: 50, offset: 0 },
- })
+ ],
+ pagination: { total: 2, limit: 50, offset: 0 },
+ })
+ )
);
component.id = 'release-track--123';
@@ -1110,8 +2119,10 @@ describe('ReleaseTrackPageComponent', () => {
],
})
);
- expect(component.taggedSnapshotCount).toBe(1);
- expect(component.hasCurrentDraftSnapshot).toBe(true);
+ expect(component.historyCounts.tagged).toBe(1);
+ expect(component.snapshotHistory.some(item => item.isCurrentDraft)).toBe(
+ true
+ );
});
it('should keep composition provenance scoped to each virtual snapshot', () => {
@@ -1128,60 +2139,63 @@ describe('ReleaseTrackPageComponent', () => {
},
} as any;
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of({
- data: [
- {
- modified: '2024-06-01T00:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Virtual,
- composition_resolution: null,
- },
- {
- modified: '2024-05-01T00:00:00.000Z',
- version: '2.0',
- type: ReleaseTrackType.Virtual,
- composition_resolution: {
- resolved_at: '2024-04-30T23:59:00.000Z',
- component_snapshots: [
- {
- track_id: 'release-track--component',
- track_name: 'Component',
- track_type: 'standard',
- resolved_snapshot_id: '2024-04-20T10:00:00.000Z',
- resolved_version: null,
- strategy_used: 'latest_draft',
- total_objects_in_source: 40,
- objects_after_filter: 40,
- objects_contributed: 38,
- },
- ],
+ of(
+ historyResponse({
+ data: [
+ {
+ modified: '2024-06-01T00:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Virtual,
+ composition_resolution: null,
+ },
+ {
+ modified: '2024-05-01T00:00:00.000Z',
+ version: '2.0',
+ type: ReleaseTrackType.Virtual,
+ composition_resolution: {
+ resolved_at: '2024-04-30T23:59:00.000Z',
+ component_snapshots: [
+ {
+ track_id: 'release-track--component',
+ track_name: 'Component',
+ track_type: 'standard',
+ resolved_snapshot_id: '2024-04-20T10:00:00.000Z',
+ resolved_version: null,
+ strategy_used: 'latest_draft',
+ total_objects_in_source: 40,
+ objects_after_filter: 40,
+ objects_contributed: 38,
+ },
+ ],
+ },
},
- },
- {
- modified: '2024-03-01T00:00:00.000Z',
- version: '1.0',
- type: ReleaseTrackType.Virtual,
- composition_resolution: {
- resolved_at: '2024-02-29T23:59:00.000Z',
- component_snapshots: [
- {
- track_id: 'release-track--component',
- track_name: 'Component',
- track_type: 'standard',
- resolved_snapshot_id: '2024-02-15T10:00:00.000Z',
- resolved_version: '1.3',
- strategy_used: 'latest_tagged',
- total_objects_in_source: 30,
- objects_after_filter: 30,
- objects_contributed: 30,
- },
- ],
+ {
+ modified: '2024-03-01T00:00:00.000Z',
+ version: '1.0',
+ type: ReleaseTrackType.Virtual,
+ composition_resolution: {
+ resolved_at: '2024-02-29T23:59:00.000Z',
+ component_snapshots: [
+ {
+ track_id: 'release-track--component',
+ track_name: 'Component',
+ track_type: 'standard',
+ resolved_snapshot_id: '2024-02-15T10:00:00.000Z',
+ resolved_version: '1.3',
+ strategy_used: 'latest_tagged',
+ total_objects_in_source: 30,
+ objects_after_filter: 30,
+ objects_contributed: 30,
+ },
+ ],
+ },
},
- },
- ],
- })
+ ],
+ })
+ )
);
component.id = 'release-track--virtual';
+ component.historyFilter = 'all';
component.getSnapshotHistory();
@@ -1239,30 +2253,32 @@ describe('ReleaseTrackPageComponent', () => {
type: ReleaseTrackType.Virtual,
});
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of({
- data: [
- {
- modified: '2024-06-01T00:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Virtual,
- composition_resolution: {
- component_snapshots: [
- {
- track_id: 'release-track--preview-source',
- track_name: 'Preview source',
- track_type: 'standard',
- resolved_snapshot_id: '2024-05-20T10:00:00.000Z',
- resolved_version: resolvedVersion,
- strategy_used: 'latest_preview',
- total_objects_in_source: 42,
- objects_after_filter: 42,
- objects_contributed: 42,
- },
- ],
+ of(
+ historyResponse({
+ data: [
+ {
+ modified: '2024-06-01T00:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Virtual,
+ composition_resolution: {
+ component_snapshots: [
+ {
+ track_id: 'release-track--preview-source',
+ track_name: 'Preview source',
+ track_type: 'standard',
+ resolved_snapshot_id: '2024-05-20T10:00:00.000Z',
+ resolved_version: resolvedVersion,
+ strategy_used: 'latest_preview',
+ total_objects_in_source: 42,
+ objects_after_filter: 42,
+ objects_contributed: 42,
+ },
+ ],
+ },
},
- },
- ],
- })
+ ],
+ })
+ )
);
component.getSnapshotHistory();
fixture.detectChanges();
@@ -1313,9 +2329,9 @@ describe('ReleaseTrackPageComponent', () => {
const trackSpy = vi
.spyOn(component, 'getReleaseTrack')
.mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ vi.spyOn(component, 'getSnapshotHistory').mockImplementation(
+ () => undefined
+ );
component.id = 'release-track--123';
component.onConvertReleaseToDraft(item);
@@ -1333,7 +2349,6 @@ describe('ReleaseTrackPageComponent', () => {
mockReleaseTrackApiConnector.convertReleaseToDraft
).toHaveBeenCalledWith('release-track--123', item.modified, '1.1');
expect(trackSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
expect(mockSnackbar.open).toHaveBeenCalledWith(
'Release 1.1 converted to draft.',
null,
@@ -1341,60 +2356,26 @@ describe('ReleaseTrackPageComponent', () => {
);
});
- it('should change a tagged release version as an administrator', () => {
- const item = {
- snapshot: { version: '1.1' },
- title: 'v1.1',
- modified: '2026-07-23T13:37:28.000Z',
- isTagged: true,
- isLatestRelease: true,
- } as any;
- mockDialog.open.mockReturnValue({ afterClosed: () => of('1.2') });
- mockReleaseTrackApiConnector.retagRelease.mockReturnValue(
- of({ version: '1.2' })
- );
- const refreshSpy = vi
- .spyOn(component as any, 'refreshReleaseTrackState')
- .mockImplementation(() => undefined);
- component.id = 'release-track--123';
-
- component.onRetagRelease(item);
-
- expect(mockDialog.open).toHaveBeenCalledWith(
- ReleaseVersionDialogComponent,
- expect.objectContaining({ data: { currentVersion: '1.1' } })
- );
- expect(mockReleaseTrackApiConnector.retagRelease).toHaveBeenCalledWith(
- 'release-track--123',
- item.modified,
- { version: '1.2' }
- );
- expect(refreshSpy).toHaveBeenCalled();
- expect(mockSnackbar.open).toHaveBeenCalledWith(
- 'Release 1.1 changed to 1.2.',
- null,
- { duration: 5000 }
- );
- });
-
it('should hide a preserved release-source draft until rollback', () => {
const sourceModified = '2026-07-23T13:37:27.000Z';
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of({
- data: [
- {
- id: 'release-track--123',
- modified: '2026-07-23T13:37:28.000Z',
- version: '1.0',
- release_source_modified: sourceModified,
- },
- {
- id: 'release-track--123',
- modified: sourceModified,
- version: null,
- },
- ],
- })
+ of(
+ historyResponse({
+ data: [
+ {
+ id: 'release-track--123',
+ modified: '2026-07-23T13:37:28.000Z',
+ version: '1.0',
+ release_source_modified: sourceModified,
+ },
+ {
+ id: 'release-track--123',
+ modified: sourceModified,
+ version: null,
+ },
+ ],
+ })
+ )
);
component.id = 'release-track--123';
@@ -1402,7 +2383,9 @@ describe('ReleaseTrackPageComponent', () => {
expect(component.snapshotHistory).toHaveLength(1);
expect(component.snapshotHistory[0].title).toBe('v1.0');
- expect(component.hasCurrentDraftSnapshot).toBe(false);
+ expect(component.snapshotHistory.some(item => item.isCurrentDraft)).toBe(
+ false
+ );
});
it('should not offer release conversion to non-administrators or for drafts', () => {
@@ -1427,7 +2410,7 @@ describe('ReleaseTrackPageComponent', () => {
).not.toHaveBeenCalled();
});
- it('should delete only the current draft and refresh snapshot history', () => {
+ it('deletes the current draft and refreshes the surviving latest snapshot and counters', () => {
component.id = 'release-track--123';
const item = {
snapshot: { version: null },
@@ -1439,12 +2422,34 @@ describe('ReleaseTrackPageComponent', () => {
mockReleaseTrackApiConnector.deleteSnapshotByModified.mockReturnValue(
of(undefined)
);
- const trackSpy = vi
- .spyOn(component, 'getReleaseTrack')
- .mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ modified: item.modified,
+ snapshot_count: 4,
+ tagged_release_count: 2,
+ });
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(
+ of(
+ new ReleaseTrackSnapshot({
+ id: component.id,
+ type: ReleaseTrackType.Virtual,
+ modified: '2026-07-22T00:00:00.000Z',
+ version: '2.0',
+ snapshot_count: 3,
+ tagged_release_count: 2,
+ })
+ )
+ );
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [{ modified: '2026-07-22T00:00:00.000Z', version: '2.0' }],
+ pagination: { total: 3 },
+ counts: { tagged: 2, drafts: 1, total: 3 },
+ })
+ )
+ );
component.onDeleteDraft(item);
expect(
mockReleaseTrackApiConnector.deleteSnapshotByModified
@@ -1452,12 +2457,13 @@ describe('ReleaseTrackPageComponent', () => {
expect(
mockReleaseTrackApiConnector.convertReleaseToDraft
).not.toHaveBeenCalled();
- expect(trackSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
- expect(mockSnackbar.open).toHaveBeenCalledWith(
- 'Draft snapshot deleted.',
- null,
- expect.anything()
+ expect(component.releaseTrack.snapshot_count).toBe(3);
+ expect(component.historyCounts.tagged).toBe(2);
+ expect(component.snapshotHistory.some(item => item.isCurrentDraft)).toBe(
+ false
+ );
+ expect(component.snapshotHistory[0].modified).toBe(
+ '2026-07-22T00:00:00.000Z'
);
});
@@ -1612,17 +2618,19 @@ describe('ReleaseTrackPageComponent', () => {
},
} as any;
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of([
- {
- modified: '2024-05-21T07:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Standard,
- is_latest: true,
- candidates_count: 1,
- staged_count: 2,
- members_count: 3,
- },
- ])
+ of(
+ historyResponse([
+ {
+ modified: '2024-05-21T07:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Standard,
+ is_latest: true,
+ candidates_count: 1,
+ staged_count: 2,
+ members_count: 3,
+ },
+ ])
+ )
);
component.id = 'release-track--123';
@@ -1639,23 +2647,25 @@ describe('ReleaseTrackPageComponent', () => {
it('should only mark the latest draft snapshot as current', () => {
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of([
- {
- modified: '2024-05-21T07:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Standard,
- },
- {
- modified: '2024-05-20T07:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Standard,
- },
- {
- modified: '2024-05-19T07:00:00.000Z',
- version: '1.0',
- type: ReleaseTrackType.Standard,
- },
- ])
+ of(
+ historyResponse([
+ {
+ modified: '2024-05-21T07:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Standard,
+ },
+ {
+ modified: '2024-05-20T07:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Standard,
+ },
+ {
+ modified: '2024-05-19T07:00:00.000Z',
+ version: '1.0',
+ type: ReleaseTrackType.Standard,
+ },
+ ])
+ )
);
component.id = 'release-track--123';
@@ -1685,23 +2695,27 @@ describe('ReleaseTrackPageComponent', () => {
isCurrentDraft: false,
})
);
- expect(component.hasCurrentDraftSnapshot).toBe(true);
+ expect(component.snapshotHistory.some(item => item.isCurrentDraft)).toBe(
+ true
+ );
});
it('should mark a tagged current snapshot as latest', () => {
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
- of([
- {
- modified: '2024-05-21T07:00:00.000Z',
- version: '1.1',
- type: ReleaseTrackType.Standard,
- },
- {
- modified: '2024-05-20T07:00:00.000Z',
- version: null,
- type: ReleaseTrackType.Standard,
- },
- ])
+ of(
+ historyResponse([
+ {
+ modified: '2024-05-21T07:00:00.000Z',
+ version: '1.1',
+ type: ReleaseTrackType.Standard,
+ },
+ {
+ modified: '2024-05-20T07:00:00.000Z',
+ version: null,
+ type: ReleaseTrackType.Standard,
+ },
+ ])
+ )
);
component.id = 'release-track--123';
@@ -1723,72 +2737,65 @@ describe('ReleaseTrackPageComponent', () => {
isCurrentDraft: false,
})
);
- expect(component.hasCurrentDraftSnapshot).toBe(false);
+ expect(component.snapshotHistory.some(item => item.isCurrentDraft)).toBe(
+ false
+ );
});
- it('should create a draft snapshot and refresh the release track', () => {
- const refreshSpy = vi
- .spyOn(component, 'getReleaseTrack')
- .mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
- mockDialog.open.mockReturnValue({
- afterClosed: () => of('Draft analyst context'),
- });
- mockReleaseTrackApiConnector.createVirtualSnapshot.mockReturnValue(
- of({
- stix: {
- modified: '2024-05-21T07:00:00.000Z',
- x_mitre_version: null,
- },
- members: [{ object_ref: 'attack-pattern--member' }],
- quarantine: [{ object_ref: 'attack-pattern--quarantined' }],
- })
- );
- component.id = 'release-track--123';
- component.releaseTrack = {
+ it('refreshes the current draft and clamps an emptied virtual page after creation', () => {
+ const draft = new ReleaseTrackSnapshot({
+ id: 'release-track--123',
type: ReleaseTrackType.Virtual,
name: 'Virtual Release',
+ modified: '2024-05-21T07:00:00.000Z',
+ snapshot_description: 'Draft analyst context',
+ members: [{ object_ref: 'attack-pattern--member' }],
+ quarantine: [{ object_ref: 'attack-pattern--quarantined' }],
composition: {
component_tracks: [{ track_id: 'release-track--standard' }],
},
- } as any;
+ });
+ mockDialog.open.mockReturnValue({
+ afterClosed: () => of({ description: 'Draft analyst context' }),
+ });
+ mockReleaseTrackApiConnector.createVirtualSnapshot.mockReturnValue(
+ of(draft)
+ );
+ mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(of(draft));
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of(
+ historyResponse({
+ data: [{ ...draft, members_count: 1, quarantine_count: 1 }],
+ })
+ )
+ );
+ component.id = draft.id;
+ component.releaseTrack = new ReleaseTrackSnapshot({
+ ...draft,
+ modified: '2024-05-20T07:00:00.000Z',
+ version: '1.0',
+ });
+ component.historyPageIndex = 3;
component.onDraft();
- expect(mockDialog.open).toHaveBeenCalledWith(
- SnapshotDescriptionDialogComponent,
- expect.objectContaining({
- data: expect.objectContaining({
- title: 'Create draft snapshot',
- message: expect.stringContaining(
- 'Create a copy of the latest snapshot for Virtual Release'
- ),
- confirmLabel: 'Create draft',
- }),
- })
- );
expect(
mockReleaseTrackApiConnector.createVirtualSnapshot
- ).toHaveBeenCalledWith('release-track--123', {
+ ).toHaveBeenCalledWith(draft.id, {
description: 'Draft analyst context',
});
- expect(refreshSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
- expect(component.hasCurrentDraftSnapshot).toBe(true);
+ expect(component.historyPageIndex).toBe(0);
expect(component.snapshotHistory[0]).toEqual(
expect.objectContaining({
- title: 'Draft Snapshot',
+ modified: '2024-05-21T07:00:00.000Z',
+ isCurrentDraft: true,
totalObjects: 2,
- isTagged: false,
})
);
expect(component.snapshotHistory[0].stats).toEqual([
expect.objectContaining({ label: 'Members', value: 1 }),
expect.objectContaining({ label: 'Quarantine', value: 1 }),
]);
- expect(component.isCreatingDraft).toBe(false);
});
it('should not create a draft snapshot without a release track id', () => {
@@ -1869,9 +2876,9 @@ describe('ReleaseTrackPageComponent', () => {
const refreshSpy = vi
.spyOn(component, 'getReleaseTrack')
.mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ vi.spyOn(component, 'getSnapshotHistory').mockImplementation(
+ () => undefined
+ );
mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
of({
type: ReleaseTrackType.Standard,
@@ -1954,7 +2961,6 @@ describe('ReleaseTrackPageComponent', () => {
);
expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
expect(component.isReleasing).toBe(false);
});
@@ -1962,9 +2968,9 @@ describe('ReleaseTrackPageComponent', () => {
const refreshSpy = vi
.spyOn(component, 'getReleaseTrack')
.mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ vi.spyOn(component, 'getSnapshotHistory').mockImplementation(
+ () => undefined
+ );
mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
of({
type: ReleaseTrackType.Standard,
@@ -2064,7 +3070,6 @@ describe('ReleaseTrackPageComponent', () => {
);
expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
expect(component.isReleasing).toBe(false);
});
@@ -2146,9 +3151,6 @@ describe('ReleaseTrackPageComponent', () => {
});
it('should stop releasing when the preview request fails', () => {
- const consoleSpy = vi
- .spyOn(console, 'error')
- .mockImplementation(() => undefined);
mockReleaseTrackApiConnector.previewRelease.mockReturnValue(
throwError(() => new Error('preview failed'))
);
@@ -2162,36 +3164,7 @@ describe('ReleaseTrackPageComponent', () => {
expect(component.isReleasing).toBe(false);
expect(component.previewingSnapshotModified).toBeNull();
- expect(consoleSpy).toHaveBeenCalledWith(
- 'Failed to load objects for release preview',
- expect.any(Error)
- );
- expect(mockDialog.open).not.toHaveBeenCalled();
- });
-
- it('should notify the user when the preview response is empty', () => {
- mockReleaseTrackApiConnector.previewRelease.mockReturnValue(of(null));
- mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue(
- of({ id: 'release-track--123' })
- );
- component.id = 'release-track--123';
-
- component.onTagSnapshot({
- modified: '2026-07-30T14:00:00.000Z',
- isTagged: false,
- snapshot: {},
- } as any);
-
- expect(mockSnackbar.open).toHaveBeenCalledWith(
- 'Unable to load the release preview. Please try again.',
- null,
- {
- duration: 5000,
- panelClass: 'error',
- }
- );
expect(mockDialog.open).not.toHaveBeenCalled();
- expect(component.isReleasing).toBe(false);
});
it('should describe in-flight work in the activity bar', () => {
@@ -2291,9 +3264,9 @@ describe('ReleaseTrackPageComponent', () => {
const refreshSpy = vi
.spyOn(component, 'getReleaseTrack')
.mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ vi.spyOn(component, 'getSnapshotHistory').mockImplementation(
+ () => undefined
+ );
mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({}));
component.id = 'release-track--123';
component.releaseTrack = { config: {} } as any;
@@ -2336,7 +3309,6 @@ describe('ReleaseTrackPageComponent', () => {
);
expect(component.isEditingConfig).toBe(false);
expect(refreshSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
});
it('should save release track config without a candidacy threshold when auto-promotion is off', () => {
@@ -2411,9 +3383,9 @@ describe('ReleaseTrackPageComponent', () => {
const refreshSpy = vi
.spyOn(component, 'getReleaseTrack')
.mockImplementation(() => undefined);
- const historySpy = vi
- .spyOn(component, 'getSnapshotHistory')
- .mockImplementation(() => undefined);
+ vi.spyOn(component, 'getSnapshotHistory').mockImplementation(
+ () => undefined
+ );
mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({}));
mockReleaseTrackApiConnector.updateSchedule.mockReturnValue(
of({ snapshot_schedule: { mode: SnapshotScheduleMode.Manual } })
@@ -2505,22 +3477,9 @@ describe('ReleaseTrackPageComponent', () => {
'release-track--virtual',
{ mode: SnapshotScheduleMode.Manual }
);
- // Publication settings apply to virtual tracks too and are saved after
- // the composition.
- expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalledWith(
- 'release-track--virtual',
- {
- publication: {
- collection_id: null,
- created: null,
- created_by_ref: { inherit: true },
- object_marking_refs: { inherit: true },
- },
- }
- );
+ expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled();
expect(component.isEditingConfig).toBe(false);
expect(refreshSpy).toHaveBeenCalled();
- expect(historySpy).toHaveBeenCalled();
});
it('saves a priority-only edit without reordering, and restores priorities on cancel', () => {
@@ -2794,6 +3753,10 @@ describe('ReleaseTrackPageComponent', () => {
component.id,
{ mode: SnapshotScheduleMode.Cron, cron: '*/15 * * * *' }
);
+ expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled();
+ expect(
+ mockReleaseTrackApiConnector.createVirtualSnapshot
+ ).not.toHaveBeenCalled();
});
it('should preserve an existing cron expression the guided editor cannot represent', () => {
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
index fd79372a..87c18675 100644
--- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
+++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
@@ -1,13 +1,29 @@
import { Clipboard } from '@angular/cdk/clipboard';
+import { HttpErrorResponse } from '@angular/common/http';
import { SelectionModel } from '@angular/cdk/collections';
-import { Component, OnInit } from '@angular/core';
+import {
+ Component,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewChild,
+} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
+import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
-import { forkJoin, Observable, of } from 'rxjs';
-import { finalize, map, switchMap, take } from 'rxjs/operators';
+import {
+ forkJoin,
+ fromEvent,
+ interval,
+ merge,
+ Observable,
+ of,
+ Subject,
+} from 'rxjs';
+import { finalize, map, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import {
ComponentTrack,
Composition,
@@ -20,6 +36,8 @@ import {
DeduplicationStrategy,
DeduplicationStrategyType,
ExportFormat,
+ DraftCleanupResult,
+ CreateVirtualSnapshotPayload,
ExportFormatType,
MemberSyncBehavior,
MemberSyncBehaviorType,
@@ -39,6 +57,7 @@ import {
ReleaseTrackType,
ResolutionStrategy,
SnapshotScheduleMode,
+ SnapshotHistoryCounts,
SnapshotCreationCause,
SnapshotCreationActor,
SNAPSHOT_CREATION_CAUSE_LABELS,
@@ -66,6 +85,7 @@ import {
} from 'src/app/components/release-review-dialog/release-review-dialog.component';
import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component';
import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component';
+import { CreateDraftDialogComponent } from 'src/app/components/create-draft-dialog/create-draft-dialog.component';
import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service';
import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service';
import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service';
@@ -271,7 +291,7 @@ const VIRTUAL_MONTH_OPTIONS = [
templateUrl: './release-track-page.component.html',
styleUrls: ['./release-track-page.component.scss'],
})
-export class ReleaseTrackPageComponent implements OnInit {
+export class ReleaseTrackPageComponent implements OnInit, OnDestroy {
public id = '';
public releaseTrack: ReleaseTrackSnapshot | null = null;
public showReleasedMembers = false;
@@ -289,6 +309,37 @@ export class ReleaseTrackPageComponent implements OnInit {
public isSavingConfig = false;
public releaseTrackConfig: ReleaseTrackConfig = {};
public snapshotHistory: SnapshotHistoryViewModel[] = [];
+ public historyFilter: 'releases' | 'drafts' | 'all' = 'all';
+ public historyPageIndex = 0;
+ public readonly historyPageSize = 25;
+ public historyTotal = 0;
+ public historyCounts: SnapshotHistoryCounts = {
+ tagged: 0,
+ drafts: 0,
+ total: 0,
+ };
+ public historyMessage = '';
+ public retentionForm: FormGroup;
+ public draftCleanupResults: DraftCleanupResult[] = [];
+ public cleanupMessage = '';
+ public retryingCleanup = new Set();
+ private historyRequest = 0;
+ private trackRequest = 0;
+ private cleanupRequest = 0;
+ private configRequest = 0;
+ private latestSnapshotModified: string | null = null;
+ private latestTaggedSnapshotModified: string | null = null;
+ private selectedTabIndex = 0;
+ private isLoadingTrack = false;
+ private isLoadingCleanup = false;
+ private boardMutations = 0;
+ private destroyed = false;
+ private readonly destroy$ = new Subject();
+ private readonly cancelRequests$ = new Subject();
+ private readonly cancelHistory$ = new Subject();
+ private announcedCleanup = new Set();
+ @ViewChild('cleanupNotification', { static: true })
+ public cleanupNotification!: TemplateRef;
public configForm: FormGroup;
private virtualComponentTrackSummaries = new Map<
string,
@@ -309,7 +360,6 @@ export class ReleaseTrackPageComponent implements OnInit {
this.virtualConfigComponentTracks
);
}
- private createdDraftSnapshot: ReleaseTrackSnapshotHistoryItem | null = null;
private updatingSnapshotDescriptionModified = new Set();
private convertingReleaseModified = new Set();
private deletingDraftModified = new Set();
@@ -397,6 +447,10 @@ export class ReleaseTrackPageComponent implements OnInit {
private clipboard: Clipboard,
private fb: FormBuilder
) {
+ this.retentionForm = this.fb.group({
+ enabled: [false],
+ maxDrafts: [10],
+ });
this.configForm = this.fb.group({
alias: [
'',
@@ -434,9 +488,12 @@ export class ReleaseTrackPageComponent implements OnInit {
virtualScheduleDateMinute: [0],
virtualComponentTrackSearch: [''],
});
- this.configForm.get('autoPromote')?.valueChanges.subscribe(autoPromote => {
- this.syncCandidacyThresholdControl(!!autoPromote);
- });
+ this.configForm
+ .get('autoPromote')
+ ?.valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe(autoPromote => {
+ this.syncCandidacyThresholdControl(!!autoPromote);
+ });
[
'virtualCronCadence',
'virtualCronMinute',
@@ -446,25 +503,127 @@ export class ReleaseTrackPageComponent implements OnInit {
'virtualCronMonthDay',
'virtualCronMonths',
].forEach(controlName => {
- this.configForm.get(controlName)?.valueChanges.subscribe(() => {
- this.virtualCronUsesExistingExpression = false;
- this.configForm
- .get('virtualScheduleSearch')
- ?.reset(null, { emitEvent: false });
- });
+ this.configForm
+ .get(controlName)
+ ?.valueChanges.pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ this.virtualCronUsesExistingExpression = false;
+ this.configForm
+ .get('virtualScheduleSearch')
+ ?.reset(null, { emitEvent: false });
+ });
});
}
ngOnInit(): void {
- this.route.params.subscribe(params => {
- if (this.id !== params.id) this.showReleasedMembers = false;
+ this.route.params.pipe(takeUntil(this.destroy$)).subscribe(params => {
+ if (this.id !== params.id) {
+ this.cancelRequests$.next();
+ this.cancelHistory$.next();
+ this.showReleasedMembers = false;
+ this.historyFilter = 'all';
+ this.historyPageIndex = 0;
+ this.historyTotal = 0;
+ this.historyCounts = { tagged: 0, drafts: 0, total: 0 };
+ this.latestSnapshotModified = null;
+ this.latestTaggedSnapshotModified = null;
+ this.releaseTrack = null;
+ this.snapshotHistory = [];
+ this.draftCleanupResults = [];
+ this.announcedCleanup.clear();
+ this.snackbar.dismiss();
+ this.historyMessage = '';
+ this.cleanupMessage = '';
+ this.isEditingConfig = false;
+ this.isEditingDescription = false;
+ this.retentionForm.markAsPristine();
+ this.isLoadingTrack = false;
+ this.isSavingDescription = false;
+ ++this.trackRequest;
+ ++this.historyRequest;
+ ++this.cleanupRequest;
+ ++this.configRequest;
+ }
this.id = params.id;
if (this.id) {
this.getReleaseTrack();
- this.getSnapshotHistory();
this.getConfig();
}
});
+
+ // Poll only lightweight history/cleanup while Releases is visible. Focus,
+ // visibility and tab entry refresh immediately; paused edits resume on a tick.
+ merge(
+ interval(30_000),
+ fromEvent(window, 'focus'),
+ fromEvent(document, 'visibilitychange')
+ )
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.refreshVisibleHistory());
+ }
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ ++this.trackRequest;
+ ++this.historyRequest;
+ ++this.cleanupRequest;
+ ++this.configRequest;
+ this.cancelRequests$.next();
+ this.cancelRequests$.complete();
+ this.cancelHistory$.next();
+ this.cancelHistory$.complete();
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.snackbar.dismiss();
+ }
+
+ public onSelectedTabChange(index: number): void {
+ this.selectedTabIndex = index;
+ if (index === 1) this.refreshVisibleHistory();
+ }
+
+ private get automaticHistoryRefreshPaused(): boolean {
+ return (
+ this.destroyed ||
+ !this.id ||
+ !this.releaseTrack ||
+ this.selectedTabIndex !== 1 ||
+ document.visibilityState !== 'visible' ||
+ this.isEditingConfig ||
+ this.isEditingDescription ||
+ this.dialog.openDialogs.length > 0 ||
+ this.isLoadingTrack ||
+ this.isLoadingConfig ||
+ this.isCreatingDraft ||
+ this.isReleasing ||
+ this.isDeleting ||
+ this.isSavingConfig ||
+ this.isSavingDescription ||
+ this.boardMutations > 0 ||
+ this.retryingCleanup.size > 0 ||
+ this.updatingSnapshotDescriptionModified.size > 0 ||
+ this.convertingReleaseModified.size > 0 ||
+ this.deletingDraftModified.size > 0 ||
+ this.retaggingReleaseModified.size > 0
+ );
+ }
+
+ private refreshVisibleHistory(): void {
+ if (
+ this.automaticHistoryRefreshPaused ||
+ this.isLoadingSnapshotHistory ||
+ this.isLoadingCleanup
+ )
+ return;
+ this.getSnapshotHistory(true);
+ this.loadDraftCleanup(true);
+ }
+
+ public trackSnapshotHistory(
+ _index: number,
+ item: SnapshotHistoryViewModel
+ ): string | null {
+ return item.modified;
}
public get releaseTrackName(): string {
@@ -532,16 +691,6 @@ export class ReleaseTrackPageComponent implements OnInit {
return (this.releaseTrack as any)?.snapshot_schedule || {};
}
- public get hasCurrentDraftSnapshot(): boolean {
- return this.snapshotHistory.some((item, index) =>
- this.isCurrentDraftHistoryItem(item, index)
- );
- }
-
- public get taggedSnapshotCount(): number {
- return this.snapshotHistory.filter(item => item.isTagged).length;
- }
-
public get resolvedComponentSnapshots(): any[] {
return this.releaseTrack?.composition_resolution?.component_snapshots ?? [];
}
@@ -725,20 +874,6 @@ export class ReleaseTrackPageComponent implements OnInit {
);
}
- private isCurrentDraftHistoryItem(
- item: SnapshotHistoryViewModel,
- index: number
- ): boolean {
- return (
- !item.isTagged &&
- (typeof item.isCurrentDraft === 'boolean'
- ? item.isCurrentDraft
- : typeof item.isLatest === 'boolean'
- ? item.isLatest
- : index === 0)
- );
- }
-
public get canEditReleaseTrack(): boolean {
return this.authenticationService.canEdit();
}
@@ -747,36 +882,180 @@ export class ReleaseTrackPageComponent implements OnInit {
return this.authenticationService.canDelete();
}
+ public get canManageDraftLifecycle(): boolean {
+ return this.authenticationService.isAuthorized([Role.ADMIN]);
+ }
+
+ public get isRetentionValid(): boolean {
+ const value = this.retentionForm.getRawValue();
+ return (
+ !value.enabled ||
+ (Number.isSafeInteger(value.maxDrafts) && value.maxDrafts > 0)
+ );
+ }
+
+ public loadDraftCleanup(background = false): void {
+ if (this.destroyed || !this.isVirtualReleaseTrack) return;
+ const request = ++this.cleanupRequest;
+ this.isLoadingCleanup = true;
+ this.connector
+ .listDraftCleanup(this.id)
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => {
+ if (request === this.cleanupRequest) this.isLoadingCleanup = false;
+ })
+ )
+ .subscribe({
+ next: result => {
+ if (
+ request !== this.cleanupRequest ||
+ (background && this.automaticHistoryRefreshPaused)
+ )
+ return;
+ this.cleanupMessage = '';
+ this.draftCleanupResults = [];
+ result.data.forEach(operation => this.captureDraftCleanup(operation));
+ },
+ error: () => {
+ if (
+ request !== this.cleanupRequest ||
+ (background && this.automaticHistoryRefreshPaused)
+ )
+ return;
+ this.cleanupMessage =
+ 'Unable to load recent cleanup operations. Automatic refresh will retry.';
+ },
+ });
+ }
+
+ public retryDraftCleanup(operation: DraftCleanupResult): void {
+ if (
+ !this.canManageDraftLifecycle ||
+ this.retryingCleanup.has(operation.operation_id)
+ )
+ return;
+ this.retryingCleanup.add(operation.operation_id);
+ this.connector
+ .retryDraftCleanup(this.id, operation.operation_id)
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => {
+ this.retryingCleanup.delete(operation.operation_id);
+ })
+ )
+ .subscribe({
+ next: result => {
+ this.captureDraftCleanup(result);
+ this.getReleaseTrack();
+ },
+ error: err => {
+ this.cleanupMessage =
+ err?.error?.message ||
+ 'Cleanup repair failed. The release is not re-tagged. Refresh to check the existing operation.';
+ this.getReleaseTrack();
+ },
+ });
+ }
+
+ private captureDraftCleanup(result?: DraftCleanupResult): void {
+ if (!result) return;
+ this.draftCleanupResults = this.draftCleanupResults.filter(
+ item => item.operation_id !== result.operation_id
+ );
+ if (result.status !== 'completed') {
+ this.draftCleanupResults.unshift(result);
+ this.cleanupMessage = result.release_committed
+ ? 'Release committed, but draft cleanup is incomplete. Use Retry cleanup; do not tag the release again.'
+ : 'The snapshot operation has deferred draft cleanup. Use Retry cleanup to repair the existing operation.';
+ } else {
+ if (!this.draftCleanupResults.length) this.cleanupMessage = '';
+ if (!this.announcedCleanup.has(result.operation_id)) {
+ this.announcedCleanup.add(result.operation_id);
+ this.snackbar.openFromTemplate(this.cleanupNotification, {
+ data: result,
+ verticalPosition: 'top',
+ horizontalPosition: 'center',
+ announcementMessage: `Draft cleanup completed. ${result.deleted_count} drafts deleted. Tagged releases preserved.`,
+ });
+ }
+ }
+ }
+
+ public dismissCleanupNotification(): void {
+ this.snackbar.dismiss();
+ }
+
+ public onHistoryFilterChange(filter: 'releases' | 'drafts' | 'all'): void {
+ this.historyFilter = filter;
+ this.historyPageIndex = 0;
+ this.snapshotHistory = [];
+ this.historyCounts = { tagged: 0, drafts: 0, total: 0 };
+ this.historyTotal = 0;
+ this.historyMessage = '';
+ this.getSnapshotHistory();
+ }
+
+ public onHistoryPageChange(event: PageEvent): void {
+ this.historyPageIndex = event.pageIndex;
+ this.getSnapshotHistory();
+ }
+
+ private showSnapshotError(err: HttpErrorResponse, fallback: string): void {
+ this.historyMessage =
+ err?.status === 404
+ ? 'This draft may have been removed by retention or release-time cleanup. History will refresh automatically.'
+ : err?.error?.message || fallback;
+ this.snackbar.open(this.historyMessage, 'Dismiss', { duration: 10000 });
+ }
+
public getReleaseTrack(): void {
+ if (this.destroyed || !this.id) return;
+ const request = ++this.trackRequest;
+ this.cancelHistory$.next();
+ ++this.historyRequest;
+ this.isLoadingTrack = true;
this.connector
.getLatestSnapshot(this.id, {
format: ExportFormat.Workbench,
include: 'all',
})
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: res => {
+ if (request !== this.trackRequest) return;
if (res) {
this.setReleaseTrack(res);
} else {
- this.loadReleaseTrackSummary();
+ this.loadReleaseTrackSummary(request);
+ return;
}
- // this.loadCandidates();
+ this.isLoadingTrack = false;
+ },
+ error: () => {
+ if (request === this.trackRequest) this.isLoadingTrack = false;
},
});
}
- private loadReleaseTrackSummary(): void {
+ private loadReleaseTrackSummary(request: number): void {
this.connector
.listReleaseTracks()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: result => {
+ if (request !== this.trackRequest) return;
const track = this.getReleaseTrackList(result).find(
item => this.getReleaseTrackId(item) === this.id
);
if (track) this.setReleaseTrack(track);
+ this.isLoadingTrack = false;
+ },
+ error: () => {
+ if (request === this.trackRequest) this.isLoadingTrack = false;
},
});
}
@@ -789,6 +1068,17 @@ export class ReleaseTrackPageComponent implements OnInit {
if (this.releaseTrack.id && this.releaseTrack.id !== this.id) {
this.id = this.releaseTrack.id;
}
+ this.latestSnapshotModified =
+ this.toIsoString(this.releaseTrack.modified) ?? null;
+ this.latestTaggedSnapshotModified = (
+ this.releaseTrack.version_history ?? []
+ ).reduce(
+ (latest, entry) => {
+ const modified = this.toIsoString(entry.snapshot_id);
+ return modified && (!latest || modified > latest) ? modified : latest;
+ },
+ this.releaseTrack.version ? this.latestSnapshotModified : null
+ );
this.syncAliasControl();
this.hydrateDynamicEntryDates();
@@ -805,6 +1095,7 @@ export class ReleaseTrackPageComponent implements OnInit {
if (this.isVirtualReleaseTrack) {
this.loadVirtualComponentTrackSummaries();
+ this.loadDraftCleanup();
} else {
this.virtualComponentTrackSummaries.clear();
}
@@ -816,10 +1107,6 @@ export class ReleaseTrackPageComponent implements OnInit {
this.setConfig(this.releaseTrack.config);
}
}
- }
-
- private refreshReleaseTrackState(): void {
- this.getReleaseTrack();
this.getSnapshotHistory();
}
@@ -840,7 +1127,7 @@ export class ReleaseTrackPageComponent implements OnInit {
)
)
)
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(results => {
results.forEach(({ entry, modified }) => {
if (modified) {
@@ -893,7 +1180,7 @@ export class ReleaseTrackPageComponent implements OnInit {
prompt
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(confirm => {
if (!confirm) return;
@@ -902,6 +1189,7 @@ export class ReleaseTrackPageComponent implements OnInit {
.deleteReleaseTrack(this.id)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isDeleting = false;
})
@@ -947,7 +1235,7 @@ export class ReleaseTrackPageComponent implements OnInit {
prompt
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(version => {
if (!version || version === currentVersion) return;
@@ -956,6 +1244,7 @@ export class ReleaseTrackPageComponent implements OnInit {
.retagRelease(this.id, modified, { version })
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => this.retaggingReleaseModified.delete(modified))
)
.subscribe({
@@ -965,7 +1254,7 @@ export class ReleaseTrackPageComponent implements OnInit {
null,
{ duration: 5000 }
);
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
},
error: err =>
console.error('Failed to change release version', err),
@@ -1003,7 +1292,7 @@ export class ReleaseTrackPageComponent implements OnInit {
},
})
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(confirm => {
if (!confirm) return;
this.convertingReleaseModified.add(modified);
@@ -1011,6 +1300,7 @@ export class ReleaseTrackPageComponent implements OnInit {
.convertReleaseToDraft(this.id, modified, version)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => this.convertingReleaseModified.delete(modified))
)
.subscribe({
@@ -1020,7 +1310,7 @@ export class ReleaseTrackPageComponent implements OnInit {
null,
{ duration: 5000 }
);
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
},
error: err =>
this.showSnapshotOperationError(
@@ -1059,7 +1349,7 @@ export class ReleaseTrackPageComponent implements OnInit {
},
})
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(confirm => {
if (!confirm) return;
this.deletingDraftModified.add(modified);
@@ -1067,6 +1357,7 @@ export class ReleaseTrackPageComponent implements OnInit {
.deleteSnapshotByModified(this.id, modified)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => this.deletingDraftModified.delete(modified))
)
.subscribe({
@@ -1074,7 +1365,7 @@ export class ReleaseTrackPageComponent implements OnInit {
this.snackbar.open('Draft snapshot deleted.', null, {
duration: 5000,
});
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
},
error: err =>
this.showSnapshotOperationError(err, 'delete this draft'),
@@ -1083,6 +1374,10 @@ export class ReleaseTrackPageComponent implements OnInit {
}
private showSnapshotOperationError(err: any, operation: string): void {
+ if (err?.status === 404) {
+ this.showSnapshotError(err, `Unable to ${operation}.`);
+ return;
+ }
const dependents = err?.error?.dependent_snapshots;
this.snackbar.open(
Array.isArray(dependents) && dependents.length
@@ -1093,45 +1388,105 @@ export class ReleaseTrackPageComponent implements OnInit {
);
}
- public getSnapshotHistory(): void {
- if (!this.id) return;
-
+ public getSnapshotHistory(background = false): void {
+ if (!this.id || this.destroyed) return;
+ this.cancelHistory$.next();
+ const request = ++this.historyRequest;
+ const virtual = this.isVirtualReleaseTrack;
+ const options = virtual
+ ? {
+ tagged:
+ this.historyFilter === 'all'
+ ? undefined
+ : this.historyFilter === 'releases',
+ limit: this.historyPageSize,
+ offset: this.historyPageIndex * this.historyPageSize,
+ }
+ : {};
this.isLoadingSnapshotHistory = true;
- this.connector
- .listSnapshots(this.id)
+ const history = virtual
+ ? this.connector.listSnapshots(this.id, options)
+ : this.connector.listSnapshots(this.id);
+ history
.pipe(
take(1),
+ takeUntil(this.cancelHistory$),
+ takeUntil(this.cancelRequests$),
finalize(() => {
- this.isLoadingSnapshotHistory = false;
+ if (request === this.historyRequest)
+ this.isLoadingSnapshotHistory = false;
})
)
.subscribe({
next: result => {
- const snapshots = Array.isArray(result) ? result : result?.data || [];
- this.snapshotHistory = this.buildSnapshotHistory(
- this.withCreatedDraftSnapshot(snapshots)
- );
+ if (
+ request !== this.historyRequest ||
+ (background && this.automaticHistoryRefreshPaused)
+ )
+ return;
+ this.historyMessage = '';
+ this.latestSnapshotModified = result.latest_snapshot_modified;
+ this.latestTaggedSnapshotModified =
+ result.latest_tagged_snapshot_modified;
+ this.historyTotal = result.pagination.total;
+ if (virtual) {
+ this.historyCounts = result.counts;
+ const lastPage = Math.max(
+ 0,
+ Math.ceil(this.historyTotal / this.historyPageSize) - 1
+ );
+ if (this.historyPageIndex > lastPage) {
+ this.historyPageIndex = lastPage;
+ this.getSnapshotHistory(background);
+ return;
+ }
+ }
+ this.snapshotHistory = this.buildSnapshotHistory(result.data);
+ if (!virtual) {
+ // Standard history intentionally hides retained rollback-source drafts.
+ const tagged = this.snapshotHistory.filter(
+ item => item.isTagged
+ ).length;
+ const total = this.snapshotHistory.length;
+ this.historyCounts = { tagged, drafts: total - tagged, total };
+ }
},
error: err => {
- console.error('Failed to load release track snapshot history', err);
+ if (
+ request !== this.historyRequest ||
+ (background && this.automaticHistoryRefreshPaused)
+ )
+ return;
+ if (background) {
+ this.historyMessage =
+ 'Unable to load snapshot history. Automatic refresh will retry.';
+ } else {
+ this.showSnapshotError(
+ err,
+ 'Unable to load snapshot history. Automatic refresh will retry.'
+ );
+ }
},
});
}
public getConfig(): void {
- if (!this.id) return;
+ if (!this.id || this.destroyed) return;
+ const request = ++this.configRequest;
this.isLoadingConfig = true;
this.connector
.getConfig(this.id)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
- this.isLoadingConfig = false;
+ if (request === this.configRequest) this.isLoadingConfig = false;
})
)
.subscribe({
next: config => {
+ if (request !== this.configRequest) return;
if (!this.isEditingConfig) {
if (this.isVirtualReleaseTrack) {
this.setVirtualConfig();
@@ -1201,24 +1556,35 @@ export class ReleaseTrackPageComponent implements OnInit {
maxHeight: '85vh',
});
- dialogRef.afterClosed().subscribe({
- next: result => {
- if (!result || !selection.selected.length) return;
+ dialogRef
+ .afterClosed()
+ .pipe(take(1), takeUntil(this.cancelRequests$))
+ .subscribe({
+ next: result => {
+ if (!result || !selection.selected.length) return;
- const objectRefs = selection.selected.map(
- id => selectedObjectRefs.get(id) || id
- );
+ const objectRefs = selection.selected.map(
+ id => selectedObjectRefs.get(id) || id
+ );
- this.connector.addCandidates(this.id, objectRefs).subscribe({
- next: () => {
- this.refreshReleaseTrackState();
- },
- error: err => {
- console.error('Failed to add candidates', err);
- },
- });
- },
- });
+ ++this.boardMutations;
+ this.connector
+ .addCandidates(this.id, objectRefs)
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => --this.boardMutations)
+ )
+ .subscribe({
+ next: () => {
+ this.getReleaseTrack();
+ },
+ error: err => {
+ console.error('Failed to add candidates', err);
+ },
+ });
+ },
+ });
}
public getViewUrl(stixId: string): string | null {
@@ -1229,15 +1595,22 @@ export class ReleaseTrackPageComponent implements OnInit {
public promote(objectIds: string[]): void {
if (!objectIds.length) return;
- const sub = this.connector.promoteCandidates(this.id, objectIds).subscribe({
- next: () => {
- this.refreshReleaseTrackState();
- },
- error: err => {
- console.error('Failed to promote candidates', err);
- },
- complete: () => sub.unsubscribe(),
- });
+ ++this.boardMutations;
+ this.connector
+ .promoteCandidates(this.id, objectIds)
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => --this.boardMutations)
+ )
+ .subscribe({
+ next: () => {
+ this.getReleaseTrack();
+ },
+ error: err => {
+ console.error('Failed to promote candidates', err);
+ },
+ });
}
public onPromoteAll(): void {
@@ -1255,15 +1628,22 @@ export class ReleaseTrackPageComponent implements OnInit {
public demote(objectRefs: StixObjectRef[]): void {
if (!objectRefs.length) return;
- const sub = this.connector.demoteStaged(this.id, objectRefs).subscribe({
- next: () => {
- this.refreshReleaseTrackState();
- },
- error: err => {
- console.error('Failed to demote staged objects', err);
- },
- complete: () => sub.unsubscribe(),
- });
+ ++this.boardMutations;
+ this.connector
+ .demoteStaged(this.id, objectRefs)
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => --this.boardMutations)
+ )
+ .subscribe({
+ next: () => {
+ this.getReleaseTrack();
+ },
+ error: err => {
+ console.error('Failed to demote staged objects', err);
+ },
+ });
}
public onDemoteAll(): void {
@@ -1332,36 +1712,38 @@ export class ReleaseTrackPageComponent implements OnInit {
: this.findMemberEntry(item.object_ref);
const relationshipAddedAfter =
this.getDiffObjectModified(relationshipBaseline);
- diff.pipe(take(1)).subscribe(({ current, prior, expectedBaseline }) => {
- if (!current) {
- this.snackbar.open(
- 'Unable to load the current object version.',
- undefined,
- {
- duration: 3000,
- }
- );
- return;
- }
+ diff
+ .pipe(take(1), takeUntil(this.cancelRequests$))
+ .subscribe(({ current, prior, expectedBaseline }) => {
+ if (!current) {
+ this.snackbar.open(
+ 'Unable to load the current object version.',
+ undefined,
+ {
+ duration: 3000,
+ }
+ );
+ return;
+ }
- if (expectedBaseline && !prior) {
- this.snackbar.open(
- 'Unable to load the comparison baseline version.',
- undefined,
- {
- duration: 3000,
- }
- );
- return;
- }
+ if (expectedBaseline && !prior) {
+ this.snackbar.open(
+ 'Unable to load the comparison baseline version.',
+ undefined,
+ {
+ duration: 3000,
+ }
+ );
+ return;
+ }
- this.openDiffDialog(
- current,
- prior,
- tier === 'staged' ? this.getDiffObjectModified(item) : undefined,
- relationshipAddedAfter
- );
- });
+ this.openDiffDialog(
+ current,
+ prior,
+ tier === 'staged' ? this.getDiffObjectModified(item) : undefined,
+ relationshipAddedAfter
+ );
+ });
}
public onReviewAndApprove(item: any): void {
@@ -1394,7 +1776,7 @@ export class ReleaseTrackPageComponent implements OnInit {
},
})
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(confirmed => {
if (!confirmed) return;
this.applyReviewResult({
@@ -1523,9 +1905,10 @@ export class ReleaseTrackPageComponent implements OnInit {
this.isSavingDescription = true;
this.connector
.updateMetadataByLatest(this.id, { description })
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
- next: () => {
+ next: result => {
+ this.captureDraftCleanup(result?.draft_cleanup);
this.isEditingDescription = false;
this.descriptionDraft = '';
this.getReleaseTrack();
@@ -1758,7 +2141,7 @@ export class ReleaseTrackPageComponent implements OnInit {
private loadVirtualComponentTrackSummaries(): void {
this.connector
.listReleaseTracks()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: result => {
const summaries = new Map();
@@ -2015,44 +2398,41 @@ export class ReleaseTrackPageComponent implements OnInit {
if (!this.canCreateDraft) return;
const trackName = this.releaseTrackName || this.id || 'this release track';
- const dialogRef = this.dialog.open(SnapshotDescriptionDialogComponent, {
+ const dialogRef = this.dialog.open(CreateDraftDialogComponent, {
autoFocus: false,
data: {
- title: 'Create draft snapshot',
- message: `Create a copy of the latest snapshot for ${trackName}. Add optional notes so other analysts can understand this draft in history.`,
+ trackName,
description: this.releaseTrack?.snapshot_description || '',
- confirmLabel: 'Create draft',
+ canManageRetention: this.canManageDraftLifecycle,
},
});
- dialogRef.afterClosed().subscribe(description => {
- if (
- description === undefined ||
- !this.id ||
- !this.isVirtualReleaseTrack
- ) {
- return;
- }
- this.createVirtualDraftSnapshot(description);
- });
+ dialogRef
+ .afterClosed()
+ .pipe(take(1), takeUntil(this.cancelRequests$))
+ .subscribe((payload?: CreateVirtualSnapshotPayload) => {
+ if (!payload || !this.id || !this.isVirtualReleaseTrack) return;
+ this.createVirtualDraftSnapshot(payload);
+ });
}
- private createVirtualDraftSnapshot(description: string): void {
+ private createVirtualDraftSnapshot(
+ payload: CreateVirtualSnapshotPayload
+ ): void {
this.isCreatingDraft = true;
this.connector
- .createVirtualSnapshot(this.id, {
- description,
- })
+ .createVirtualSnapshot(this.id, payload)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isCreatingDraft = false;
})
)
.subscribe({
next: snapshot => {
- this.addCreatedDraftSnapshotToHistory(snapshot);
- this.refreshReleaseTrackState();
+ this.captureDraftCleanup(snapshot?.draft_cleanup);
+ this.getReleaseTrack();
},
error: err => {
console.error('Failed to create draft snapshot', err);
@@ -2060,87 +2440,17 @@ export class ReleaseTrackPageComponent implements OnInit {
});
}
- private addCreatedDraftSnapshotToHistory(snapshot: any): void {
- const draftSnapshot = this.getCreatedDraftSnapshot(snapshot);
- if (!draftSnapshot) return;
-
- this.createdDraftSnapshot = draftSnapshot;
- this.snapshotHistory = this.buildSnapshotHistory(
- this.withCreatedDraftSnapshot(
- this.snapshotHistory.map(item => item.snapshot)
- )
- );
- }
-
- private getCreatedDraftSnapshot(
- snapshot: any
- ): ReleaseTrackSnapshotHistoryItem | null {
- const draftSnapshot = snapshot?.data || snapshot?.snapshot || snapshot;
- if (!draftSnapshot || this.isTaggedSnapshot(draftSnapshot)) return null;
-
- const modified = this.getSnapshotModified(draftSnapshot);
- if (!modified) return null;
- const resolvedTotalObjects =
- typeof draftSnapshot.composition_resolution?.total_objects === 'number'
- ? draftSnapshot.composition_resolution.total_objects
- : null;
- const membersCount =
- this.getSnapshotCount(draftSnapshot, 'members_count') ??
- this.getSnapshotMembers(draftSnapshot).length;
- const quarantineCount =
- this.getSnapshotCount(
- draftSnapshot,
- 'quarantine_count',
- 'quarantined_count'
- ) ?? this.getSnapshotQuarantineCount(draftSnapshot);
- const shouldUseResolvedTotal =
- !membersCount && !quarantineCount && resolvedTotalObjects !== null;
-
- return {
- ...draftSnapshot,
- modified,
- version: null,
- type: ReleaseTrackType.Virtual,
- members_count: shouldUseResolvedTotal
- ? resolvedTotalObjects
- : membersCount,
- quarantine_count: quarantineCount,
- };
- }
-
- private withCreatedDraftSnapshot(
- snapshots: ReleaseTrackSnapshotHistoryItem[]
- ): ReleaseTrackSnapshotHistoryItem[] {
- if (!this.createdDraftSnapshot) return snapshots;
-
- const createdDraftModified = this.getSnapshotModified(
- this.createdDraftSnapshot
- );
- const draftAlreadyLoaded = snapshots.some(
- snapshot => this.getSnapshotModified(snapshot) === createdDraftModified
- );
-
- if (draftAlreadyLoaded) {
- this.createdDraftSnapshot = null;
- return snapshots;
- }
-
- return [...snapshots, this.createdDraftSnapshot];
- }
-
public onEditConfig(): void {
if (this.isVirtualReleaseTrack) this.setVirtualConfig();
+ this.setConfig(this.releaseTrackConfig);
this.loadPublicationOptions();
this.syncAliasControl();
this.isEditingConfig = true;
}
public onCancelConfigEdit(): void {
- if (this.isVirtualReleaseTrack) {
- this.setVirtualConfig();
- } else {
- this.setConfig(this.releaseTrackConfig);
- }
+ if (this.isVirtualReleaseTrack) this.setVirtualConfig();
+ this.setConfig(this.releaseTrackConfig);
this.syncAliasControl();
this.isEditingConfig = false;
}
@@ -2202,13 +2512,14 @@ export class ReleaseTrackPageComponent implements OnInit {
private withAliasUpdate(next: () => Observable): Observable {
const update = this.getAliasUpdate();
if (!update) return next();
- return this.connector
- .updateMetadataByLatest(this.id, update)
- .pipe(switchMap(() => next()));
+ return this.connector.updateMetadataByLatest(this.id, update).pipe(
+ tap(result => this.captureDraftCleanup(result?.draft_cleanup)),
+ switchMap(() => next())
+ );
}
public onSaveConfig(): void {
- if (!this.id || this.isSavingConfig) return;
+ if (!this.id || !this.isEditingConfig || this.isSavingConfig) return;
const aliasControl = this.configForm.get('alias');
if (aliasControl?.invalid) {
aliasControl.markAsTouched();
@@ -2227,6 +2538,7 @@ export class ReleaseTrackPageComponent implements OnInit {
this.withAliasUpdate(() => this.connector.updateConfig(this.id, payload))
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isSavingConfig = false;
})
@@ -2237,7 +2549,7 @@ export class ReleaseTrackPageComponent implements OnInit {
this.setConfig(this.getConfigFromResponse(result, payload));
if (this.releaseTrack)
this.releaseTrack.config = this.releaseTrackConfig;
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
this.getConfig();
if (aliasChanged) this.getReleaseTrack();
},
@@ -2280,6 +2592,7 @@ export class ReleaseTrackPageComponent implements OnInit {
preview
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isReleasing = false;
this.previewingSnapshotModified = null;
@@ -2289,7 +2602,7 @@ export class ReleaseTrackPageComponent implements OnInit {
next: result => {
if (!result.preview || !result.track) {
this.snackbar.open(
- 'Unable to load the release preview. Please try again.',
+ 'This snapshot may no longer exist. History will refresh automatically.',
null,
{
duration: 5000,
@@ -2306,7 +2619,10 @@ export class ReleaseTrackPageComponent implements OnInit {
);
},
error: err => {
- console.error('Failed to load objects for release preview', err);
+ this.showSnapshotError(
+ err,
+ 'Unable to load the release preview. Try again after history refreshes.'
+ );
},
});
}
@@ -2316,7 +2632,7 @@ export class ReleaseTrackPageComponent implements OnInit {
const modified = item.modified;
this.openExportFormatDialog('Export release track snapshot', true)
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(choice => {
if (!choice) return;
if (choice === 'copy-summary') {
@@ -2419,7 +2735,7 @@ export class ReleaseTrackPageComponent implements OnInit {
dialogRef
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe((description: string | undefined) => {
if (description === undefined) return;
@@ -2428,6 +2744,7 @@ export class ReleaseTrackPageComponent implements OnInit {
.updateSnapshotDescription(this.id, modified, { description })
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.updatingSnapshotDescriptionModified.delete(modified);
})
@@ -2443,12 +2760,6 @@ export class ReleaseTrackPageComponent implements OnInit {
) {
Object.assign(this.releaseTrack, snapshot);
}
- if (
- this.createdDraftSnapshot &&
- this.getSnapshotModified(this.createdDraftSnapshot) === modified
- ) {
- Object.assign(this.createdDraftSnapshot, snapshot);
- }
this.snackbar.open(
updatedDescription
@@ -2459,11 +2770,9 @@ export class ReleaseTrackPageComponent implements OnInit {
);
},
error: err => {
- console.error('Failed to update snapshot notes', err);
- this.snackbar.open(
- 'Unable to save snapshot notes. Please try again.',
- null,
- { duration: 5000, panelClass: 'error' }
+ this.showSnapshotError(
+ err,
+ 'Unable to save snapshot notes. Please try again.'
);
},
});
@@ -2482,7 +2791,7 @@ export class ReleaseTrackPageComponent implements OnInit {
selection.format,
this.getSnapshotExportOptions(selection)
)
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: result => {
this.restApiConnectorService.triggerBrowserDownload(
@@ -2491,7 +2800,10 @@ export class ReleaseTrackPageComponent implements OnInit {
);
},
error: err => {
- console.error('Failed to export release track snapshot', err);
+ this.showSnapshotError(
+ err,
+ 'Unable to export this snapshot. Try again after history refreshes.'
+ );
},
});
}
@@ -2562,7 +2874,7 @@ export class ReleaseTrackPageComponent implements OnInit {
)
)
)
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: resolvedItems => {
const reviewItems = resolvedItems.filter(
@@ -2623,7 +2935,7 @@ export class ReleaseTrackPageComponent implements OnInit {
restoreFocus: true,
})
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe(result => this.applyReviewResult(result));
}
@@ -2655,14 +2967,19 @@ export class ReleaseTrackPageComponent implements OnInit {
if (!requests.length) return;
+ ++this.boardMutations;
forkJoin(requests)
- .pipe(take(1))
+ .pipe(
+ take(1),
+ takeUntil(this.cancelRequests$),
+ finalize(() => --this.boardMutations)
+ )
.subscribe({
next: () => {
this.snackbar.open('Review updates saved.', undefined, {
duration: 3000,
});
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
},
error: err => {
console.error('Failed to save release track review', err);
@@ -2670,7 +2987,7 @@ export class ReleaseTrackPageComponent implements OnInit {
duration: 5000,
panelClass: 'error',
});
- this.refreshReleaseTrackState();
+ this.getReleaseTrack();
},
});
}
@@ -2993,6 +3310,7 @@ export class ReleaseTrackPageComponent implements OnInit {
if (value.virtualSnapshotScheduleMode !== SnapshotScheduleMode.Cron) {
return true;
}
+ if (this.canManageDraftLifecycle && !this.isRetentionValid) return false;
if (
typeof this.configForm.get('virtualScheduleSearch')?.value === 'string' &&
this.configForm.get('virtualScheduleSearch')?.value.trim()
@@ -3095,6 +3413,14 @@ export class ReleaseTrackPageComponent implements OnInit {
private setVirtualConfig(): void {
const deduplication = this.releaseTrack?.composition?.deduplication || {};
const snapshotSchedule = this.virtualSnapshotSchedule;
+ const maxDrafts =
+ snapshotSchedule.mode === SnapshotScheduleMode.Cron
+ ? snapshotSchedule.draft_retention?.max_drafts
+ : null;
+ this.retentionForm.reset({
+ enabled: maxDrafts != null,
+ maxDrafts: maxDrafts ?? 10,
+ });
this.virtualConfigComponentTracks = this.cloneVirtualComponentTracks(
this.virtualComponentTracks
);
@@ -3233,6 +3559,14 @@ export class ReleaseTrackPageComponent implements OnInit {
);
const aliasChanged = !!this.getAliasUpdate();
const compositionChanged = this.hasVirtualCompositionChanges(payload);
+ const schedule = this.getVirtualSchedulePayload();
+ const scheduleChanged =
+ JSON.stringify(schedule) !== JSON.stringify(this.virtualSnapshotSchedule);
+ const savedPublication = this.getPublicationPayload(
+ this.getConfigFormValue(this.releaseTrackConfig)
+ );
+ const publicationChanged =
+ JSON.stringify(publication) !== JSON.stringify(savedPublication);
this.isSavingConfig = true;
this.withAliasUpdate(() =>
compositionChanged
@@ -3242,23 +3576,28 @@ export class ReleaseTrackPageComponent implements OnInit {
.pipe(
take(1),
switchMap(result =>
- this.connector
- .updateSchedule(this.id, this.getVirtualSchedulePayload())
- .pipe(map(scheduleResult => ({ result, scheduleResult })))
+ (scheduleChanged
+ ? this.connector.updateSchedule(this.id, schedule)
+ : of({ snapshot_schedule: schedule })
+ ).pipe(map(scheduleResult => ({ result, scheduleResult })))
),
switchMap(({ result, scheduleResult }) =>
- this.connector
- .updateConfig(this.id, { publication })
- .pipe(
- map(configResult => ({ result, scheduleResult, configResult }))
- )
+ (publicationChanged
+ ? this.connector.updateConfig(this.id, { publication })
+ : of(null)
+ ).pipe(
+ map(configResult => ({ result, scheduleResult, configResult }))
+ )
),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isSavingConfig = false;
})
)
.subscribe({
next: ({ result, scheduleResult, configResult }) => {
+ this.captureDraftCleanup(result?.draft_cleanup);
+ this.captureDraftCleanup(configResult?.draft_cleanup);
this.isEditingConfig = false;
if (this.releaseTrack) {
this.releaseTrack.composition = this.getCompositionFromResponse(
@@ -3266,13 +3605,14 @@ export class ReleaseTrackPageComponent implements OnInit {
payload
);
this.releaseTrack.snapshot_schedule =
- scheduleResult?.snapshot_schedule ||
- this.getVirtualSchedulePayload();
+ scheduleResult?.snapshot_schedule || schedule;
}
- this.setConfig(
- this.getConfigFromResponse(configResult, { publication })
- );
- this.refreshReleaseTrackState();
+ if (publicationChanged) {
+ this.setConfig(
+ this.getConfigFromResponse(configResult, { publication })
+ );
+ }
+ this.getReleaseTrack();
this.getConfig();
if (aliasChanged) this.getReleaseTrack();
},
@@ -3286,11 +3626,25 @@ export class ReleaseTrackPageComponent implements OnInit {
const value =
this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue;
switch (value.virtualSnapshotScheduleMode) {
- case SnapshotScheduleMode.Cron:
+ case SnapshotScheduleMode.Cron: {
+ const saved =
+ this.virtualSnapshotSchedule.mode === SnapshotScheduleMode.Cron
+ ? this.virtualSnapshotSchedule.draft_retention
+ : undefined;
+ const retention = this.retentionForm.getRawValue();
+ const policy = this.canManageDraftLifecycle
+ ? retention.enabled
+ ? { max_drafts: retention.maxDrafts }
+ : saved
+ ? { max_drafts: null }
+ : undefined
+ : saved;
return {
mode: SnapshotScheduleMode.Cron,
cron: this.virtualCronExpression,
+ ...(policy ? { draft_retention: policy } : {}),
};
+ }
case SnapshotScheduleMode.Dates:
return {
mode: SnapshotScheduleMode.Dates,
@@ -3505,7 +3859,7 @@ export class ReleaseTrackPageComponent implements OnInit {
if (typeof service?.getAllIdentities === 'function') {
service
.getAllIdentities()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: (result: any) => {
this.publicationIdentityOptions = (result?.data ?? []).map(
@@ -3526,7 +3880,7 @@ export class ReleaseTrackPageComponent implements OnInit {
if (typeof service?.getAllMarkingDefinitions === 'function') {
service
.getAllMarkingDefinitions()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe({
next: (result: any) => {
this.publicationMarkingOptions = (result?.data ?? []).map(
@@ -3626,12 +3980,14 @@ export class ReleaseTrackPageComponent implements OnInit {
conflicts: this.getReleaseConflicts(preview),
proposedMinorVersion: preview?.version,
previewSummary: preview,
+ canSquashDrafts:
+ this.isVirtualReleaseTrack && this.canManageDraftLifecycle,
},
});
releaseRef
.afterClosed()
- .pipe(take(1))
+ .pipe(take(1), takeUntil(this.cancelRequests$))
.subscribe((selection: ReleasePreviewSelection | undefined) => {
if (!selection) return;
this.releaseSnapshot(selection, item);
@@ -3672,19 +4028,37 @@ export class ReleaseTrackPageComponent implements OnInit {
.releaseSnapshot(this.id, item.modified, selection)
.pipe(
take(1),
+ takeUntil(this.cancelRequests$),
finalize(() => {
this.isReleasing = false;
})
)
.subscribe({
- next: () => {
- if (item.modified === this.createdDraftSnapshot?.modified) {
- this.createdDraftSnapshot = null;
- }
- this.refreshReleaseTrackState();
+ next: result => {
+ this.captureDraftCleanup(result?.draft_cleanup);
+ this.getReleaseTrack();
},
error: err => {
- console.error('Failed to tag release track snapshot', err);
+ const details = { ...err?.error?.details, ...err?.error };
+ this.captureDraftCleanup(details.draft_cleanup);
+ if (details.release_committed || details.operation_id) {
+ this.cleanupMessage = details.release_committed
+ ? `Release committed, but completion requires repair. Do not tag again. Cleanup operation: ${details.operation_id || 'see recent operations'}.`
+ : `Release did not complete. Review cleanup operation ${details.operation_id} before trying again.`;
+ this.getReleaseTrack();
+ } else if (err?.status === 409) {
+ this.historyMessage =
+ 'The release preview is stale or the track changed. Open a new preview before tagging.';
+ this.snackbar.open(this.historyMessage, 'Dismiss', {
+ duration: 10000,
+ });
+ } else {
+ this.showSnapshotError(
+ err,
+ 'Unable to tag this snapshot. Check its state after history refreshes before trying again.'
+ );
+ this.loadDraftCleanup();
+ }
},
});
}
@@ -3705,19 +4079,23 @@ export class ReleaseTrackPageComponent implements OnInit {
modified instanceof Date ? modified.toISOString() : String(modified)
)
);
- const visibleSnapshots = snapshots.filter(snapshot => {
- if (this.isTaggedSnapshot(snapshot)) return true;
- const modified = this.getSnapshotModified(snapshot);
- return !modified || !retainedSourceDrafts.has(modified);
- });
+ const visibleSnapshots = this.isVirtualReleaseTrack
+ ? snapshots
+ : snapshots.filter(snapshot => {
+ if (this.isTaggedSnapshot(snapshot)) return true;
+ const modified = this.getSnapshotModified(snapshot);
+ return !modified || !retainedSourceDrafts.has(modified);
+ });
const sorted = [...visibleSnapshots].sort(
(a, b) => this.getSnapshotTime(b) - this.getSnapshotTime(a)
);
- const latestSnapshot =
- sorted.find(snapshot => this.isLatestHistorySnapshot(snapshot)) ??
- sorted[0];
- const latestRelease = sorted.find(snapshot =>
- this.isTaggedSnapshot(snapshot)
+ const latestSnapshot = sorted.find(
+ snapshot =>
+ this.getSnapshotModified(snapshot) === this.latestSnapshotModified
+ );
+ const latestRelease = sorted.find(
+ snapshot =>
+ this.getSnapshotModified(snapshot) === this.latestTaggedSnapshotModified
);
return sorted.map((snapshot, index) => {
@@ -4140,7 +4518,7 @@ export class ReleaseTrackPageComponent implements OnInit {
private isLatestHistorySnapshot(
snapshot: ReleaseTrackSnapshotHistoryItem
): boolean {
- if (snapshot.is_latest) return true;
+ if (!this.isVirtualReleaseTrack && snapshot.is_latest) return true;
if (!this.releaseTrack?.modified) return false;
return (
this.getSnapshotModified(snapshot) ===