diff --git a/package-lock.json b/package-lock.json
index 095df2649..89fd695a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"@angular/platform-browser": "^19.2.25",
"@angular/platform-browser-dynamic": "^19.2.25",
"@angular/router": "^19.2.25",
- "@mitre-attack/attack-data-model": "^5.0.0-beta.1",
+ "@mitre-attack/attack-data-model": "^5.0.0-beta.3",
"@ng-matero/extensions": "^19.5.1",
"diff-match-patch-ts": "^0.8.0",
"lodash": "^4.17.21",
diff --git a/package.json b/package.json
index 06cd2a01e..a82b78af6 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
"@angular/forms": "^19.2.25",
"@angular/material": "^19.2.16",
"@angular/material-moment-adapter": "^19.2.16",
- "@mitre-attack/attack-data-model": "^5.0.0-beta.1",
+ "@mitre-attack/attack-data-model": "^5.0.0-beta.3",
"@angular/platform-browser": "^19.2.25",
"@angular/platform-browser-dynamic": "^19.2.25",
"@angular/router": "^19.2.25",
diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts
index 93e663156..7ec844bef 100644
--- a/src/app/classes/release-tracks/snapshot.ts
+++ b/src/app/classes/release-tracks/snapshot.ts
@@ -2,6 +2,7 @@ import { Composition, CompositionResolution } from './composition';
import { ReleaseTrackConfig } from './config';
import { ReleaseTrackType } from './enums';
import { VersionHistoryEntry } from './history';
+import { SnapshotSchedule } from './release-track';
import {
CandidateEntry,
MemberEntry,
@@ -34,6 +35,7 @@ export class ReleaseTrackSnapshot {
// virtual track composition
public composition?: Composition;
public composition_resolution?: CompositionResolution;
+ public snapshot_schedule?: SnapshotSchedule;
constructor(raw?: any) {
if (raw) this.deserialize(raw);
@@ -105,15 +107,21 @@ export class ReleaseTrackSnapshot {
if ('members' in raw && Array.isArray(raw.members)) {
this.members = raw.members.map((m: any) => ({
+ ...m,
object_ref: m.object_ref,
- object_modified: new Date(m.object_modified),
+ object_modified: m.object_modified
+ ? new Date(m.object_modified)
+ : undefined,
}));
}
if ('staged' in raw && Array.isArray(raw.staged)) {
this.staged = raw.staged.map((s: any) => ({
+ ...s,
object_ref: s.object_ref,
- object_modified: new Date(s.object_modified),
+ object_modified: s.object_modified
+ ? new Date(s.object_modified)
+ : undefined,
object_status: s.object_status,
object_staged_at: s.object_staged_at
? new Date(s.object_staged_at)
@@ -124,8 +132,11 @@ export class ReleaseTrackSnapshot {
if ('candidates' in raw && Array.isArray(raw.candidates)) {
this.candidates = raw.candidates.map((c: any) => ({
+ ...c,
object_ref: c.object_ref,
- object_modified: new Date(c.object_modified),
+ object_modified: c.object_modified
+ ? new Date(c.object_modified)
+ : undefined,
object_status: c.object_status,
object_added_at: c.object_added_at
? new Date(c.object_added_at)
@@ -136,8 +147,11 @@ export class ReleaseTrackSnapshot {
if ('quarantine' in raw && Array.isArray(raw.quarantine)) {
this.quarantine = raw.quarantine.map((q: any) => ({
+ ...q,
object_ref: q.object_ref,
- object_modified: new Date(q.object_modified),
+ object_modified: q.object_modified
+ ? new Date(q.object_modified)
+ : undefined,
source_track_id: q.source_track_id,
source_track_name: q.source_track_name,
source_snapshot_version: q.source_snapshot_version,
@@ -147,6 +161,9 @@ export class ReleaseTrackSnapshot {
if ('composition' in raw) this.composition = raw.composition;
+ if ('snapshot_schedule' in raw)
+ this.snapshot_schedule = raw.snapshot_schedule;
+
if ('composition_resolution' in raw && raw.composition_resolution) {
const cr = raw.composition_resolution;
const resolved: CompositionResolution = { ...cr } as any;
@@ -219,6 +236,7 @@ export class ReleaseTrackSnapshot {
})),
composition: this.composition,
composition_resolution: this.composition_resolution,
+ snapshot_schedule: this.snapshot_schedule,
};
}
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 27010867a..f137f310a 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
@@ -84,63 +84,159 @@
-
-
Object Deduplication
+
+
Component Tracks
- Configure deduplication rules for the virtual track
+ Standard tracks to include in this virtual release track.
-
- Strategy
-
- {{ formatOption(opt) }}
-
- How conflicting objects from component tracks are
- resolved
-
+
+
+ Loading component tracks
+
+
+
+ No standard release tracks found.
+
-
+
- Snapshot Tier
-
+ Add component track
+
+ arrow_drop_down
+
{{ formatOption(opt) }}
-
+ *ngFor="let track of filteredComponentTrackOptions"
+ [value]="track">
+
+
+ {{ track.name }}
+
+
+ ({{ getComponentTrackSnapshotLabel(track) }})
+
+
+
+
+ No matching standard tracks
+
+
- Prefer objects from this tier when resolving conflicts
+ Select one or more standard tracks to include in this virtual
+ track.
+
+
+
+
+
+
+ {{ track.description }}
+
+
+
+ Object type filter
+
+
+ {{ opt.label }}
+
+
+ Leave empty to include all object types
+
+
+
+
+
+
+
+
Object Deduplication
- Workflow Status
-
+ Strategy
+
{{ formatOption(opt) }}
-
- Prefer objects with this status when resolving conflicts
-
+
+
+
+ Snapshot Tier
+
+ {{ formatOption(opt) }}
+
+
+ Prefer objects from this tier when resolving conflicts
+
+
+
+
+ Workflow Status
+
+ {{ formatOption(opt) }}
+
+
+ Prefer objects with this status when resolving conflicts
+
+
+
-
+
Snapshot Schedule
Configure automatic snapshot scheduling for this virtual track
diff --git a/src/app/components/new-track-dialog/new-track-dialog.component.scss b/src/app/components/new-track-dialog/new-track-dialog.component.scss
index aec5343ed..06a22e3ea 100644
--- a/src/app/components/new-track-dialog/new-track-dialog.component.scss
+++ b/src/app/components/new-track-dialog/new-track-dialog.component.scss
@@ -28,15 +28,36 @@
&.gap-12 {
gap: 12px;
}
+
+ &.stack {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 4px;
+ }
}
.section-title {
font-weight: 600;
- color: rgba(0, 0, 0, 0.85);
+
+ .light & {
+ color: colors.on-color-emphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-emphasis(dark);
+ }
}
+
.section-sub {
font-size: 0.9em;
- color: rgba(0, 0, 0, 0.6);
+
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
}
.mat-mdc-form-field {
@@ -48,7 +69,114 @@
}
mat-hint {
- color: rgba(0, 0, 0, 0.65);
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
+ }
+
+ .loading-row,
+ .empty-state {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ min-height: 56px;
+
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
+ }
+
+ .component-track-picker {
+ margin-bottom: 12px;
+ }
+
+ .selected-component-track-list {
+ display: grid;
+ gap: 12px;
+ }
+
+ .component-track-option {
+ border-radius: 6px;
+ border: 1px solid;
+ padding: 12px;
+
+ .light & {
+ border-color: colors.border-color(light);
+ }
+
+ .dark & {
+ border-color: colors.border-color(dark);
+ }
+
+ &.selected {
+ .light & {
+ border-color: colors.color(secondary);
+ }
+
+ .dark & {
+ border-color: colors.color(mitre-light-blue);
+ }
+ }
+ }
+
+ .component-track-selected-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+
+ > div {
+ min-width: 0;
+ }
+
+ button {
+ flex: 0 0 auto;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ }
+ }
+
+ .component-track-name {
+ font-weight: 600;
+ margin-right: 8px;
+ }
+
+ .component-track-meta {
+ font-size: 0.85em;
+
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
+ }
+
+ .component-track-description {
+ font-size: 0.9em;
+ margin: 8px 0 12px;
+
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
+ }
+
+ .object-type-filter {
+ margin-top: 8px;
}
// actions pinned to bottom of dialog
@@ -67,3 +195,29 @@
}
}
}
+
+.component-track-option-label {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ min-width: 0;
+}
+
+.component-track-option-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.component-track-option-meta {
+ flex: 0 0 auto;
+ font-size: 0.82em;
+
+ .light & {
+ color: colors.on-color-deemphasis(light);
+ }
+
+ .dark & {
+ color: colors.on-color-deemphasis(dark);
+ }
+}
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
new file mode 100644
index 000000000..00927ca0b
--- /dev/null
+++ b/src/app/components/new-track-dialog/new-track-dialog.component.spec.ts
@@ -0,0 +1,243 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { of } from 'rxjs';
+
+import { NewTrackDialogComponent } from './new-track-dialog.component';
+import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service';
+import {
+ DeduplicationStrategy,
+ ReleaseTrackType,
+ ResolutionStrategy,
+ SnapshotScheduleMode,
+ SnapshotTier,
+} from 'src/app/classes/release-tracks';
+import { WorkflowStatus } from 'src/app/utils/types';
+
+describe('NewTrackDialogComponent', () => {
+ let component: NewTrackDialogComponent;
+ let fixture: ComponentFixture
;
+ let mockDialogRef: any;
+ let mockConnector: any;
+
+ beforeEach(async () => {
+ mockDialogRef = {
+ close: vi.fn(),
+ };
+ mockConnector = {
+ listReleaseTracks: vi.fn(() =>
+ of({
+ data: [
+ {
+ track_id: 'release-track--standard-tagged',
+ type: 'standard',
+ name: 'Tagged Standard',
+ description: 'Can be used by a virtual track',
+ latest_tagged_version: '1.0',
+ tagged_release_count: 1,
+ },
+ {
+ track_id: 'release-track--standard-draft',
+ type: 'standard',
+ name: 'Draft Only Standard',
+ tagged_release_count: 0,
+ },
+ {
+ track_id: 'release-track--virtual-tagged',
+ type: 'virtual',
+ name: 'Virtual Track',
+ latest_tagged_version: '1.0',
+ tagged_release_count: 1,
+ },
+ ],
+ })
+ ),
+ createReleaseTrack: vi.fn(() =>
+ of({ track_id: 'release-track--new-virtual' })
+ ),
+ };
+
+ await TestBed.configureTestingModule({
+ declarations: [NewTrackDialogComponent],
+ imports: [ReactiveFormsModule],
+ providers: [
+ { provide: MatDialogRef, useValue: mockDialogRef },
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: { type: ReleaseTrackType.Virtual },
+ },
+ { provide: ReleaseTracksConnectorService, useValue: mockConnector },
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NewTrackDialogComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ });
+
+ it('should list standard tracks even when they have no tagged snapshots', () => {
+ expect(mockConnector.listReleaseTracks).toHaveBeenCalledWith();
+ expect(component.componentTrackOptions).toEqual([
+ expect.objectContaining({
+ trackId: 'release-track--standard-tagged',
+ name: 'Tagged Standard',
+ }),
+ expect.objectContaining({
+ trackId: 'release-track--standard-draft',
+ name: 'Draft Only Standard',
+ }),
+ ]);
+ });
+
+ it('should filter and select component tracks from the autocomplete', () => {
+ component.form.get('composition.componentTrackSearch')?.setValue('draft');
+
+ expect(component.filteredComponentTrackOptions).toEqual([
+ expect.objectContaining({
+ trackId: 'release-track--standard-draft',
+ }),
+ ]);
+
+ component.selectComponentTrack({
+ option: {
+ value: component.componentTrackOptions[1],
+ },
+ });
+
+ expect(component.selectedComponentTracks).toEqual([
+ expect.objectContaining({
+ trackId: 'release-track--standard-draft',
+ }),
+ ]);
+ expect(component.form.get('composition.componentTrackSearch')?.value).toBe(
+ ''
+ );
+ expect(component.filteredComponentTrackOptions).not.toContain(
+ component.componentTrackOptions[1]
+ );
+ });
+
+ it('should format component track snapshot labels', () => {
+ expect(
+ component.getComponentTrackSnapshotLabel(
+ component.componentTrackOptions[0]
+ )
+ ).toBe('v1.0');
+ expect(
+ component.getComponentTrackSnapshotLabel(
+ component.componentTrackOptions[1]
+ )
+ ).toBe('no tagged snapshots');
+ });
+
+ it('should remove selected component tracks and clear their filters', () => {
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+ component.componentTrackOptions[0].objectTypes = ['attack-pattern'];
+
+ component.removeComponentTrack(component.componentTrackOptions[0]);
+
+ expect(component.componentTrackOptions[0].selected).toBe(false);
+ expect(component.componentTrackOptions[0].objectTypes).toEqual([]);
+ });
+
+ it('should create a virtual track with latest tagged components and priorities', () => {
+ component.form.patchValue({
+ name: 'Combined Enterprise',
+ description: 'Aggregates released Enterprise content',
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+ component.componentTrackOptions[0].objectTypes = ['attack-pattern'];
+
+ component.handleCreate();
+
+ expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith({
+ type: ReleaseTrackType.Virtual,
+ name: 'Combined Enterprise',
+ description: 'Aggregates released Enterprise content',
+ composition: {
+ component_tracks: [
+ {
+ track_id: 'release-track--standard-tagged',
+ resolution_strategy: ResolutionStrategy.LatestTagged,
+ priority: 0,
+ filters: {
+ object_types: ['attack-pattern'],
+ },
+ },
+ ],
+ deduplication: {
+ strategy: DeduplicationStrategy.PrioritizeLatestObject,
+ tier_resolution: SnapshotTier.Member,
+ status_resolution: WorkflowStatus.WorkInProgress,
+ },
+ },
+ snapshot_schedule: {
+ mode: SnapshotScheduleMode.Manual,
+ },
+ });
+ expect(mockDialogRef.close).toHaveBeenCalledWith({
+ track_id: 'release-track--new-virtual',
+ });
+ });
+
+ it('should allow untagged standard tracks in virtual track composition', () => {
+ component.form.patchValue({
+ name: 'Future Combined Track',
+ description: 'Will resolve once components are tagged',
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[1], true);
+
+ component.handleCreate();
+
+ expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ composition: expect.objectContaining({
+ component_tracks: [
+ expect.objectContaining({
+ track_id: 'release-track--standard-draft',
+ resolution_strategy: ResolutionStrategy.LatestTagged,
+ priority: 0,
+ }),
+ ],
+ }),
+ })
+ );
+ });
+
+ it('should preserve optional virtual track settings in the create payload', () => {
+ component.form.patchValue({
+ name: 'Scheduled Combined Track',
+ description: 'Includes optional virtual settings',
+ composition: {
+ deduplicationStrategy: DeduplicationStrategy.Quarantine,
+ deduplicationTier: SnapshotTier.Staged,
+ deduplicationStatus: WorkflowStatus.Reviewed,
+ },
+ snapshotSchedule: {
+ mode: SnapshotScheduleMode.Cron,
+ cron: '0 0 1 1,7 *',
+ },
+ });
+ component.toggleComponentTrack(component.componentTrackOptions[0], true);
+
+ component.handleCreate();
+
+ expect(mockConnector.createReleaseTrack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ composition: expect.objectContaining({
+ deduplication: {
+ strategy: DeduplicationStrategy.Quarantine,
+ tier_resolution: SnapshotTier.Staged,
+ status_resolution: WorkflowStatus.Reviewed,
+ },
+ }),
+ snapshot_schedule: {
+ mode: SnapshotScheduleMode.Cron,
+ cron: '0 0 1 1,7 *',
+ },
+ })
+ );
+ });
+});
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 415e98971..9e9476c9f 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
@@ -6,11 +6,32 @@ import {
MemberSyncBehavior,
ReleaseTrackType,
DeduplicationStrategy,
+ ResolutionStrategy,
SnapshotTier,
SnapshotScheduleMode,
} from 'src/app/classes/release-tracks/enums';
import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service';
-import { WorkflowStatus } from 'src/app/utils/types';
+import { WorkflowStatus, StixType } from 'src/app/utils/types';
+import {
+ AttackTypeToPlural,
+ StixTypeToAttackType,
+} from 'src/app/utils/type-mappings';
+import { finalize, take } from 'rxjs/operators';
+
+const OBJECT_FILTER_OPTIONS: StixType[] = [
+ 'attack-pattern',
+ 'campaign',
+ 'course-of-action',
+ 'intrusion-set',
+ 'malware',
+ 'tool',
+ 'x-mitre-asset',
+ 'x-mitre-data-component',
+ 'x-mitre-detection-strategy',
+ 'x-mitre-analytic',
+ 'x-mitre-matrix',
+ 'x-mitre-tactic',
+];
@Component({
standalone: false,
@@ -36,6 +57,12 @@ export class NewTrackDialogComponent implements OnInit {
public deduplicationTierOptions = Object.values(SnapshotTier);
public deduplicationStatusOptions = Object.values(WorkflowStatus);
public snapshotModeOptions = Object.values(SnapshotScheduleMode);
+ public componentTrackOptions: VirtualComponentTrackOption[] = [];
+ public isLoadingComponentTracks = false;
+ public objectTypeOptions = OBJECT_FILTER_OPTIONS.map(type => ({
+ label: this.formatStixType(type),
+ value: type,
+ }));
public mode: 'standard' | 'virtual' = 'standard';
@@ -57,12 +84,13 @@ export class NewTrackDialogComponent implements OnInit {
memberSync: [MemberSyncStrategy.TrackLatest],
supplantBehavior: [MemberSyncBehavior.Replace],
composition: this.fb.group({
- deduplicationStrategy: [this.deduplicationOptions[0]],
+ componentTrackSearch: [''],
+ deduplicationStrategy: [DeduplicationStrategy.PrioritizeLatestObject],
deduplicationTier: [this.deduplicationTierOptions[0]],
deduplicationStatus: [this.deduplicationStatusOptions[0]],
}),
snapshotSchedule: this.fb.group({
- mode: [this.snapshotModeOptions[0]],
+ mode: [SnapshotScheduleMode.Manual],
cron: [''],
}),
});
@@ -79,6 +107,7 @@ export class NewTrackDialogComponent implements OnInit {
this.form
.get('composition')
?.updateValueAndValidity({ emitEvent: false });
+ this.loadComponentTrackOptions();
}
const autoCtrl = this.form.get('autoPromote');
@@ -99,8 +128,7 @@ export class NewTrackDialogComponent implements OnInit {
public isFormValid(): boolean {
const nameValid = !!this.form.get('name')?.value?.trim();
if (this.isVirtual) {
- const ded = this.form.get('composition.deduplicationStrategy')?.value;
- return nameValid && !!ded;
+ return nameValid && this.selectedComponentTracks.length > 0;
}
return this.form.valid && nameValid;
}
@@ -112,42 +140,64 @@ export class NewTrackDialogComponent implements OnInit {
return out.toLowerCase();
}
+ public toggleComponentTrack(
+ track: VirtualComponentTrackOption,
+ selected: boolean
+ ): void {
+ track.selected = selected;
+ }
+
+ public get selectedComponentTracks(): VirtualComponentTrackOption[] {
+ return this.componentTrackOptions.filter(track => track.selected);
+ }
+
+ public get filteredComponentTrackOptions(): VirtualComponentTrackOption[] {
+ const search = this.getComponentTrackSearchText();
+ return this.componentTrackOptions
+ .filter(track => !track.selected)
+ .filter(track => this.matchesComponentTrackSearch(track, search));
+ }
+
+ public displayComponentTrack(track: VirtualComponentTrackOption): string {
+ return track?.name || '';
+ }
+
+ public getComponentTrackSnapshotLabel(
+ track: VirtualComponentTrackOption
+ ): string {
+ return track.latestTaggedVersion
+ ? `v${track.latestTaggedVersion}`
+ : 'no tagged snapshots';
+ }
+
+ public selectComponentTrack(event: any): void {
+ const track = event?.option?.value as VirtualComponentTrackOption;
+ if (!track) return;
+
+ track.selected = true;
+ this.form
+ .get('composition.componentTrackSearch')
+ ?.setValue('', { emitEvent: false });
+ }
+
+ public removeComponentTrack(track: VirtualComponentTrackOption): void {
+ track.selected = false;
+ track.objectTypes = [];
+ }
+
public handleCreate(): void {
if (!this.isFormValid() || this.loading) return;
let payload: any;
if (this.isVirtual) {
- const compGroup = this.form.get('composition') as FormGroup;
- let composition: any = undefined;
- if (compGroup) {
- const strategy = compGroup.get('deduplicationStrategy')?.value;
- const tier = compGroup.get('deduplicationTier')?.value;
- const status = compGroup.get('deduplicationStatus')?.value;
-
- composition = { deduplication: {} };
- if (strategy) composition.deduplication.strategy = strategy;
- if (tier) composition.deduplication.tier_resolution = tier;
- if (status) composition.deduplication.status_resolution = status;
- }
-
- const snapshotSchedule = this.form.get('snapshotSchedule') as FormGroup;
- let snapshot_schedule: any = undefined;
- if (snapshotSchedule) {
- const mode = snapshotSchedule.get('mode')?.value;
- const cron = snapshotSchedule.get('cron')?.value;
- if (mode) {
- snapshot_schedule = { mode };
- if (mode === 'cron' && cron) snapshot_schedule.cron = cron;
- }
- }
-
payload = {
name: this.form.get('name')?.value,
description: this.form.get('description')?.value,
- composition: composition,
- snapshot_schedule: snapshot_schedule,
+ composition: this.buildVirtualComposition(),
type: ReleaseTrackType.Virtual,
};
+ const snapshotSchedule = this.buildVirtualSnapshotSchedule();
+ if (snapshotSchedule) payload.snapshot_schedule = snapshotSchedule;
} else {
payload = {
name: this.form.get('name')?.value,
@@ -168,17 +218,166 @@ export class NewTrackDialogComponent implements OnInit {
}
this.loading = true;
- const subscription = this.connector.createReleaseTrack(payload).subscribe({
- next: result => {
- this.dialogRef.close(result);
- },
- error: () => {
- // leave dialog open and stop loading
- this.loading = false;
- },
- complete: () => {
- if (subscription) subscription.unsubscribe();
- },
- });
+ this.connector
+ .createReleaseTrack(payload)
+ .pipe(take(1))
+ .subscribe({
+ next: result => {
+ this.dialogRef.close(result);
+ },
+ error: () => {
+ // leave dialog open and stop loading
+ this.loading = false;
+ },
+ });
+ }
+
+ private loadComponentTrackOptions(): void {
+ this.isLoadingComponentTracks = true;
+ this.connector
+ .listReleaseTracks()
+ .pipe(
+ take(1),
+ finalize(() => {
+ this.isLoadingComponentTracks = false;
+ })
+ )
+ .subscribe({
+ next: result => {
+ const tracks = this.getTrackList(result);
+ this.componentTrackOptions = tracks
+ .filter(track => this.isStandardTrack(track))
+ .filter(track => !!this.getTrackId(track))
+ .map(track => this.toComponentTrackOption(track));
+ },
+ error: () => {
+ this.componentTrackOptions = [];
+ },
+ });
}
+
+ private buildVirtualComposition(): any {
+ const deduplication: any = {};
+ const strategy = this.form.get('composition.deduplicationStrategy')?.value;
+ const tier = this.form.get('composition.deduplicationTier')?.value;
+ const status = this.form.get('composition.deduplicationStatus')?.value;
+ if (strategy) deduplication.strategy = strategy;
+ if (tier) deduplication.tier_resolution = tier;
+ if (status) deduplication.status_resolution = status;
+
+ return {
+ component_tracks: this.selectedComponentTracks.map((track, priority) => {
+ const componentTrack: any = {
+ track_id: track.trackId,
+ resolution_strategy: ResolutionStrategy.LatestTagged,
+ priority,
+ };
+
+ if (track.objectTypes.length) {
+ componentTrack.filters = {
+ object_types: track.objectTypes,
+ };
+ }
+
+ return componentTrack;
+ }),
+ deduplication,
+ };
+ }
+
+ private buildVirtualSnapshotSchedule(): any | undefined {
+ const snapshotSchedule = this.form.get('snapshotSchedule') as FormGroup;
+ if (!snapshotSchedule) return undefined;
+
+ const mode = snapshotSchedule.get('mode')?.value;
+ const cron = snapshotSchedule.get('cron')?.value;
+ if (!mode) return undefined;
+
+ const payload: any = { mode };
+ if (mode === SnapshotScheduleMode.Cron && cron) payload.cron = cron;
+ return payload;
+ }
+
+ private getTrackList(result: any): any[] {
+ if (Array.isArray(result?.data)) return result.data;
+ if (Array.isArray(result?.release_tracks)) return result.release_tracks;
+ if (Array.isArray(result)) return result;
+ return [];
+ }
+
+ private isStandardTrack(track: any): boolean {
+ return String(track?.type).toLowerCase() === ReleaseTrackType.Standard;
+ }
+
+ private toComponentTrackOption(track: any): VirtualComponentTrackOption {
+ const latestTaggedVersion = this.getLatestTaggedVersion(track);
+ const taggedReleaseCount = this.getTaggedReleaseCount(track);
+
+ return {
+ trackId: this.getTrackId(track) as string,
+ name: track.name || 'Untitled release track',
+ description: track.description || '',
+ latestTaggedVersion,
+ taggedReleaseCount,
+ selected: false,
+ objectTypes: [],
+ };
+ }
+
+ private getTrackId(track: any): string | null {
+ return track?.track_id || track?.id || null;
+ }
+
+ private getLatestTaggedVersion(track: any): string | null {
+ return (
+ track?.latest_tagged_version ||
+ track?.latestTaggedVersion ||
+ track?.latest_version ||
+ track?.latestVersion ||
+ null
+ );
+ }
+
+ private getTaggedReleaseCount(track: any): number {
+ return Number(
+ track?.tagged_release_count ||
+ track?.taggedReleaseCount ||
+ track?.tagged_releases_count ||
+ 0
+ );
+ }
+
+ private formatStixType(type: StixType): string {
+ const attackType = StixTypeToAttackType[type];
+ return AttackTypeToPlural[attackType]?.replace(/-/g, ' ') || type;
+ }
+
+ private getComponentTrackSearchText(): string {
+ const value = this.form.get('composition.componentTrackSearch')?.value;
+ if (!value) return '';
+ if (typeof value === 'string') return value.trim().toLowerCase();
+ return String(value.name || value.trackId || '')
+ .trim()
+ .toLowerCase();
+ }
+
+ private matchesComponentTrackSearch(
+ track: VirtualComponentTrackOption,
+ search: string
+ ): boolean {
+ if (!search) return true;
+ return [track.name, track.trackId, track.description]
+ .filter(Boolean)
+ .some(value => value.toLowerCase().includes(search));
+ }
+}
+
+interface VirtualComponentTrackOption {
+ trackId: string;
+ name: string;
+ description: string;
+ latestTaggedVersion: string | null;
+ taggedReleaseCount: number;
+ selected: boolean;
+ objectTypes: StixType[];
}
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 d36a9b3fb..44c2e0523 100644
--- a/src/app/services/connectors/rest-api/release-tracks.service.ts
+++ b/src/app/services/connectors/rest-api/release-tracks.service.ts
@@ -13,6 +13,7 @@ import {
import type {
BumpPayload,
ClonePayload,
+ Composition,
CreateReleaseTrackPayload,
ExportFormatType,
ReleaseTrackConfig,
@@ -29,6 +30,7 @@ import { Paginated } from './rest-api-connector.service';
export type {
BumpPayload,
ClonePayload,
+ Composition,
CreateReleaseTrackPayload,
ReleaseTrackSnapshotHistoryItem,
ReleaseTrackSnapshotOptions,
@@ -595,6 +597,29 @@ export class ReleaseTracksConnectorService extends ApiConnector {
);
}
+ /**
+ * PUT /api/release-tracks/:id/composition
+ * Update the composition rules for a virtual release track.
+ * @param id Release track id
+ * @param body Composition payload
+ * @returns Observable
+ */
+ public updateComposition(
+ id: string,
+ body: Composition,
+ userAccountId?: string
+ ): Observable {
+ const url = `${this.apiUrl}/release-tracks/${id}/composition`;
+ const payload = userAccountId ? { ...body, userAccountId } : body;
+ return this.http.put(url, payload).pipe(
+ tap(result =>
+ logger.log(`updated virtual composition for track ${id}`, result)
+ ),
+ catchError(this.handleError_raise()),
+ share()
+ );
+ }
+
// -----------------------------------------------------------------------------
// Candidate 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 4ff125996..9494d9f07 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
@@ -38,7 +38,9 @@
-
+
Description
-
-
- {{ candidates.length || 0 }}
-
-
-
Candidates
-
in this draft
+
+
+
+ {{ virtualComponentTracks.length || 0 }}
+
+
+ Components
+ standard tracks
+
+
+
+
+
+ {{ virtualResolvedObjectCount || 0 }}
+
+
+ Resolved Objects
+ latest snapshot
+
+
+
+
+
+ {{ quarantineObjects.length || 0 }}
+
+
+ Quarantined
+ needs review
+
+
+
+
+
+
+
+ {{ candidates.length || 0 }}
+
+
+ Candidates
+ in this draft
+
+
+
+
+
+ {{ staged.length || 0 }}
+
+
+ Staged
+ for next release
+
+
+
+
+
+ {{ members.length || 0 }}
+
+
+ Released Members
+ published
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No component tracks configured.
+
+
+
+
+
{{ getComponentTrackLabel(track) }}
+
{{ track.track_id }}
+
+
+
+ {{ formatConfigOption(track.resolution_strategy) }}
+
+
+ priority {{ track.priority ?? 0 }}
+
+
+ {{ getComponentTrackFilters(track).join(', ') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No resolved snapshot yet. Create a draft snapshot to resolve
+ component contents.
+
-
-
- {{ staged.length || 0 }}
-
-
-
Staged
-
for next release
+
+
+
+ {{
+ component.track_name ||
+ component.track_id ||
+ 'Component track'
+ }}
+
+
+ {{ getResolvedComponentLabel(component) }}
+
+
+
+
+ {{ component.objects_contributed || 0 }} contributed
+
+
+ {{ component.objects_after_filter || 0 }} after filters
+
+
+
+
-
-
- {{ members.length || 0 }}
-
-
-
Released Members
-
published
+
+
+
+
+
+
+
+
+ {{ virtualResolvedObjectCount || 0 }}
+ Total Objects
+
+
+ {{ virtualDuplicateCount || 0 }}
+ Duplicates
+
+
+ {{ virtualConflictCount || 0 }}
+ Conflicts
+
+
+ {{ quarantineObjects.length || 0 }}
+ Quarantined
+
-
+
-
+
-
-
+
+
-
-
- Released Members ({{ lane.items.length }})
-
-
-
-
-
-
-
+
@@ -392,327 +724,624 @@