From aab7f0dba522f2b673b1917b93365bfc16fd25fb Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:29:07 -0400 Subject: [PATCH] feat(release-tracks): add guided schedule autocomplete Map selected phrases to UTC schedules and avoid invalid composition updates during schedule-only saves. --- docs/usage.md | 14 +- src/app/classes/release-tracks/composition.ts | 9 +- .../classes/release-tracks/release-track.ts | 2 +- .../rest-api/release-tracks.service.spec.ts | 11 + .../rest-api/release-tracks.service.ts | 18 + .../release-track-page.component.html | 344 +++++++++++--- .../release-track-page.component.scss | 81 ++++ .../release-track-page.component.spec.ts | 161 ++++++- .../release-track-page.component.ts | 441 +++++++++++++++++- 9 files changed, 968 insertions(+), 113 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ddac028a..7b6390ad 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -28,9 +28,21 @@ You can read more about the technical specifications for a collection, such as t #### Releasing a Release-Track Snapshot +For a virtual track's recurring schedule, use **Find a schedule** in the Config +tab: typing `hou` suggests **Hourly — at minute 0**, and typing `every 15` +suggests **Every 15 minutes**. Select a suggestion to apply it; unmatched text +does not change the schedule. The controls below let you customize the selected +schedule, and the generated expression is shown before saving. All times are +UTC. Hourly runs at the selected minute of each clock hour; 15/30-minute +intervals align to the clock rather than the time you click Save. + +Saving only a schedule leaves the virtual track's composition and draft +unchanged. Deduplication configuration supports the strategy selector; +preferred tier and preferred status are not supported by the API. + The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles. -The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, a progress bar with a status message appears under the page header while a long operation runs (creating a draft, preparing or committing a release, deleting a release or the track, saving the configuration), and deleting an entire track lives in the danger zone at the bottom of the Config tab. +The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. Virtual-track schedules are configured in the Config tab as manual, recurring, or specific dates; recurring schedules use guided cadence/day/time controls that generate a five-field UTC cron expression, and specific dates use controlled future UTC date and time inputs. Scheduled drafts run only when the connected REST API has its global scheduler enabled. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, a progress bar with a status message appears under the page header while a long operation runs (creating a draft, preparing or committing a release, deleting a release or the track, saving the configuration), and deleting an entire track lives in the danger zone at the bottom of the Config tab. Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes. diff --git a/src/app/classes/release-tracks/composition.ts b/src/app/classes/release-tracks/composition.ts index c2459254..47658371 100644 --- a/src/app/classes/release-tracks/composition.ts +++ b/src/app/classes/release-tracks/composition.ts @@ -5,20 +5,13 @@ // aggregates content from component tracks // ----------------------------------------------------------------------------- -import { DeduplicationStrategyType, SnapshotTierType } from './enums'; +import { DeduplicationStrategyType } from './enums'; import { ComponentTrack, ComponentSnapshotResolution } from './component-track'; -import { WorkflowStatusType } from 'src/app/utils/types'; export interface Composition { component_tracks?: ComponentTrack[]; deduplication?: { strategy?: DeduplicationStrategyType; - // When resolving conflicts between versions, which snapshot tier should be - // preferred (e.g., prefer from 'staged' over 'candidate' or vice-versa). - tier_resolution?: SnapshotTierType; - // When resolving conflicts, prefer the object with the highest workflow - // status (e.g., 'reviewed' over 'awaiting-review'). - status_resolution?: WorkflowStatusType; }; } diff --git a/src/app/classes/release-tracks/release-track.ts b/src/app/classes/release-tracks/release-track.ts index 2a1c7e77..ee408366 100644 --- a/src/app/classes/release-tracks/release-track.ts +++ b/src/app/classes/release-tracks/release-track.ts @@ -22,5 +22,5 @@ export interface ReleaseTrack { export interface SnapshotSchedule { mode?: SnapshotScheduleModeType; cron?: string | null; - dates?: Date[] | undefined; + dates?: (Date | string)[]; } diff --git a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts index d8a66451..12edece8 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts @@ -181,6 +181,17 @@ describe('ReleaseTracksConnectorService', () => { ); }); + it('should replace a virtual track schedule through the virtual namespace', () => { + const schedule = { mode: 'cron' as const, cron: '15 9 * * 1,3' }; + + service.updateSchedule('release-track--virtual', schedule).subscribe(); + + expect(http.put).toHaveBeenCalledWith( + `${environment.integrations.rest_api.url}/release-tracks/release-track--virtual/virtual/schedule`, + schedule + ); + }); + it('should promote an exact quarantined revision', () => { const body = { object_ref: 'attack-pattern--one', 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 0d6224fd..a6366ac5 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -23,6 +23,7 @@ import type { ReleaseTrackSnapshotOptions, ReviewPayload, SnapshotHistoryOptions, + SnapshotSchedule, StixBundlePayload, StixObjectRef, UpdateMetadataPayload, @@ -561,6 +562,23 @@ export class ReleaseTracksConnectorService extends ApiConnector { ); } + /** Replace the active registry-backed schedule for a virtual track. */ + public updateSchedule(id: string, body: SnapshotSchedule): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/virtual/schedule`; + return this.http + .put<{ snapshot_schedule: SnapshotSchedule }>(url, body) + .pipe( + tap(result => + logger.log( + `updated virtual snapshot schedule for track ${id}`, + result + ) + ), + catchError(this.handleError_raise()), + share() + ); + } + /** * POST /api/release-tracks/:id/virtual/quarantine/promote * Resolve one quarantined object to an exact revision in a new virtual draft. 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 9745d6d8..d678dade 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 @@ -913,7 +913,10 @@

@@ -1166,112 +1169,309 @@

Object Deduplication

+ + - +
+
+

Snapshot Schedule

-
Preferred Tier
-

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

+
Mode
+

Controls how draft snapshots are scheduled.

- + - Preferred Tier - + Schedule mode + - {{ formatConfigOption(option) }} + {{ getVirtualScheduleModeLabel(option) }} - + {{ - formatConfigOption( - configForm.get('virtualDeduplicationTier')?.value + getVirtualScheduleModeLabel( + configForm.get('virtualSnapshotScheduleMode')?.value ) }}
- + + -
-
Preferred Status
-

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

+
Recurring schedule
+

Choose a recurrence and time. All times are UTC.

- - - Preferred Status - + + + + Find a schedule + + - {{ formatConfigOption(option) }} + *ngFor="let preset of filteredSchedulePresets" + [value]="preset"> + {{ preset.label }} - + + No matching schedule. Use the controls below. + + + Select a suggestion to apply it, or customize below. All + times are UTC. +
+
+ This track uses an existing expression that the guided + editor cannot represent: + {{ virtualCronExpression }} +
+ +
+ +
+ + Repeats + + + {{ option.label }} + + + + + + Hour (UTC) + + + {{ formatTwoDigit(hour) }} + + + + + + Minute + + + {{ formatTwoDigit(minute) }} + + + + + + Interval + + Every 15 minutes + Every 30 minutes + + + + + Days of week + + + {{ day.label }} + + + + + + Day of month + + + {{ day }} + + + + + + Months + + + {{ month.label }} + + + +
+ +
+ Generated expression: + {{ virtualCronExpression }} +
- - - {{ - formatConfigOption( - configForm.get('virtualDeduplicationStatus')?.value - ) - }} - + + {{ + getVirtualScheduleValue('cron') + }} -
-
-
+ -
-
-

Snapshot Schedule

+ + -
-
Mode
-

Controls how draft snapshots are scheduled.

+
Specific dates
+

+ Create one draft at each selected future date. All times are + UTC and survive server restarts. +

- - {{ - formatConfigOption( - configForm.get('virtualSnapshotScheduleMode')?.value - ) - }} - -
- +
+
+ + Date + + + + Hour (UTC) + + + {{ formatTwoDigit(hour) }} + + + + + Minute + + + {{ formatTwoDigit(minute) }} + + + + +
+
-
-
-
Cron Expression
-

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

+
+
+ {{ date | date: 'MMM d, y, HH:mm' : 'UTC' }} UTC + +
- - {{ getVirtualScheduleValue('cron') }} - -
+ +

+ Add at least one future date to save this schedule. +

+
+
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 510dcb30..9dbaccf3 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 @@ -144,6 +144,77 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } + .schedule-editor-grid, + .schedule-date-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; + } + + .schedule-wide-field { + grid-column: 1 / -1; + width: 100%; + } + + .schedule-expression-preview, + .schedule-existing-warning, + .schedule-date-list { + margin-top: 12px; + } + + .schedule-existing-warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px; + border: 1px solid colors.color(warn); + border-radius: 4px; + } + + .schedule-date-controls { + align-items: start; + grid-template-columns: 2fr 1fr 1fr auto; + + button { + margin-top: 4px; + } + } + + .schedule-date-list { + display: flex; + flex-direction: column; + gap: 4px; + + > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 40px; + padding-left: 12px; + border-radius: 4px; + + .dark & { + background: rgba(colors.color(secondary), 0.1); + } + + .light & { + background: rgba(colors.color(secondary), 0.055); + } + } + } + + .schedule-read-value { + display: block; + margin-top: 12px; + } + + .schedule-validation-message { + margin: 12px 0 0; + color: colors.color(error); + } + .config-component-list { display: flex; flex-direction: column; @@ -1822,6 +1893,16 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } + .schedule-editor-grid, + .schedule-date-controls { + grid-template-columns: 1fr; + } + + .schedule-existing-warning { + align-items: flex-start; + flex-direction: column; + } + .config-component-row { flex-direction: column; gap: 10px; 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 6d5316fe..59a3ca70 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 @@ -31,7 +31,6 @@ import { MemberSyncStrategy, ReleaseTrackType, SnapshotScheduleMode, - SnapshotTier, } from 'src/app/classes/release-tracks'; describe('ReleaseTrackPageComponent', () => { @@ -66,6 +65,9 @@ describe('ReleaseTrackPageComponent', () => { getConfig: vi.fn(() => createAsyncObservable(null)), updateConfig: vi.fn(() => createAsyncObservable({})), updateComposition: vi.fn(() => createAsyncObservable({})), + updateSchedule: vi.fn(() => + createAsyncObservable({ snapshot_schedule: { mode: 'manual' } }) + ), reviewCandidates: vi.fn(() => createAsyncObservable({})), updateMetadataByLatest: vi.fn(() => createAsyncObservable({})), addCandidates: vi.fn(() => createAsyncObservable({})), @@ -1897,8 +1899,6 @@ describe('ReleaseTrackPageComponent', () => { ], deduplication: { strategy: DeduplicationStrategy.Quarantine, - tier_resolution: SnapshotTier.Staged, - status_resolution: 'awaiting-review', }, }, snapshot_schedule: { @@ -1915,8 +1915,6 @@ describe('ReleaseTrackPageComponent', () => { 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 *', }) @@ -1931,6 +1929,9 @@ describe('ReleaseTrackPageComponent', () => { .spyOn(component, 'getSnapshotHistory') .mockImplementation(() => undefined); mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + mockReleaseTrackApiConnector.updateSchedule.mockReturnValue( + of({ snapshot_schedule: { mode: SnapshotScheduleMode.Manual } }) + ); mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); component.id = 'release-track--virtual'; component.releaseTrack = { @@ -1949,14 +1950,10 @@ describe('ReleaseTrackPageComponent', () => { } 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(); @@ -1973,11 +1970,13 @@ describe('ReleaseTrackPageComponent', () => { ], deduplication: { strategy: DeduplicationStrategy.Quarantine, - tier_resolution: SnapshotTier.Staged, - status_resolution: 'reviewed', }, } ); + expect(mockReleaseTrackApiConnector.updateSchedule).toHaveBeenCalledWith( + 'release-track--virtual', + { mode: SnapshotScheduleMode.Manual } + ); // Publication settings apply to virtual tracks too and are saved after // the composition. expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalledWith( @@ -1996,6 +1995,146 @@ describe('ReleaseTrackPageComponent', () => { expect(historySpy).toHaveBeenCalled(); }); + it('should suggest hourly from partial text and apply only a selected preset', () => { + component.configForm.patchValue({ + virtualSnapshotScheduleMode: SnapshotScheduleMode.Cron, + }); + const previous = component.virtualCronExpression; + component.configForm.patchValue({ virtualScheduleSearch: 'hou' }); + expect( + component.filteredSchedulePresets.map(preset => preset.label) + ).toEqual(['Hourly — at minute 0']); + expect(component.virtualCronExpression).toBe(previous); + expect(component.isVirtualScheduleValid).toBe(false); + component.selectSchedulePreset(component.filteredSchedulePresets[0]); + expect(component.virtualCronExpression).toBe('0 * * * *'); + expect(component.isVirtualScheduleValid).toBe(true); + expect(component.virtualCronUsesExistingExpression).toBe(false); + }); + + it('should apply every preset and allow subsequent guided customization', () => { + for (const preset of component.schedulePresets) { + component.selectSchedulePreset(preset); + expect(component.virtualCronExpression).toBe(preset.cron); + expect(component.virtualCronUsesExistingExpression).toBe(false); + } + component.configForm.patchValue({ virtualScheduleSearch: 'EVERY 15' }); + component.selectSchedulePreset(component.filteredSchedulePresets[0]); + expect(component.virtualCronExpression).toBe('*/15 * * * *'); + component.configForm.patchValue({ virtualCronInterval: 30 }); + expect(component.virtualCronExpression).toBe('*/30 * * * *'); + expect(component.configForm.get('virtualScheduleSearch')?.value).toBeNull(); + component.configForm.patchValue({ virtualScheduleSearch: 'tomorrowish' }); + expect(component.filteredSchedulePresets).toEqual([]); + component.selectSchedulePreset({ label: 'Invalid', cron: 'bad' }); + expect(component.virtualCronExpression).toBe('*/30 * * * *'); + }); + + it('should generate a controlled weekly UTC cron schedule', () => { + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + snapshot_schedule: { mode: SnapshotScheduleMode.Manual }, + } as any; + component.onEditConfig(); + component.configForm.patchValue({ + virtualSnapshotScheduleMode: SnapshotScheduleMode.Cron, + virtualCronCadence: 'weekly', + virtualCronHour: 9, + virtualCronMinute: 15, + virtualCronWeekdays: [3, 1], + }); + + expect(component.virtualCronExpression).toBe('15 9 * * 1,3'); + expect(component.isVirtualScheduleValid).toBe(true); + }); + + it('should save every 15 minutes without resubmitting a server-shaped composition', () => { + mockReleaseTrackApiConnector.updateSchedule.mockReturnValue( + of({ + snapshot_schedule: { + mode: SnapshotScheduleMode.Cron, + cron: '*/15 * * * *', + }, + }) + ); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--virtual'; + component.releaseTrack = { + id: component.id, + type: ReleaseTrackType.Virtual, + snapshot_schedule: { mode: SnapshotScheduleMode.Manual }, + composition: { + component_tracks: [ + { + track_id: 'release-track--0ac85c02-e554-41cc-bdf9-8f89a1ce1fd1', + resolution_strategy: 'latest_tagged', + priority: 10, + filters: { domains: ['enterprise-attack'] }, + }, + ], + deduplication: { + strategy: DeduplicationStrategy.PrioritizeLatestObject, + }, + }, + } as any; + component.onEditConfig(); + component.configForm.patchValue({ + virtualSnapshotScheduleMode: SnapshotScheduleMode.Cron, + }); + component.selectSchedulePreset( + component.schedulePresets.find(preset => preset.cron === '*/15 * * * *')! + ); + + component.onSaveConfig(); + + expect( + mockReleaseTrackApiConnector.updateComposition + ).not.toHaveBeenCalled(); + expect(mockReleaseTrackApiConnector.updateSchedule).toHaveBeenCalledWith( + component.id, + { mode: SnapshotScheduleMode.Cron, cron: '*/15 * * * *' } + ); + }); + + it('should preserve an existing cron expression the guided editor cannot represent', () => { + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + snapshot_schedule: { + mode: SnapshotScheduleMode.Cron, + cron: '*/10 8-17 * * 1-5', + }, + } as any; + + component.onEditConfig(); + + expect(component.virtualCronUsesExistingExpression).toBe(true); + expect(component.virtualCronExpression).toBe('*/10 8-17 * * 1-5'); + }); + + it('should add unique future UTC schedule dates and require at least one', () => { + component.releaseTrack = { + type: ReleaseTrackType.Virtual, + snapshot_schedule: { mode: SnapshotScheduleMode.Dates, dates: [] }, + } as any; + component.onEditConfig(); + component.configForm.patchValue({ + virtualSnapshotScheduleMode: SnapshotScheduleMode.Dates, + virtualScheduleDateDraft: '2099-07-15', + virtualScheduleDateHour: 9, + virtualScheduleDateMinute: 30, + }); + + expect(component.isVirtualScheduleValid).toBe(false); + component.addVirtualScheduleDate(); + component.configForm.patchValue({ virtualScheduleDateDraft: '2099-07-15' }); + component.addVirtualScheduleDate(); + + expect(component.virtualScheduleDates).toEqual([ + '2099-07-15T09:30:00.000Z', + ]); + expect(component.isVirtualScheduleValid).toBe(true); + }); + it('should retain object types when clearing component-track domains', () => { const track: any = { filters: { 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 20533ea7..7af59a0d 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 @@ -33,8 +33,7 @@ import { ResolutionStrategy, SnapshotScheduleMode, SnapshotScheduleModeType, - SnapshotTier, - SnapshotTierType, + SnapshotSchedule, StixObjectRef, UpdateMetadataPayload, } from 'src/app/classes/release-tracks'; @@ -154,10 +153,26 @@ interface PublicationOption { interface VirtualReleaseTrackConfigFormValue { virtualDeduplicationStrategy: DeduplicationStrategyType; - virtualDeduplicationTier: SnapshotTierType; - virtualDeduplicationStatus: WorkflowStatusType; virtualSnapshotScheduleMode: SnapshotScheduleModeType; virtualSnapshotScheduleCron: string; + virtualCronCadence: VirtualCronCadence; + virtualCronMinute: number; + virtualCronHour: number; + virtualCronWeekdays: number[]; + virtualCronMonthDay: number; + virtualCronMonths: number[]; + virtualScheduleDates: string[]; + virtualScheduleDateDraft: string; + virtualScheduleDateHour: number; + virtualScheduleDateMinute: number; +} + +type VirtualCronCadence = + 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly'; + +interface SchedulePreset { + label: string; + cron: string; } interface VirtualResolutionRow { @@ -207,6 +222,29 @@ const VIRTUAL_OBJECT_TYPE_OPTIONS: StixType[] = [ ]; const VIRTUAL_DOMAIN_FILTER_OPTIONS = ['enterprise', 'ics', 'mobile']; +const VIRTUAL_WEEKDAY_OPTIONS = [ + { label: 'Sunday', value: 0 }, + { label: 'Monday', value: 1 }, + { label: 'Tuesday', value: 2 }, + { label: 'Wednesday', value: 3 }, + { label: 'Thursday', value: 4 }, + { label: 'Friday', value: 5 }, + { label: 'Saturday', value: 6 }, +]; +const VIRTUAL_MONTH_OPTIONS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +].map((label, index) => ({ label, value: index + 1 })); @Component({ selector: 'app-release-track-page', @@ -255,8 +293,6 @@ export class ReleaseTrackPageComponent implements OnInit { ).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 => ({ @@ -264,6 +300,55 @@ export class ReleaseTrackPageComponent implements OnInit { value: type, })); public virtualDomainOptions = VIRTUAL_DOMAIN_FILTER_OPTIONS; + public virtualCronCadenceOptions: { + label: string; + value: VirtualCronCadence; + }[] = [ + { label: 'Every 15 or 30 minutes', value: 'interval' }, + { label: 'Hourly', value: 'hourly' }, + { label: 'Daily', value: 'daily' }, + { label: 'Weekly', value: 'weekly' }, + { label: 'Monthly', value: 'monthly' }, + { label: 'Yearly', value: 'yearly' }, + ]; + public virtualWeekdayOptions = VIRTUAL_WEEKDAY_OPTIONS; + public virtualMonthOptions = VIRTUAL_MONTH_OPTIONS; + public virtualHourOptions = Array.from({ length: 24 }, (_, value) => value); + public virtualMinuteOptions = Array.from( + { length: 12 }, + (_, index) => index * 5 + ); + public virtualMonthDayOptions = Array.from( + { length: 28 }, + (_, index) => index + 1 + ); + public virtualCronUsesExistingExpression = false; + public readonly schedulePresets: SchedulePreset[] = [ + { label: 'Hourly — at minute 0', cron: '0 * * * *' }, + { label: 'Every 15 minutes', cron: '*/15 * * * *' }, + { label: 'Every 30 minutes', cron: '*/30 * * * *' }, + { label: 'Daily at midnight UTC', cron: '0 0 * * *' }, + { label: 'Weekdays at 09:00 UTC', cron: '0 9 * * 1,2,3,4,5' }, + { label: 'Weekly on Monday at 09:00 UTC', cron: '0 9 * * 1' }, + { label: 'Monthly on day 1 at midnight UTC', cron: '0 0 1 * *' }, + { label: 'Yearly on January 1 at midnight UTC', cron: '0 0 1 1 *' }, + ]; + + public displaySchedulePreset = (preset: SchedulePreset | null): string => + preset?.label || ''; + + public get filteredSchedulePresets(): SchedulePreset[] { + const value = this.configForm.get('virtualScheduleSearch')?.value; + const query = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return this.schedulePresets.filter(preset => + preset.label.toLowerCase().includes(query) + ); + } + + public selectSchedulePreset(preset: SchedulePreset): void { + if (!this.schedulePresets.includes(preset)) return; + this.configureVirtualCronExpression(preset.cron); + } constructor( private connector: ReleaseTracksConnectorService, @@ -298,15 +383,41 @@ export class ReleaseTrackPageComponent implements OnInit { virtualDeduplicationStrategy: [ DeduplicationStrategy.PrioritizeLatestObject, ], - virtualDeduplicationTier: [SnapshotTier.Member], - virtualDeduplicationStatus: [WorkflowStatus.Reviewed], virtualSnapshotScheduleMode: [SnapshotScheduleMode.Manual], virtualSnapshotScheduleCron: [''], + virtualCronCadence: ['daily' as VirtualCronCadence], + virtualCronMinute: [0], + virtualCronInterval: [15], + virtualScheduleSearch: [null], + virtualCronHour: [0], + virtualCronWeekdays: [[1]], + virtualCronMonthDay: [1], + virtualCronMonths: [[1]], + virtualScheduleDates: [[] as string[]], + virtualScheduleDateDraft: [''], + virtualScheduleDateHour: [0], + virtualScheduleDateMinute: [0], virtualComponentTrackSearch: [''], }); this.configForm.get('autoPromote')?.valueChanges.subscribe(autoPromote => { this.syncCandidacyThresholdControl(!!autoPromote); }); + [ + 'virtualCronCadence', + 'virtualCronMinute', + 'virtualCronInterval', + 'virtualCronHour', + 'virtualCronWeekdays', + 'virtualCronMonthDay', + 'virtualCronMonths', + ].forEach(controlName => { + this.configForm.get(controlName)?.valueChanges.subscribe(() => { + this.virtualCronUsesExistingExpression = false; + this.configForm + .get('virtualScheduleSearch') + ?.reset(null, { emitEvent: false }); + }); + }); } ngOnInit(): void { @@ -1907,6 +2018,7 @@ export class ReleaseTrackPageComponent implements OnInit { return; } if (this.isVirtualReleaseTrack) { + if (!this.isVirtualScheduleValid) return; this.saveVirtualConfig(); return; } @@ -2595,6 +2707,17 @@ export class ReleaseTrackPageComponent implements OnInit { return String(value).replace(/[_-]+/g, ' '); } + public getVirtualScheduleModeLabel(mode: SnapshotScheduleMode): string { + switch (mode) { + case SnapshotScheduleMode.Cron: + return 'Recurring'; + case SnapshotScheduleMode.Dates: + return 'Specific dates'; + default: + return 'Manual'; + } + } + public getVirtualTrackPriority(track: any, index: number): number { return typeof track?.priority === 'number' ? track.priority : index; } @@ -2606,6 +2729,144 @@ export class ReleaseTrackPageComponent implements OnInit { : String(value); } + public formatTwoDigit(value: number): string { + return String(value).padStart(2, '0'); + } + + public get virtualCronExpression(): string { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + if (this.virtualCronUsesExistingExpression) { + return value.virtualSnapshotScheduleCron; + } + + const prefix = `${value.virtualCronMinute} ${value.virtualCronHour}`; + switch (value.virtualCronCadence) { + case 'hourly': + return `${value.virtualCronMinute} * * * *`; + case 'interval': + return `*/${this.configForm.get('virtualCronInterval')?.value} * * * *`; + case 'weekly': + return `${prefix} * * ${[...value.virtualCronWeekdays].sort().join(',')}`; + case 'monthly': + return `${prefix} ${value.virtualCronMonthDay} * *`; + case 'yearly': + return `${prefix} ${value.virtualCronMonthDay} ${[ + ...value.virtualCronMonths, + ] + .sort((left, right) => left - right) + .join(',')} *`; + default: + return `${prefix} * * *`; + } + } + + public get virtualScheduleDates(): string[] { + return ( + (this.configForm.get('virtualScheduleDates')?.value as string[]) || [] + ); + } + + public get virtualScheduleDateMin(): string { + return new Date().toISOString().slice(0, 10); + } + + public get isVirtualScheduleValid(): boolean { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + if (value.virtualSnapshotScheduleMode === SnapshotScheduleMode.Dates) { + return value.virtualScheduleDates.length > 0; + } + if (value.virtualSnapshotScheduleMode !== SnapshotScheduleMode.Cron) { + return true; + } + if ( + typeof this.configForm.get('virtualScheduleSearch')?.value === 'string' && + this.configForm.get('virtualScheduleSearch')?.value.trim() + ) + return false; + if (this.virtualCronUsesExistingExpression) return true; + if ( + value.virtualCronCadence === 'weekly' && + !value.virtualCronWeekdays.length + ) { + return false; + } + return !( + value.virtualCronCadence === 'yearly' && !value.virtualCronMonths.length + ); + } + + public get canAddVirtualScheduleDate(): boolean { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + if (!/^\d{4}-\d{2}-\d{2}$/.test(value.virtualScheduleDateDraft)) { + return false; + } + return this.isValidVirtualScheduleDate( + value.virtualScheduleDateDraft, + value.virtualScheduleDateHour, + value.virtualScheduleDateMinute + ); + } + + public addVirtualScheduleDate(): void { + if (!this.canAddVirtualScheduleDate) return; + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + const date = this.buildVirtualScheduleDate( + value.virtualScheduleDateDraft, + value.virtualScheduleDateHour, + value.virtualScheduleDateMinute + ).toISOString(); + this.configForm.patchValue({ + virtualScheduleDates: [ + ...new Set([...value.virtualScheduleDates, date]), + ].sort(), + virtualScheduleDateDraft: '', + }); + } + + public removeVirtualScheduleDate(date: string): void { + this.configForm.patchValue({ + virtualScheduleDates: this.virtualScheduleDates.filter( + item => item !== date + ), + }); + } + + public replaceExistingCronExpression(): void { + this.virtualCronUsesExistingExpression = false; + this.configForm.patchValue({ + virtualCronCadence: 'daily', + virtualCronMinute: 0, + virtualCronHour: 0, + }); + } + + private buildVirtualScheduleDate( + date: string, + hour: number, + minute: number + ): Date { + return new Date( + `${date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00.000Z` + ); + } + + private isValidVirtualScheduleDate( + date: string, + hour: number, + minute: number + ): boolean { + const value = this.buildVirtualScheduleDate(date, hour, minute); + return ( + !Number.isNaN(value.getTime()) && + value.toISOString().slice(0, 10) === date && + value.getTime() > Date.now() + ); + } + private setConfig(config: any): void { const normalizedConfig = this.normalizeConfig(config); this.releaseTrackConfig = normalizedConfig; @@ -2631,16 +2892,123 @@ export class ReleaseTrackPageComponent implements OnInit { 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 ?? '', + virtualScheduleDates: (snapshotSchedule.dates ?? []).map((date: any) => + new Date(date).toISOString() + ), + virtualScheduleDateDraft: '', }, { emitEvent: false } ); + this.configureVirtualCronExpression(snapshotSchedule.cron); + this.initialVirtualComposition = JSON.stringify( + this.getVirtualCompositionPayload() + ); + } + + private configureVirtualCronExpression(cron?: string): void { + this.configForm + .get('virtualScheduleSearch') + ?.reset( + this.schedulePresets.find(preset => preset.cron === cron) || null, + { emitEvent: false } + ); + if (cron && /^(0|5|[1-5][05]|\*\/(15|30)) \* \* \* \*$/.test(cron)) { + this.virtualCronUsesExistingExpression = false; + this.configForm.patchValue( + { + virtualCronCadence: cron.startsWith('*/') ? 'interval' : 'hourly', + virtualCronMinute: cron.startsWith('*/') + ? 0 + : Number(cron.split(' ')[0]), + virtualCronInterval: cron.startsWith('*/30') ? 30 : 15, + }, + { emitEvent: false } + ); + return; + } + if (!cron) { + this.virtualCronUsesExistingExpression = false; + return; + } + const fields = cron.trim().split(/\s+/); + const minute = Number(fields[0]); + const hour = Number(fields[1]); + if ( + fields.length !== 5 || + !Number.isInteger(minute) || + minute < 0 || + minute > 59 || + !this.virtualMinuteOptions.includes(minute) || + !Number.isInteger(hour) || + hour < 0 || + hour > 23 + ) { + this.virtualCronUsesExistingExpression = true; + return; + } + + const parseList = (field: string, minimum: number, maximum: number) => { + const values = field.split(',').map(Number); + return values.length > 0 && + new Set(values).size === values.length && + values.every( + value => + Number.isInteger(value) && value >= minimum && value <= maximum + ) + ? values + : null; + }; + const [, , dayOfMonth, month, dayOfWeek] = fields; + let controls: Record | null = null; + if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + controls = { virtualCronCadence: 'daily' }; + } else if (dayOfMonth === '*' && month === '*') { + const weekdays = parseList(dayOfWeek, 0, 6); + if (weekdays) { + controls = { + virtualCronCadence: 'weekly', + virtualCronWeekdays: weekdays, + }; + } + } else if (month === '*' && dayOfWeek === '*') { + const monthDay = Number(dayOfMonth); + if (Number.isInteger(monthDay) && monthDay >= 1 && monthDay <= 28) { + controls = { + virtualCronCadence: 'monthly', + virtualCronMonthDay: monthDay, + }; + } + } else if (dayOfWeek === '*') { + const monthDay = Number(dayOfMonth); + const months = parseList(month, 1, 12); + if ( + Number.isInteger(monthDay) && + monthDay >= 1 && + monthDay <= 28 && + months + ) { + controls = { + virtualCronCadence: 'yearly', + virtualCronMonthDay: monthDay, + virtualCronMonths: months, + }; + } + } + + this.virtualCronUsesExistingExpression = !controls; + if (controls) { + this.configForm.patchValue( + { + ...controls, + virtualCronMinute: minute, + virtualCronHour: hour, + }, + { emitEvent: false } + ); + } } private saveVirtualConfig(): void { @@ -2649,29 +3017,42 @@ export class ReleaseTrackPageComponent implements OnInit { this.configForm.getRawValue() as ReleaseTrackConfigFormValue ); const aliasChanged = !!this.getAliasUpdate(); + const compositionChanged = this.hasVirtualCompositionChanges(payload); this.isSavingConfig = true; this.withAliasUpdate(() => - this.connector.updateComposition(this.id, payload) + compositionChanged + ? this.connector.updateComposition(this.id, payload) + : of(this.releaseTrack?.composition) ) .pipe( take(1), switchMap(result => + this.connector + .updateSchedule(this.id, this.getVirtualSchedulePayload()) + .pipe(map(scheduleResult => ({ result, scheduleResult }))) + ), + switchMap(({ result, scheduleResult }) => this.connector .updateConfig(this.id, { publication }) - .pipe(map(configResult => ({ result, configResult }))) + .pipe( + map(configResult => ({ result, scheduleResult, configResult })) + ) ), finalize(() => { this.isSavingConfig = false; }) ) .subscribe({ - next: ({ result, configResult }) => { + next: ({ result, scheduleResult, configResult }) => { this.isEditingConfig = false; if (this.releaseTrack) { this.releaseTrack.composition = this.getCompositionFromResponse( result, payload ); + this.releaseTrack.snapshot_schedule = + scheduleResult?.snapshot_schedule || + this.getVirtualSchedulePayload(); } this.setConfig( this.getConfigFromResponse(configResult, { publication }) @@ -2686,6 +3067,31 @@ export class ReleaseTrackPageComponent implements OnInit { }); } + private getVirtualSchedulePayload(): SnapshotSchedule { + const value = + this.configForm.getRawValue() as VirtualReleaseTrackConfigFormValue; + switch (value.virtualSnapshotScheduleMode) { + case SnapshotScheduleMode.Cron: + return { + mode: SnapshotScheduleMode.Cron, + cron: this.virtualCronExpression, + }; + case SnapshotScheduleMode.Dates: + return { + mode: SnapshotScheduleMode.Dates, + dates: value.virtualScheduleDates, + }; + default: + return { mode: SnapshotScheduleMode.Manual }; + } + } + + private initialVirtualComposition = ''; + + private hasVirtualCompositionChanges(payload: any): boolean { + return JSON.stringify(payload) !== this.initialVirtualComposition; + } + private getCompositionFromResponse(response: any, fallback: any): any { if (!response) return fallback; return response.composition || response; @@ -2956,10 +3362,8 @@ export class ReleaseTrackPageComponent implements OnInit { 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, @@ -2970,9 +3374,6 @@ export class ReleaseTrackPageComponent implements OnInit { strategy: value.virtualDeduplicationStrategy || DeduplicationStrategy.PrioritizeLatestObject, - tier_resolution: value.virtualDeduplicationTier || SnapshotTier.Member, - status_resolution: - value.virtualDeduplicationStatus || WorkflowStatus.Reviewed, }, }; }