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.name }}
+
+ {{ getComponentTrackSnapshotLabel(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 +
+
+
+
+
+
+ + +
+
+
+
+

+ Component Tracks ({{ virtualComponentTracks.length }}) +

+
+ + + +
+
+ No component tracks configured. +
+ +
+
+

{{ getComponentTrackLabel(track) }}

+

{{ track.track_id }}

+
+
+ + {{ formatConfigOption(track.resolution_strategy) }} + + + priority {{ track.priority ?? 0 }} + + + {{ getComponentTrackFilters(track).join(', ') }} + +
+
+
+ +
+
+
+

Latest Resolution

+
+ + + +
+
+ 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 +
+
+
+

Resolution Details

+
+ + + +
+
+ {{ virtualResolvedObjectCount || 0 }} + Total Objects +
+
+ {{ virtualDuplicateCount || 0 }} + Duplicates +
+
+ {{ virtualConflictCount || 0 }} + Conflicts +
+
+ {{ quarantineObjects.length || 0 }} + Quarantined +
-
+
-
+
-
-
+ +
- - - -
-
-

- {{ lane.title }} ({{ lane.items.length }}) -

-
- - + class="release-column" + [ngClass]="{ + 'released-members-column': lane.isReleasedMembers, + 'released-members-collapsed': + lane.isReleasedMembers && !showReleasedMembers, + }" + *ngFor="let lane of workspaceLanes; trackBy: trackByLaneKey"> +
+ + + +
+
+

+ {{ lane.title }} ({{ lane.items.length }}) +

+
+ + +
-
- + -
-
- {{ lane.emptyLabel }} +
+
+ {{ lane.emptyLabel }} +
+
+ +
-
- +
+ +
+
+
+ +
+ + +
+
+
+
+

Composition Resolution

+ + Resolved at: + {{ virtualResolvedAt | date: 'MMM d, y, h:mm:ss a' }} + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Component TrackStrategyResolved VersionCandidatesStagedMembers
+ No component tracks configured. +
+ {{ row.trackName }} + {{ row.trackId }} + {{ formatConfigOption(row.strategy) }} + + {{ getVirtualResolvedVersion(row) }} + + + {{ + getVirtualTierCount(row.candidatesCount) + }} + + {{ + getVirtualTierCount(row.stagedCount) + }} + + {{ + getVirtualTierCount(row.membersCount) + }} +
+
+
+
+ +
+
+
+
+

Members ({{ members.length || 0 }})

+
+ + + +
+
+ No member objects. +
+ +
+
+

{{ getVirtualObjectTitle(item) }}

+

{{ getVirtualObjectSubtitle(item) }}

+ +
+
+
+
+ +
+
+
+

+ Quarantine ({{ quarantineObjects.length || 0 }}) +

+
+ + + +
+
+ No quarantined objects.
+ +
+
+

{{ getVirtualObjectTitle(item) }}

+

{{ getVirtualObjectSubtitle(item) }}

+
+ +
- -
+
+
-
+
@@ -392,327 +724,624 @@

-
-
-

Promotion Policy

- -
-
-
Auto-promote Candidates
-

- Automatically promote candidates to Staged when they meet the - configured workflow threshold. -

-
- - - - - - {{ configForm.get('autoPromote')?.value ? 'ON' : 'OFF' }} + +
+
+

Composition

+ +
+
+
Component Tracks
+

+ Standard release tracks resolved into this virtual track. +

+
+ + {{ displayedVirtualConfigComponentTracks.length }} - -
+
- + -
-
-
Candidacy Threshold
-

- Defines the minimum workflow state required for a candidate to - be promoted to Staged. -

-
- - - Candidacy Threshold - +
+ + Add component track + + arrow_drop_down + + + + + {{ track.name }} + + + ({{ getVirtualComponentTrackSnapshotLabel(track) }}) + + + - {{ formatConfigOption(option) }} + *ngIf="!filteredVirtualComponentTrackOptions.length" + disabled> + No matching standard tracks - + + + Select one or more standard tracks to include in this + virtual track. + - - - +
- {{ - formatConfigOption( - configForm.get('candidacyThreshold')?.value - ) - }} - - -
-
-
+
+
+
+ {{ getComponentTrackLabel(track) }} +
+

{{ track.track_id }}

+

+ {{ getVirtualComponentTrackDescription(track) }} +

+
+ +
-
-
-

Lifecycle Settings

- -
-
-
Member Sync Strategy
-

- Determines if new revisions to existing member objects are - automatically added as candidates. -

+
+ + {{ formatConfigOption(track.resolution_strategy) }} + + + priority + {{ getVirtualTrackPriority(track, componentIndex) }} + + + {{ getComponentTrackFilters(track).join(', ') }} + +
+ + + Object type filter + + + {{ option.label }} + + + Leave empty to include all object types + +
- - - Member Sync Strategy - - - {{ formatConfigOption(option) }} - - - - - - - {{ - formatConfigOption( - configForm.get('memberSyncStrategy')?.value - ) - }} - + + +
No component tracks configured.
+
- +
+
+

Object Deduplication

-
-
-
Supplant Behavior
-

- Controls how a new version is handled when the object is - already tracked by this release track. -

+
+
+
Strategy
+

Determines which object wins when components overlap.

+
+ + + Strategy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationStrategy')?.value + ) + }} + +
- - - Supplant Behavior - - - {{ formatConfigOption(option) }} - - - - - - - {{ - formatConfigOption( - configForm.get('memberSyncSupplantBehavior')?.value - ) - }} - - -
- + -
-
-
Supplant Status Policy
-

- Sets whether a queued replacement keeps or resets the tracked - workflow status. -

+
+
+
Preferred Tier
+

+ Breaks ties by preferring objects from the selected tier. +

+
+ + + Preferred Tier + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationTier')?.value + ) + }} + +
- - - Supplant Status Policy - - - {{ formatConfigOption(option) }} - - - - - + + + +
+
+
Preferred Status
+

+ Breaks ties by preferring objects with the selected workflow + state. +

+
+ + + Preferred Status + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('virtualDeduplicationStatus')?.value + ) + }} + + +
+
+
+ +
+
+

Snapshot Schedule

+ +
+
+
Mode
+

Controls how draft snapshots are scheduled.

+
{{ formatConfigOption( - configForm.get('memberSyncSupplantStatusPolicy')?.value + configForm.get('virtualSnapshotScheduleMode')?.value ) }} - +
+ + + +
+
+
Cron Expression
+

+ UTC cron expression used when the schedule mode is cron. +

+
+ + {{ getVirtualScheduleValue('cron') }} + +
-
-
+ + -
-
-

Conflict Resolution

- -
-
-
Candidates to Staged
-

- Decides what happens when a candidate conflicts with another - version already staged. -

+ +
+
+

Promotion Policy

+ +
+
+
Auto-promote Candidates
+

+ Automatically promote candidates to Staged when they meet + the configured workflow threshold. +

+
+ + + + + + {{ configForm.get('autoPromote')?.value ? 'ON' : 'OFF' }} + + +
+ + + +
+
+
Candidacy Threshold
+

+ Defines the minimum workflow state required for a candidate + to be promoted to Staged. +

+
+ + + Candidacy Threshold + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('candidacyThreshold')?.value + ) + }} + +
- - - Candidates to Staged - - - {{ formatConfigOption(option) }} - - - - - - - {{ - formatConfigOption( - configForm.get('candidatesToStagedConflict')?.value - ) - }} - -
+
- +
+
+

Lifecycle Settings

-
-
-
Staged to Members
-

- Decides whether release tagging can continue when staged - objects conflict with released member versions. -

+
+
+
Member Sync Strategy
+

+ Determines if new revisions to existing member objects are + automatically added as candidates. +

+
+ + + Member Sync Strategy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncStrategy')?.value + ) + }} + + +
+ + + +
+
+
Supplant Behavior
+

+ Controls how a new version is handled when the object is + already tracked by this release track. +

+
+ + + Supplant Behavior + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncSupplantBehavior')?.value + ) + }} + + +
+ + + +
+
+
Supplant Status Policy
+

+ Sets whether a queued replacement keeps or resets the + tracked workflow status. +

+
+ + + Supplant Status Policy + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('memberSyncSupplantStatusPolicy')?.value + ) + }} + +
- - - Staged to Members - - - {{ formatConfigOption(option) }} - - - - - - - {{ - formatConfigOption( - configForm.get('stagedToMembersConflict')?.value - ) - }} - -
-
-
+
-
-
-

Secondary Objects

- -
-
-
Include Secondary Objects
-

- Includes supporting objects when they meet the configured - workflow state threshold. -

+
+
+

Conflict Resolution

+ +
+
+
Candidates to Staged
+

+ Decides what happens when a candidate conflicts with another + version already staged. +

+
+ + + Candidates to Staged + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('candidatesToStagedConflict')?.value + ) + }} + + +
+ + + +
+
+
Staged to Members
+

+ Decides whether release tagging can continue when staged + objects conflict with released member versions. +

+
+ + + Staged to Members + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('stagedToMembersConflict')?.value + ) + }} + +
- - - - - - {{ - configForm.get('includeSecondaryObjects')?.value - ? 'ON' - : 'OFF' - }} - -
+
- +
+
+

Secondary Objects

-
-
-
Secondary Object Threshold
-

- Defines the minimum workflow state for automatically included - supporting objects. -

+
+
+
Include Secondary Objects
+

+ Includes supporting objects when they meet the configured + workflow state threshold. +

+
+ + + + + + {{ + configForm.get('includeSecondaryObjects')?.value + ? 'ON' + : 'OFF' + }} + + +
+ + + +
+
+
Secondary Object Threshold
+

+ Defines the minimum workflow state for automatically + included supporting objects. +

+
+ + + Secondary Object Threshold + + + {{ formatConfigOption(option) }} + + + + + + + {{ + formatConfigOption( + configForm.get('secondaryObjectThreshold')?.value + ) + }} + +
- - - Secondary Object Threshold - - - {{ formatConfigOption(option) }} - - - - - - - {{ - formatConfigOption( - configForm.get('secondaryObjectThreshold')?.value - ) - }} - -
-
-
+
+
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss index 3a376de1c..d6b4daf18 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss @@ -22,6 +22,9 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); .demote-color { color: $awaiting-review-color; } + .quarantine-color { + color: colors.color(error); + } .release-track-header { display: flex; @@ -120,6 +123,145 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } + .config-component-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .virtual-component-track-picker { + width: 100%; + margin-bottom: 18px; + + .mat-mdc-form-field { + width: 100%; + } + + .mat-mdc-form-field-subscript-wrapper { + min-height: 20px; + } + } + + .config-component-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + box-sizing: border-box; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + } + + .config-component-row-editing { + flex-direction: column; + width: 100%; + } + + .config-component-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; + + > .config-copy { + flex: 1 1 auto; + } + + button { + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + } + } + + .config-component-description { + margin-top: 8px !important; + } + + .config-component-filter { + width: 100%; + + .mat-mdc-form-field-subscript-wrapper { + min-height: 20px; + } + } + + .config-value-list { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: 36%; + } + + .component-track-id { + margin-top: 3px; + overflow-wrap: normal; + font-family: + Roboto Mono, + monospace; + font-size: 11px; + font-weight: 400; + line-height: 15px; + white-space: nowrap; + + .dark & { + color: color.mix( + colors.on-color-deemphasis(dark), + colors.color(dark), + 72% + ); + } + + .light & { + color: color.mix( + colors.on-color-deemphasis(light), + colors.color(light), + 76% + ); + } + } + + .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; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + .config-row-disabled { .config-label, .config-copy p, @@ -255,6 +397,24 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } + .clickable-row { + cursor: pointer; + transition: background-color 120ms ease; + + &:focus-visible { + outline: 2px solid colors.color(secondary); + outline-offset: 2px; + } + + .dark &:hover { + background: rgba(colors.color(secondary), 0.1); + } + + .light &:hover { + background: rgba(colors.color(secondary), 0.055); + } + } + .config-field { width: min(100%, 320px); } @@ -439,6 +599,261 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); color: color.mix(colors.color(spark-yellow), colors.color(warn), 72%); } + .virtual-track-layout { + display: flex; + flex-direction: column; + gap: 16px; + height: calc(100dvh - 148px); + min-height: 0; + } + + .virtual-resolution-table, + .virtual-object-panel, + .virtual-component-row, + .virtual-stat, + .config-component-row, + .empty-state { + .dark & { + border-color: colors.border-color(dark); + } + + .light & { + border-color: colors.border-color(light); + } + } + + .labelled-box.virtual-resolution-panel > .content, + .labelled-box.virtual-object-panel > .content { + display: flex; + flex-direction: column; + align-items: stretch; + min-height: 0; + padding: 14px 12px 16px; + overflow: hidden; + } + + .virtual-resolution-panel { + flex: 0 0 auto; + } + + .virtual-resolution-header { + align-items: flex-start !important; + + h3 { + min-width: 0; + } + + .text-deemphasis { + flex: 0 0 auto; + font-size: 12px; + line-height: 18px; + text-align: right; + } + } + + .virtual-resolution-table { + margin-top: 12px; + overflow-x: auto; + + table { + width: 100%; + border-collapse: collapse; + min-width: 820px; + table-layout: fixed; + } + + .virtual-resolution-component-col { + width: 46%; + } + + .virtual-resolution-strategy-col { + width: 17%; + } + + .virtual-resolution-version-col { + width: 13%; + } + + .virtual-resolution-count-col { + width: 8%; + } + + th, + td { + padding: 10px 12px; + text-align: left; + vertical-align: middle; + } + + th:nth-child(n + 4), + td:nth-child(n + 4) { + text-align: center; + white-space: nowrap; + } + + .virtual-resolution-row { + &:focus-visible { + outline: 2px solid colors.color(secondary); + outline-offset: -3px; + } + + .dark &:hover td { + background: rgba(colors.color(secondary), 0.1); + } + + .light &:hover td { + background: rgba(colors.color(secondary), 0.055); + } + } + + th { + font-family: + Roboto Condensed, + Arial, + sans-serif; + font-size: 13px; + font-weight: 700; + line-height: 18px; + text-transform: uppercase; + + .dark & { + color: colors.on-color-deemphasis(dark); + background: rgba(colors.color(mitre-silver), 0.035); + } + + .light & { + color: colors.on-color-deemphasis(light); + background: rgba(colors.color(mitre-black), 0.018); + } + } + + td { + border-top: 1px solid; + font-size: 14px; + line-height: 20px; + + .dark & { + border-color: colors.border-color(dark); + } + + .light & { + border-color: colors.border-color(light); + } + + strong, + span { + display: block; + } + + span { + margin-top: 2px; + font-size: 12px; + line-height: 16px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + } + } + + .virtual-empty-cell { + height: 64px; + text-align: center !important; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + .virtual-object-panels { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + flex: 1 1 auto; + min-height: 0; + } + + .virtual-object-panel { + min-height: 0; + } + + .virtual-object-list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + padding-top: 12px; + overflow-y: auto; + padding-right: 4px; + scrollbar-gutter: stable; + } + + .virtual-object-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + h4 { + margin: 0; + font-size: 15px; + font-weight: 800; + line-height: 20px; + } + + p { + margin: 4px 0 0; + font-size: 12px; + line-height: 16px; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } + } + + button { + flex: 0 0 auto; + height: 32px; + min-width: 0; + padding: 0 10px; + + .mat-icon { + width: 18px; + height: 18px; + margin-left: 4px; + font-size: 18px; + line-height: 18px; + } + } + } + .release-track-board { display: flex; flex-direction: column; @@ -632,6 +1047,143 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); min-height: 0; } + .virtual-track-workspace { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.15fr) minmax( + 280px, + 0.7fr + ); + gap: 16px; + flex: 1 1 auto; + min-height: 0; + } + + .virtual-track-card { + min-height: 0; + + > .content { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + padding: 14px 12px 16px; + } + } + + .virtual-card-body { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + padding-top: 12px; + overflow-y: auto; + scrollbar-gutter: stable; + } + + .virtual-component-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + border: 1px solid; + border-radius: 4px; + padding: 12px; + + > div:first-child { + flex: 1 1 auto; + min-width: 0; + } + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + h4 { + margin: 0; + font-size: 15px; + line-height: 20px; + } + + p { + margin: 4px 0 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 16px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + } + + .virtual-component-meta { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: 36%; + } + + .virtual-resolution-stats { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + overflow: visible; + } + + .virtual-stat { + border: 1px solid; + border-radius: 4px; + padding: 12px; + text-align: center; + + .light & { + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.014); + } + + .dark & { + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.025); + } + + strong { + display: block; + font-size: 28px; + font-weight: 800; + line-height: 32px; + } + + span { + display: block; + margin-top: 2px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 16px; + text-transform: uppercase; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } + } + .release-column { display: flex; flex: 1 1 0; @@ -827,7 +1379,8 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } @media (max-width: 960px) { - .release-track-board { + .release-track-board, + .virtual-track-layout { height: auto; } @@ -839,6 +1392,14 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); flex-basis: calc(50% - 8px); } + .virtual-track-workspace { + grid-template-columns: 1fr; + } + + .virtual-object-panels { + grid-template-columns: 1fr; + } + .summary-frame > .summary-overview.content { grid-template-columns: 1fr; } @@ -894,6 +1455,22 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); grid-template-columns: 1fr; } + .virtual-resolution-header { + flex-direction: column; + + .text-deemphasis { + text-align: left; + } + } + + .virtual-resolution-table { + overflow-x: auto; + + table { + min-width: 720px; + } + } + .summary-metric { & + .summary-metric { .light &, @@ -959,5 +1536,48 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); margin-left: 0; } } + + .config-component-row { + flex-direction: column; + gap: 10px; + } + + .config-value-list { + justify-content: flex-start; + max-width: none; + } + + .component-track-id { + overflow-wrap: anywhere; + white-space: normal; + } + } +} + +::ng-deep .virtual-component-track-autocomplete { + .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; + + .dark & { + color: colors.on-color-deemphasis(dark); + } + + .light & { + color: colors.on-color-deemphasis(light); + } } } diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index 53cd46063..e50126602 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -18,10 +18,13 @@ import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.com import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ConflictPolicy, + DeduplicationStrategy, MemberSyncBehavior, MemberSyncPolicy, MemberSyncStrategy, ReleaseTrackType, + SnapshotScheduleMode, + SnapshotTier, } from 'src/app/classes/release-tracks'; describe('ReleaseTrackPageComponent', () => { @@ -30,19 +33,23 @@ describe('ReleaseTrackPageComponent', () => { let mockReleaseTrackApiConnector: any; let mockDialog: any; let mockRestApiConnector: any; + let mockRouter: any; beforeEach(async () => { mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ getLatestSnapshot: vi.fn(() => createAsyncObservable(null)), + listReleaseTracks: vi.fn(() => createAsyncObservable({ data: [] })), listSnapshots: vi.fn(() => createAsyncObservable([])), exportLatestSnapshot: vi.fn(() => createAsyncObservable({})), exportSnapshotByModified: vi.fn(() => createAsyncObservable({})), retrieveSnapshotByModified: vi.fn(() => createAsyncObservable(null)), + previewVirtualSnapshot: vi.fn(() => createAsyncObservable({})), createVirtualSnapshot: vi.fn(() => createAsyncObservable({})), previewBump: vi.fn(() => createAsyncObservable({})), bumpByLatest: vi.fn(() => createAsyncObservable({})), getConfig: vi.fn(() => createAsyncObservable(null)), updateConfig: vi.fn(() => createAsyncObservable({})), + updateComposition: vi.fn(() => createAsyncObservable({})), reviewCandidates: vi.fn(() => createAsyncObservable({})), updateMetadataByLatest: vi.fn(() => createAsyncObservable({})), addCandidates: vi.fn(() => createAsyncObservable({})), @@ -59,7 +66,7 @@ describe('ReleaseTrackPageComponent', () => { const mockBreadcrumbService = { changeBreadcrumb: vi.fn(), }; - const mockRouter = { + mockRouter = { navigate: vi.fn(), }; @@ -179,6 +186,176 @@ describe('ReleaseTrackPageComponent', () => { expect(component.releaseTrack?.name).toBe('Enterprise Release'); }); + it('should expose virtual release track composition and resolution details', () => { + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + filters: { + object_types: ['attack-pattern'], + }, + }, + { + track_id: 'release-track--component-two', + resolution_strategy: 'latest_tagged', + priority: 1, + }, + ], + }, + composition_resolution: { + component_snapshots: [ + { + track_id: 'release-track--component-one', + track_name: 'Resolved Component One', + resolved_version: '1.0', + objects_contributed: 8, + objects_after_filter: 8, + }, + { + track_id: 'release-track--component-two', + track_name: 'Component Two', + resolved_version: '2.0', + objects_contributed: 12, + objects_after_filter: 12, + }, + ], + summary: { + total_objects: 20, + }, + deduplication: { + duplicates_found: 2, + conflicts_resolved: [{ object_ref: 'attack-pattern--one' }], + }, + }, + members: [ + { + object_ref: 'x-mitre-collection--enterprise', + object_modified: '2026-01-01T00:00:00.000Z', + attack_id: 'NX0001', + name: 'Enterprise ATT&CK', + }, + ], + quarantine: [{ object_ref: 'attack-pattern--quarantined' }], + } as any; + (component as any).virtualComponentTrackSummaries = new Map([ + [ + 'release-track--component-one', + { + trackId: 'release-track--component-one', + name: 'Component One', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }, + ], + [ + 'release-track--component-two', + { + trackId: 'release-track--component-two', + name: 'Component Two', + candidatesCount: 2, + stagedCount: 1, + membersCount: 20, + }, + ], + ]); + + expect(component.isVirtualReleaseTrack).toBe(true); + expect(component.virtualComponentTracks).toHaveLength(2); + expect(component.resolvedComponentSnapshots).toHaveLength(2); + expect(component.virtualResolvedObjectCount).toBe(20); + expect(component.virtualDuplicateCount).toBe(2); + expect(component.virtualConflictCount).toBe(1); + expect(component.quarantineObjects).toHaveLength(1); + expect( + component.getComponentTrackLabel(component.virtualComponentTracks[0]) + ).toBe('Component One'); + expect( + component.getComponentTrackFilters(component.virtualComponentTracks[0]) + ).toEqual(['attack pattern']); + expect(component.virtualResolutionRows[0]).toEqual( + expect.objectContaining({ + trackId: 'release-track--component-one', + trackName: 'Component One', + strategy: 'latest_tagged', + resolvedVersion: '1.0', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }) + ); + expect( + component.getVirtualResolvedVersion(component.virtualResolutionRows[0]) + ).toBe('v1.0'); + expect(component.getVirtualTierCount(9)).toBe('9'); + expect(component.getVirtualObjectTitle(component.members[0])).toBe( + 'Enterprise ATT&CK' + ); + expect(component.getVirtualObjectSubtitle(component.members[0])).toBe( + 'NX0001' + ); + }); + + it('should load component track summaries alongside a virtual track', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + }, + ], + }, + }) + ); + mockReleaseTrackApiConnector.listReleaseTracks.mockReturnValue( + of({ + data: [ + { + track_id: 'release-track--component-one', + name: 'Component One', + summary: { + candidates_count: 9, + staged_count: 3, + members_count: 72, + }, + }, + ], + }) + ); + component.id = 'release-track--virtual'; + + component.getReleaseTrack(); + + expect(mockReleaseTrackApiConnector.listReleaseTracks).toHaveBeenCalled(); + expect(component.virtualResolutionRows[0]).toEqual( + expect.objectContaining({ + trackName: 'Component One', + candidatesCount: 9, + stagedCount: 3, + membersCount: 72, + }) + ); + }); + + it('should navigate to a component release track page', () => { + component.onOpenComponentTrack({ + trackId: 'release-track--component-one', + }); + + expect(mockRouter.navigate).toHaveBeenCalledWith([ + '/dashboard/release-management', + 'release-track--component-one', + ]); + }); + it('should load snapshot history and compute timeline counts', () => { mockReleaseTrackApiConnector.listSnapshots.mockReturnValue( of([ @@ -239,6 +416,24 @@ describe('ReleaseTrackPageComponent', () => { const historySpy = vi .spyOn(component, 'getSnapshotHistory') .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.previewVirtualSnapshot.mockReturnValue( + of({ + preview: { + would_resolve_to: { + total_objects: 20, + component_snapshots: [ + { + track_name: 'Component Track', + resolved_version: '1.0', + }, + ], + }, + }, + }) + ); + mockDialog.open.mockReturnValue({ + afterClosed: () => of('create'), + }); mockReleaseTrackApiConnector.createVirtualSnapshot.mockReturnValue(of({})); component.id = 'release-track--123'; component.releaseTrack = { type: ReleaseTrackType.Virtual } as any; @@ -246,8 +441,22 @@ describe('ReleaseTrackPageComponent', () => { component.onDraft(); expect( - mockReleaseTrackApiConnector.createVirtualSnapshot + mockReleaseTrackApiConnector.previewVirtualSnapshot ).toHaveBeenCalledWith('release-track--123'); + expect(mockDialog.open).toHaveBeenCalledWith( + MultipleChoiceDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Create draft snapshot?', + description: expect.stringContaining('20 objects'), + }), + }) + ); + expect( + mockReleaseTrackApiConnector.createVirtualSnapshot + ).toHaveBeenCalledWith('release-track--123', { + description: 'Initial virtual snapshot', + }); expect(refreshSpy).toHaveBeenCalled(); expect(historySpy).toHaveBeenCalled(); expect(component.isCreatingDraft).toBe(false); @@ -260,7 +469,7 @@ describe('ReleaseTrackPageComponent', () => { component.onDraft(); expect( - mockReleaseTrackApiConnector.createVirtualSnapshot + mockReleaseTrackApiConnector.previewVirtualSnapshot ).not.toHaveBeenCalled(); }); @@ -271,7 +480,7 @@ describe('ReleaseTrackPageComponent', () => { component.onDraft(); expect( - mockReleaseTrackApiConnector.createVirtualSnapshot + mockReleaseTrackApiConnector.previewVirtualSnapshot ).not.toHaveBeenCalled(); }); @@ -412,17 +621,19 @@ describe('ReleaseTrackPageComponent', () => { expect(mockReleaseTrackApiConnector.getConfig).toHaveBeenCalledWith( 'release-track--123' ); - expect(component.configForm.getRawValue()).toEqual({ - autoPromote: false, - candidacyThreshold: 'awaiting-review', - memberSyncStrategy: 'track_latest', - memberSyncSupplantBehavior: 'queue', - memberSyncSupplantStatusPolicy: 'reset', - candidatesToStagedConflict: 'always_reject', - stagedToMembersConflict: 'abort', - includeSecondaryObjects: true, - secondaryObjectThreshold: 'work-in-progress', - }); + expect(component.configForm.getRawValue()).toEqual( + expect.objectContaining({ + autoPromote: false, + candidacyThreshold: 'awaiting-review', + memberSyncStrategy: 'track_latest', + memberSyncSupplantBehavior: 'queue', + memberSyncSupplantStatusPolicy: 'reset', + candidatesToStagedConflict: 'always_reject', + stagedToMembersConflict: 'abort', + includeSecondaryObjects: true, + secondaryObjectThreshold: 'work-in-progress', + }) + ); expect(component.configForm.get('candidacyThreshold')?.disabled).toBe(true); }); @@ -541,6 +752,178 @@ describe('ReleaseTrackPageComponent', () => { ).toBeUndefined(); }); + it('should load virtual release track config from composition fields', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ + type: ReleaseTrackType.Virtual, + name: 'Virtual Release', + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.Quarantine, + tier_resolution: SnapshotTier.Staged, + status_resolution: 'awaiting-review', + }, + }, + snapshot_schedule: { + mode: SnapshotScheduleMode.Cron, + cron: '0 0 1 1,7 *', + }, + config: {}, + }) + ); + component.id = 'release-track--virtual'; + + component.getReleaseTrack(); + + expect(component.configForm.getRawValue()).toEqual( + expect.objectContaining({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'awaiting-review', + virtualSnapshotScheduleMode: SnapshotScheduleMode.Cron, + virtualSnapshotScheduleCron: '0 0 1 1,7 *', + }) + ); + }); + + it('should save virtual release track composition config', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + component.id = 'release-track--virtual'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + }, + ], + deduplication: { + strategy: DeduplicationStrategy.PrioritizeLatestObject, + }, + }, + } as any; + component.configForm.patchValue({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'reviewed', + }); + component.onEditConfig(); + component.configForm.patchValue({ + virtualDeduplicationStrategy: DeduplicationStrategy.Quarantine, + virtualDeduplicationTier: SnapshotTier.Staged, + virtualDeduplicationStatus: 'reviewed', + }); + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateComposition).toHaveBeenCalledWith( + 'release-track--virtual', + { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.Quarantine, + tier_resolution: SnapshotTier.Staged, + status_resolution: 'reviewed', + }, + } + ); + expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled(); + expect(component.isEditingConfig).toBe(false); + expect(refreshSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); + }); + + it('should edit virtual release track component tracks', () => { + mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + component.id = 'release-track--virtual'; + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + composition: { + component_tracks: [ + { + track_id: 'release-track--component-one', + resolution_strategy: 'latest_tagged', + priority: 0, + }, + ], + }, + } as any; + component.virtualComponentTrackOptions = [ + { + trackId: 'release-track--component-one', + name: 'Component One', + description: '', + latestTaggedVersion: '1.0', + taggedReleaseCount: 1, + }, + { + trackId: 'release-track--component-two', + name: 'Component Two', + description: '', + latestTaggedVersion: null, + taggedReleaseCount: 0, + }, + ]; + component.onEditConfig(); + + expect(component.filteredVirtualComponentTrackOptions).toEqual([ + expect.objectContaining({ + trackId: 'release-track--component-two', + }), + ]); + + component.selectVirtualComponentTrack({ + option: { + value: component.virtualComponentTrackOptions[1], + }, + }); + component.setVirtualComponentTrackObjectTypes( + component.virtualConfigComponentTracks[1], + ['attack-pattern'] + ); + component.removeVirtualComponentTrack( + component.virtualConfigComponentTracks[0] + ); + + component.onSaveConfig(); + + expect(mockReleaseTrackApiConnector.updateComposition).toHaveBeenCalledWith( + 'release-track--virtual', + expect.objectContaining({ + component_tracks: [ + { + track_id: 'release-track--component-two', + resolution_strategy: 'latest_tagged', + priority: 0, + filters: { + object_types: ['attack-pattern'], + }, + }, + ], + }) + ); + }); + it('should split auto-promotion release tracks into workflow lanes', () => { component.releaseTrack = { config: { auto_promote: true }, 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 fd5b690ff..e5eb8385d 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,8 +1,14 @@ +import { SelectionModel } from '@angular/cdk/collections'; import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; +import { finalize, take } from 'rxjs/operators'; import { ConflictPolicy, ConflictPolicyType, + DeduplicationStrategy, + DeduplicationStrategyType, ExportFormat, ExportFormatType, MemberSyncBehavior, @@ -15,24 +21,28 @@ import { ReleaseTrackSnapshot, ReleaseTrackSnapshotHistoryItem, ReleaseTrackType, + ResolutionStrategy, + SnapshotScheduleMode, + SnapshotScheduleModeType, + SnapshotTier, + SnapshotTierType, StixObjectRef, } from 'src/app/classes/release-tracks'; +import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; -import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; -import { MatDialog } from '@angular/material/dialog'; -import { SelectionModel } from '@angular/cdk/collections'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; -import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; +import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; +import { + AttackTypeToPlural, + StixTypeToAttackType, +} from 'src/app/utils/type-mappings'; import { StixType, WorkflowStatus, WorkflowStatusType, } from 'src/app/utils/types'; -import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; -import { finalize, take } from 'rxjs/operators'; -import { FormBuilder, FormGroup } from '@angular/forms'; -import { ReleaseTrackObjectItem } from 'src/app/components/release-track-object-card/release-track-object-card.component'; import { ALL_OBJECTS_STIX_LIST_CONFIG } from 'src/app/views/stix/all-objects-page/all-objects-page.component'; type ReleaseTrackLaneType = 'candidate' | 'staged' | 'member'; @@ -76,6 +86,60 @@ interface ReleaseTrackConfigFormValue { secondaryObjectThreshold: WorkflowStatusType; } +interface VirtualReleaseTrackConfigFormValue { + virtualDeduplicationStrategy: DeduplicationStrategyType; + virtualDeduplicationTier: SnapshotTierType; + virtualDeduplicationStatus: WorkflowStatusType; + virtualSnapshotScheduleMode: SnapshotScheduleModeType; + virtualSnapshotScheduleCron: string; +} + +interface VirtualResolutionRow { + trackId: string; + trackName: string; + strategy: string; + resolvedVersion?: string | null; + candidatesCount?: number | null; + stagedCount?: number | null; + membersCount?: number | null; +} + +interface VirtualComponentTrackSummary { + trackId: string; + name: string; + description: string; + type: string; + latestTaggedVersion: string | null; + taggedReleaseCount: number; + candidatesCount: number; + stagedCount: number; + membersCount: number; +} + +interface VirtualComponentTrackOption { + trackId: string; + name: string; + description: string; + latestTaggedVersion: string | null; + taggedReleaseCount: number; +} + +const VIRTUAL_OBJECT_TYPE_OPTIONS: StixType[] = [ + 'attack-pattern', + 'campaign', + 'course-of-action', + 'intrusion-set', + 'malware', + 'tool', + 'x-mitre-asset', + 'x-mitre-data-source', + 'x-mitre-data-component', + 'x-mitre-detection-strategy', + 'x-mitre-analytic', + 'x-mitre-matrix', + 'x-mitre-tactic', +]; + @Component({ selector: 'app-release-track-page', standalone: false, @@ -98,6 +162,12 @@ export class ReleaseTrackPageComponent implements OnInit { public releaseTrackConfig: ReleaseTrackConfig = {}; public snapshotHistory: SnapshotHistoryViewModel[] = []; public configForm: FormGroup; + private virtualComponentTrackSummaries = new Map< + string, + VirtualComponentTrackSummary + >(); + public virtualComponentTrackOptions: VirtualComponentTrackOption[] = []; + public virtualConfigComponentTracks: any[] = []; public candidacyOptions = Object.values(WorkflowStatus); public memberSyncStrategyOptions = Object.values(MemberSyncStrategy); @@ -107,6 +177,15 @@ export class ReleaseTrackPageComponent implements OnInit { ConflictPolicy ).filter(policy => policy !== ConflictPolicy.Abort); public stagedToMembersConflictOptions = Object.values(ConflictPolicy); + public virtualDeduplicationOptions = Object.values(DeduplicationStrategy); + public virtualDeduplicationTierOptions = Object.values(SnapshotTier); + public virtualDeduplicationStatusOptions = Object.values(WorkflowStatus); + public virtualSnapshotScheduleModeOptions = + Object.values(SnapshotScheduleMode); + public virtualObjectTypeOptions = VIRTUAL_OBJECT_TYPE_OPTIONS.map(type => ({ + label: this.formatStixType(type), + value: type, + })); constructor( private connector: ReleaseTracksConnectorService, @@ -127,6 +206,14 @@ export class ReleaseTrackPageComponent implements OnInit { stagedToMembersConflict: [ConflictPolicy.Abort], includeSecondaryObjects: [false], secondaryObjectThreshold: [WorkflowStatus.Reviewed], + virtualDeduplicationStrategy: [ + DeduplicationStrategy.PrioritizeLatestObject, + ], + virtualDeduplicationTier: [SnapshotTier.Member], + virtualDeduplicationStatus: [WorkflowStatus.Reviewed], + virtualSnapshotScheduleMode: [SnapshotScheduleMode.Manual], + virtualSnapshotScheduleCron: [''], + virtualComponentTrackSearch: [''], }); this.configForm.get('autoPromote')?.valueChanges.subscribe(autoPromote => { this.syncCandidacyThresholdControl(!!autoPromote); @@ -171,6 +258,120 @@ export class ReleaseTrackPageComponent implements OnInit { return this.releaseTrack?.members ?? []; } + public get virtualComponentTracks(): any[] { + return this.releaseTrack?.composition?.component_tracks ?? []; + } + + public get displayedVirtualConfigComponentTracks(): any[] { + return this.isEditingConfig + ? this.virtualConfigComponentTracks + : this.virtualComponentTracks; + } + + public get filteredVirtualComponentTrackOptions(): VirtualComponentTrackOption[] { + const search = this.getVirtualComponentTrackSearchText(); + const selectedIds = new Set( + this.virtualConfigComponentTracks + .map(track => track.track_id) + .filter((trackId): trackId is string => !!trackId) + ); + + return this.virtualComponentTrackOptions + .filter(track => !selectedIds.has(track.trackId)) + .filter(track => this.matchesVirtualComponentTrackSearch(track, search)); + } + + public get virtualSnapshotSchedule(): any { + return (this.releaseTrack as any)?.snapshot_schedule || {}; + } + + public get resolvedComponentSnapshots(): any[] { + return this.releaseTrack?.composition_resolution?.component_snapshots ?? []; + } + + public get quarantineObjects(): any[] { + return this.releaseTrack?.quarantine ?? []; + } + + public get virtualResolvedAt(): Date | null { + return this.releaseTrack?.composition_resolution?.resolved_at ?? null; + } + + public get virtualResolutionRows(): VirtualResolutionRow[] { + if (this.resolvedComponentSnapshots.length) { + return this.resolvedComponentSnapshots.map(component => { + const configuredTrack = this.virtualComponentTracks.find( + track => track.track_id === component.track_id + ); + return { + trackId: component.track_id, + trackName: + this.getComponentTrackSummary(component.track_id)?.name || + component.track_name || + component.track_id, + strategy: + component.strategy_used || + configuredTrack?.resolution_strategy || + '', + resolvedVersion: + component.resolved_version || + component.version || + component.version_label || + null, + ...this.getVirtualComponentCounts(component.track_id), + }; + }); + } + + return this.virtualComponentTracks.map(track => ({ + trackId: track.track_id, + trackName: this.getComponentTrackLabel(track), + strategy: track.resolution_strategy, + resolvedVersion: null, + ...this.getVirtualComponentCounts(track.track_id), + })); + } + + public get virtualResolvedObjectCount(): number { + const resolved = this.releaseTrack?.composition_resolution as any; + return ( + this.getResolutionNumber( + resolved, + 'total_objects', + 'totalObjects', + 'objects_contributed', + 'total_objects_after' + ) || + this.resolvedComponentSnapshots.reduce( + (total, component) => + total + Number(component.objects_contributed || 0), + 0 + ) + ); + } + + public get virtualDuplicateCount(): number { + return this.getResolutionNumber( + this.releaseTrack?.composition_resolution as any, + 'duplicates_found', + 'duplicates', + 'duplicate_count' + ); + } + + public get virtualConflictCount(): number { + const resolved = this.releaseTrack?.composition_resolution as any; + const conflictCount = this.getResolutionNumber( + resolved, + 'conflicts_found', + 'conflicts', + 'conflict_count' + ); + if (conflictCount) return conflictCount; + const resolvedConflicts = resolved?.deduplication?.conflicts_resolved; + return Array.isArray(resolvedConflicts) ? resolvedConflicts.length : 0; + } + public get autoPromotionEnabled(): boolean { return !!this.releaseTrack?.config?.auto_promote; } @@ -271,8 +472,18 @@ export class ReleaseTrackPageComponent implements OnInit { this.releaseTrack.name ); - if (!this.isEditingConfig && this.releaseTrack.config) { - this.setConfig(this.releaseTrack.config); + if (this.isVirtualReleaseTrack) { + this.loadVirtualComponentTrackSummaries(); + } else { + this.virtualComponentTrackSummaries.clear(); + } + + if (!this.isEditingConfig) { + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else if (this.releaseTrack.config) { + this.setConfig(this.releaseTrack.config); + } } // this.loadCandidates(); @@ -322,9 +533,13 @@ export class ReleaseTrackPageComponent implements OnInit { .subscribe({ next: config => { if (!this.isEditingConfig) { - this.setConfig( - this.getConfigFromResponse(config, this.releaseTrack?.config) - ); + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else { + this.setConfig( + this.getConfigFromResponse(config, this.releaseTrack?.config) + ); + } } }, error: err => { @@ -565,6 +780,337 @@ export class ReleaseTrackPageComponent implements OnInit { return `${item.object_ref}-${item.object_modified || ''}`; } + public trackByComponentTrack(_index: number, track: any): string { + return track?.track_id || `${_index}`; + } + + public trackByResolvedComponent(_index: number, component: any): string { + return component?.track_id || component?.track_name || `${_index}`; + } + + public trackByVirtualResolutionRow( + _index: number, + row: VirtualResolutionRow + ): string { + return row.trackId || `${_index}`; + } + + public onOpenComponentTrack(track: any): void { + const trackId = track?.track_id || track?.trackId; + if (!trackId) return; + this.router.navigate(['/dashboard/release-management', trackId]); + } + + public getComponentTrackLabel(track: any): string { + const resolvedComponent = this.resolvedComponentSnapshots.find( + component => component.track_id === track.track_id + ); + return ( + this.getComponentTrackSummary(track.track_id)?.name || + resolvedComponent?.track_name || + track.track_name || + track.track_id + ); + } + + public getComponentTrackFilters(track: any): string[] { + const objectTypes = track?.filters?.object_types; + if (!Array.isArray(objectTypes)) return []; + return objectTypes.map(type => this.formatConfigOption(type)); + } + + public getResolvedComponentLabel(component: any): string { + const version = + component.resolved_version || + component.version || + component.version_label; + if (version) return `Resolved version ${version}`; + + const snapshot = + component.resolved_snapshot_id || component.resolved_snapshot; + if (snapshot) return `Resolved snapshot ${this.toDisplayDate(snapshot)}`; + + return 'Resolved snapshot'; + } + + public getVirtualResolvedVersion(row: VirtualResolutionRow): string { + if (!row.resolvedVersion) return '-'; + return row.resolvedVersion.startsWith('v') + ? row.resolvedVersion + : `v${row.resolvedVersion}`; + } + + public getVirtualTierCount(value: number | null | undefined): string { + if (value === null || value === undefined) return '-'; + return String(value); + } + + public displayVirtualComponentTrackOption( + track: VirtualComponentTrackOption + ): string { + return track?.name || ''; + } + + public getVirtualComponentTrackSnapshotLabel( + track: VirtualComponentTrackOption | any + ): string { + const version = + track?.latestTaggedVersion || + track?.latest_tagged_version || + track?.latest_version; + return version ? `v${version}` : 'no tagged snapshots'; + } + + public selectVirtualComponentTrack(event: any): void { + const option = event?.option?.value as VirtualComponentTrackOption; + if (!option) return; + + this.virtualConfigComponentTracks = [ + ...this.virtualConfigComponentTracks, + { + track_id: option.trackId, + resolution_strategy: ResolutionStrategy.LatestTagged, + priority: this.virtualConfigComponentTracks.length, + }, + ]; + this.configForm + .get('virtualComponentTrackSearch') + ?.setValue('', { emitEvent: false }); + } + + public removeVirtualComponentTrack(track: any): void { + this.virtualConfigComponentTracks = + this.virtualConfigComponentTracks.filter(item => item !== track); + } + + public getVirtualComponentTrackObjectTypes(track: any): string[] { + const objectTypes = track?.filters?.object_types; + return Array.isArray(objectTypes) ? objectTypes : []; + } + + public setVirtualComponentTrackObjectTypes( + track: any, + objectTypes: string[] + ): void { + if (!objectTypes.length) { + const filters = { ...(track.filters || {}) }; + delete filters.object_types; + track.filters = Object.keys(filters).length ? filters : undefined; + return; + } + + track.filters = { + ...(track.filters || {}), + object_types: objectTypes, + }; + } + + public getVirtualComponentTrackDescription(track: any): string { + return this.getComponentTrackSummary(track?.track_id)?.description || ''; + } + + public getVirtualObjectTitle(item: any): string { + return item?.name || this.getFallbackObjectLabel(item?.object_ref); + } + + public getVirtualObjectSubtitle(item: any): string { + return ( + item?.attack_id || + item?.attackId || + item?.source_snapshot_version || + item?.conflict_reason || + item?.object_ref || + '' + ); + } + + private getResolutionNumber(resolution: any, ...keys: string[]): number { + if (!resolution) return 0; + const sources = [resolution, resolution.summary, resolution.deduplication]; + for (const source of sources) { + if (!source) continue; + for (const key of keys) { + if (typeof source[key] === 'number') return source[key]; + } + } + return 0; + } + + private loadVirtualComponentTrackSummaries(): void { + this.connector + .listReleaseTracks() + .pipe(take(1)) + .subscribe({ + next: result => { + const summaries = new Map(); + const options: VirtualComponentTrackOption[] = []; + for (const track of this.getReleaseTrackList(result)) { + const trackId = this.getReleaseTrackId(track); + if (!trackId) continue; + summaries.set(trackId, this.toVirtualComponentTrackSummary(track)); + if (this.isStandardReleaseTrack(track)) { + options.push(this.toVirtualComponentTrackOption(track)); + } + } + this.virtualComponentTrackSummaries = summaries; + this.virtualComponentTrackOptions = options; + }, + error: err => { + this.virtualComponentTrackSummaries.clear(); + this.virtualComponentTrackOptions = []; + console.error('Failed to load component track summaries', err); + }, + }); + } + + private getReleaseTrackList(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 getReleaseTrackId(track: any): string | null { + return track?.track_id || track?.id || null; + } + + private isStandardReleaseTrack(track: any): boolean { + return String(track?.type).toLowerCase() === ReleaseTrackType.Standard; + } + + private toVirtualComponentTrackSummary( + track: any + ): VirtualComponentTrackSummary { + const summary = track?.summary || {}; + return { + trackId: this.getReleaseTrackId(track) as string, + name: track?.name || 'Untitled release track', + description: track?.description || '', + type: track?.type || '', + latestTaggedVersion: this.getLatestTaggedVersion(track), + taggedReleaseCount: this.getTaggedReleaseCount(track), + candidatesCount: Number(summary.candidates_count || 0), + stagedCount: Number(summary.staged_count || 0), + membersCount: Number(summary.members_count || 0), + }; + } + + private toVirtualComponentTrackOption( + track: any + ): VirtualComponentTrackOption { + return { + trackId: this.getReleaseTrackId(track) as string, + name: track?.name || 'Untitled release track', + description: track?.description || '', + latestTaggedVersion: this.getLatestTaggedVersion(track), + taggedReleaseCount: this.getTaggedReleaseCount(track), + }; + } + + 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 getComponentTrackSummary( + trackId: string + ): VirtualComponentTrackSummary | undefined { + return this.virtualComponentTrackSummaries.get(trackId); + } + + private getVirtualComponentCounts( + trackId: string + ): Pick< + VirtualResolutionRow, + 'candidatesCount' | 'stagedCount' | 'membersCount' + > { + const summary = this.getComponentTrackSummary(trackId); + return { + candidatesCount: summary?.candidatesCount ?? null, + stagedCount: summary?.stagedCount ?? null, + membersCount: summary?.membersCount ?? null, + }; + } + + private cloneVirtualComponentTracks(tracks: any[]): any[] { + return tracks.map(track => ({ + ...track, + filters: track.filters + ? { + ...track.filters, + object_types: Array.isArray(track.filters.object_types) + ? [...track.filters.object_types] + : undefined, + domains: Array.isArray(track.filters.domains) + ? [...track.filters.domains] + : undefined, + } + : undefined, + })); + } + + private getVirtualComponentTrackSearchText(): string { + const value = this.configForm.get('virtualComponentTrackSearch')?.value; + if (!value) return ''; + if (typeof value === 'string') return value.trim().toLowerCase(); + return String(value.name || value.trackId || '') + .trim() + .toLowerCase(); + } + + private matchesVirtualComponentTrackSearch( + track: VirtualComponentTrackOption, + search: string + ): boolean { + if (!search) return true; + return [track.name, track.trackId, track.description] + .filter(Boolean) + .some(value => value.toLowerCase().includes(search)); + } + + private toDisplayDate(value: any): string { + if (!value) return ''; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); + } + + private toOptionalNumber(value: any): number | null { + if (value === null || value === undefined || value === '') return null; + const numberValue = Number(value); + return Number.isFinite(numberValue) ? numberValue : null; + } + + private formatStixType(type: StixType): string { + const attackType = StixTypeToAttackType[type]; + return AttackTypeToPlural[attackType]?.replace(/-/g, ' ') || type; + } + + private getFallbackObjectLabel(objectRef: string | undefined): string { + if (!objectRef) return 'Unknown object'; + const [type, id] = objectRef.split('--'); + if (!type || !id) return objectRef; + const attackType = StixTypeToAttackType[type as StixType]; + const typeLabel = attackType + ? attackType.replace(/-/g, ' ') + : type.replace(/-/g, ' '); + return `${typeLabel.replace(/\b\w/g, char => char.toUpperCase())} ${id.slice(0, 8)}`; + } + public onExport(): void { if (!this.id) return; @@ -639,12 +1185,98 @@ export class ReleaseTrackPageComponent implements OnInit { return `${safeName}-latest-${format}.json`; } + private getVirtualSnapshotPreviewDescription(preview: any): string { + const resolved = + preview?.preview?.would_resolve_to || + preview?.would_resolve_to || + preview?.composition_resolution || + preview; + const components = resolved?.component_snapshots || []; + const totalObjects = + resolved?.total_objects || + resolved?.summary?.total_objects || + preview?.total_objects || + preview?.summary?.total_objects; + + const componentText = components.length + ? `Components: ${components + .map((component: any) => { + const name = + component.track_name || component.track_id || 'component track'; + const version = + component.resolved_version || + component.version || + component.resolved_snapshot; + return version ? `${name} (${version})` : name; + }) + .join(', ')}.` + : 'No component details were returned.'; + const objectText = + totalObjects === undefined || totalObjects === null + ? 'Object count was not returned.' + : `${totalObjects} objects will be resolved.`; + + return `${objectText} ${componentText}`; + } + public onDraft(): void { if (!this.canCreateDraft) return; this.isCreatingDraft = true; this.connector - .createVirtualSnapshot(this.id) + .previewVirtualSnapshot(this.id) + .pipe(take(1)) + .subscribe({ + next: preview => { + this.isCreatingDraft = false; + if (!preview) return; + this.openVirtualSnapshotPreview(preview); + }, + error: err => { + this.isCreatingDraft = false; + console.error('Failed to preview draft snapshot', err); + }, + }); + } + + private openVirtualSnapshotPreview(preview: any): void { + const dialogRef = this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + data: { + title: 'Create draft snapshot?', + description: this.getVirtualSnapshotPreviewDescription(preview), + choices: [ + { + label: 'Create Draft', + value: 'create', + description: + 'Resolve the virtual track composition into a new draft snapshot.', + }, + { + label: 'Cancel', + value: 'cancel', + }, + ], + }, + }); + + dialogRef.afterClosed().subscribe(choice => { + if (choice !== 'create' || !this.id || !this.isVirtualReleaseTrack) { + return; + } + this.createVirtualDraftSnapshot(); + }); + } + + private createVirtualDraftSnapshot(): void { + this.isCreatingDraft = true; + this.connector + .createVirtualSnapshot(this.id, { + description: this.snapshotHistory.length + ? 'Virtual snapshot' + : 'Initial virtual snapshot', + }) .pipe( take(1), finalize(() => { @@ -662,16 +1294,25 @@ export class ReleaseTrackPageComponent implements OnInit { } public onEditConfig(): void { + if (this.isVirtualReleaseTrack) this.setVirtualConfig(); this.isEditingConfig = true; } public onCancelConfigEdit(): void { - this.setConfig(this.releaseTrackConfig); + if (this.isVirtualReleaseTrack) { + this.setVirtualConfig(); + } else { + this.setConfig(this.releaseTrackConfig); + } this.isEditingConfig = false; } public onSaveConfig(): void { if (!this.id || this.isSavingConfig) return; + if (this.isVirtualReleaseTrack) { + this.saveVirtualConfig(); + return; + } const payload = this.getConfigPayload(); this.isSavingConfig = true; @@ -731,6 +1372,9 @@ export class ReleaseTrackPageComponent implements OnInit { next: snapshot => { if (!snapshot) return; this.releaseTrack = snapshot; + if (this.isVirtualReleaseTrack) { + this.loadVirtualComponentTrackSummaries(); + } this.breadcrumbService.changeBreadcrumb( this.route.snapshot, snapshot.name @@ -806,10 +1450,21 @@ export class ReleaseTrackPageComponent implements OnInit { return String(value).replace(/[_-]+/g, ' '); } + public getVirtualTrackPriority(track: any, index: number): number { + return typeof track?.priority === 'number' ? track.priority : index; + } + + public getVirtualScheduleValue(key: string): string { + const value = this.virtualSnapshotSchedule?.[key]; + return value === null || value === undefined || value === '' + ? 'not set' + : String(value); + } + private setConfig(config: any): void { const normalizedConfig = this.normalizeConfig(config); this.releaseTrackConfig = normalizedConfig; - this.configForm.reset(this.getConfigFormValue(normalizedConfig), { + this.configForm.patchValue(this.getConfigFormValue(normalizedConfig), { emitEvent: false, }); this.syncCandidacyThresholdControl(!!normalizedConfig.auto_promote); @@ -818,6 +1473,64 @@ export class ReleaseTrackPageComponent implements OnInit { ); } + private setVirtualConfig(): void { + const deduplication = this.releaseTrack?.composition?.deduplication || {}; + const snapshotSchedule = this.virtualSnapshotSchedule; + this.virtualConfigComponentTracks = this.cloneVirtualComponentTracks( + this.virtualComponentTracks + ); + + this.configForm.patchValue( + { + virtualComponentTrackSearch: '', + virtualDeduplicationStrategy: + deduplication.strategy ?? + DeduplicationStrategy.PrioritizeLatestObject, + virtualDeduplicationTier: + deduplication.tier_resolution ?? SnapshotTier.Member, + virtualDeduplicationStatus: + deduplication.status_resolution ?? WorkflowStatus.Reviewed, + virtualSnapshotScheduleMode: + snapshotSchedule.mode ?? SnapshotScheduleMode.Manual, + virtualSnapshotScheduleCron: snapshotSchedule.cron ?? '', + }, + { emitEvent: false } + ); + } + + private saveVirtualConfig(): void { + const payload = this.getVirtualCompositionPayload(); + this.isSavingConfig = true; + this.connector + .updateComposition(this.id, payload) + .pipe( + take(1), + finalize(() => { + this.isSavingConfig = false; + }) + ) + .subscribe({ + next: result => { + this.isEditingConfig = false; + if (this.releaseTrack) { + this.releaseTrack.composition = this.getCompositionFromResponse( + result, + payload + ); + } + this.refreshReleaseTrackState(); + }, + error: err => { + console.error('Failed to update virtual release track config', err); + }, + }); + } + + private getCompositionFromResponse(response: any, fallback: any): any { + if (!response) return fallback; + return response.composition || response; + } + private syncCandidacyThresholdControl(autoPromote: boolean): void { this.syncWorkflowStatusControl('candidacyThreshold', autoPromote); } @@ -969,6 +1682,30 @@ export class ReleaseTrackPageComponent implements OnInit { return payload; } + private getVirtualCompositionPayload(): any { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + const currentComposition = this.releaseTrack?.composition || {}; + + return { + ...currentComposition, + component_tracks: this.virtualConfigComponentTracks.map( + (track, index) => ({ + ...track, + priority: index, + }) + ), + deduplication: { + strategy: + value.virtualDeduplicationStrategy || + DeduplicationStrategy.PrioritizeLatestObject, + tier_resolution: value.virtualDeduplicationTier || SnapshotTier.Member, + status_resolution: + value.virtualDeduplicationStatus || WorkflowStatus.Reviewed, + }, + }; + } + private openReleasePreviewDialog(preview: any): void { const conflicts = this.getReleaseConflicts(preview);