Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,61 @@ requires explicitly choosing **Latest preview** or **Latest tagged** before
saving or creating another snapshot; it never silently includes staged content.
Historical draft-only provenance retains its original meaning.

Virtual-track history offers **Drafts only**, **Releases only**, and **All
releases** (the default). All releases includes both drafts and tagged releases;
Releases only never inserts a current draft into the results. The paginator loads
25 entries per page, newest first.

The smaller summary below the selector shows **Tagged releases**, **Drafts**, and
**Total** for the selected view across all matching pages, not just the visible
page. For example, a track with two tags and three drafts reports `2 / 3 / 5` in
All releases, `2 / 0 / 2` in Releases only, and `0 / 3 / 3` in Drafts only.
Zero-result views still show all three counters.

History refreshes automatically every 30 seconds while the Releases tab is
visible, and immediately when entering that tab or returning browser focus or
visibility. This picks up cron-created snapshots and other users' changes
without a Refresh history button. Refresh pauses while editing, while a dialog
is open, or while an operation/request is in flight. It preserves the selected
filter, page and scroll position; if cleanup removes the last page, it moves to
the last remaining page. Local changes still refresh immediately.

Automatic refresh reads lightweight history and cleanup status rather than
reloading full snapshot contents or configuration. The **LATEST** pill and
latest-only actions use unfiltered server identities, so an older filtered page
is not mistaken for the current snapshot.

**Create Draft** offers administrators **Delete older drafts after creating this
draft**, off by default every time the dialog opens. A positive whole-number
limit applies to that request only; it is never saved or inherited from a
schedule.

To configure persistent retention, click **Edit Config** and select **Recurring**
schedule mode. **Recurring draft retention** is read-only outside edit mode;
administrators can change its limit while editing. **Cancel** restores the saved
policy. **Save Config** sends schedule-only changes without creating a content
draft or deleting anything immediately. Actual cron runs apply this policy;
manual creation and dated schedules do not. The former global policy has been
removed, so configure the recurring policy explicitly.

Both policies count all untagged snapshots across the track, regardless of how
they were created. Tagged releases and protected sources survive. Configuration,
metadata, composition and quarantine edits never trigger retention.

The virtual release preview offers administrators an unchecked **Delete earlier
drafts after tagging** option. It shows eligible/protected counts and the strict
interval since the preceding release (or track creation). This permanently
deletes history, not content; the selected release and newer snapshots survive.
If the reviewed deletion set changes, fetch a fresh preview.

If a release commits but cleanup fails, the page says so and exposes **Retry
cleanup**. This repairs the existing operation without tagging again. Pending
operations are rediscovered when reopening the track. Completed cleanup appears
as a floating notification with an **X** dismiss button; dismissal survives
ordinary history refreshes. Pending/failed operations retain their repair
controls. Large cleanups can need more than one retry. If a draft is removed
while you are viewing it, the automatic history refresh lets you select a surviving snapshot.

The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles.

The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. For virtual tracks, each snapshot card shows its own Composition Resolution provenance: the exact component track snapshot, tagged version, snapshot creation timestamp, resolution strategy and filters, and source/filter/contribution counts used for that materialization. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. A standard release preserves its exact pre-release draft, which remains hidden while the release exists. Administrators can convert the most recent tagged release back to a draft from the Releases tab by confirming its version. Standard tracks restore the preserved pre-release draft; virtual tracks retain the same snapshot and composition provenance while removing its tag and publication metadata. Conversion is blocked when any downstream virtual snapshot resolved that release. Tagged releases cannot be deleted directly. Editors can separately delete the current draft, provided it is not the track's only snapshot, a preserved source of a tagged release, or a resolved component of a downstream virtual snapshot. Administrators can also correct a tagged snapshot's version from its card when the replacement remains valid between adjacent releases. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. Virtual-track schedules are configured in the Config tab as manual, recurring, or specific dates; recurring schedules use guided cadence/day/time controls that generate a five-field UTC cron expression, and specific dates use controlled future UTC date and time inputs. Scheduled drafts run only when the connected REST API has its global scheduler enabled. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent tagged release offers Convert to draft, and only the current draft offers Delete draft, a progress bar with a status message appears under the page header while a long operation runs, and deleting an entire track lives in the danger zone at the bottom of the Config tab.
Expand Down
34 changes: 32 additions & 2 deletions src/app/classes/release-tracks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
type ExportFormatType,
type ReleasePreviewFormatType,
} from './enums';
import type { SnapshotSchedule } from './release-track';
import type {
DraftRetention,
DraftSquashPreview,
SnapshotSchedule,
} from './release-track';
import type { SnapshotCreationCause } from './snapshot-creation-cause';
import type { SnapshotCreationActor } from './snapshot-creation-actor';

Expand All @@ -22,6 +26,11 @@ export interface CreateReleaseTrackPayload {
snapshot_schedule?: SnapshotSchedule;
}

export interface CreateVirtualSnapshotPayload {
description?: string;
draft_retention?: DraftRetention | null;
}

export interface StixBundlePayload {
type: 'bundle';
id?: string;
Expand All @@ -43,7 +52,11 @@ export type ReleasePayload = (
| { increment: 'major' | 'minor'; version?: never }
| { increment?: never; version: string }
| { increment?: undefined; version?: undefined }
) & { description?: string };
) & {
description?: string;
squash_drafts?: boolean;
squash_fingerprint?: string;
};

export interface RetagReleasePayload {
version: string;
Expand Down Expand Up @@ -77,6 +90,22 @@ export interface SnapshotHistoryOptions {
offset?: number;
}

/** Counts cover the selected tagged filter before pagination. */
export interface SnapshotHistoryCounts {
tagged: number;
drafts: number;
total: number;
}

export interface SnapshotHistoryResponse {
data: ReleaseTrackSnapshotHistoryItem[];
pagination: { total: number; limit: number; offset: number };
counts: SnapshotHistoryCounts;
/** Unfiltered live identities, even when neither snapshot is on this page. */
latest_snapshot_modified: string | null;
latest_tagged_snapshot_modified: string | null;
}

export interface SnapshotContentStatistics {
primary_count: number;
secondary_count: number;
Expand Down Expand Up @@ -151,6 +180,7 @@ export interface StandardReleasePreviewSummary extends ReleasePreviewSummaryBase

export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase {
type: ReleaseTrackType.Virtual;
draft_squash?: DraftSquashPreview;
previous_release: {
version: string;
modified: string;
Expand Down
43 changes: 38 additions & 5 deletions src/app/classes/release-tracks/release-track.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReleaseTrackType, SnapshotScheduleModeType } from './enums';
import { ReleaseTrackType } from './enums';

export interface ReleaseTrack {
track_id: string;
Expand All @@ -19,8 +19,41 @@ export interface ReleaseTrack {
snapshot_schedule?: SnapshotSchedule;
}

export interface SnapshotSchedule {
mode?: SnapshotScheduleModeType;
cron?: string | null;
dates?: (Date | string)[];
export type SnapshotSchedule =
| { mode: 'manual'; cron?: never; dates?: never; draft_retention?: never }
| {
mode: 'dates';
dates: (Date | string)[];
cron?: never;
draft_retention?: never;
}
| {
mode: 'cron';
cron: string;
dates?: never;
draft_retention?: DraftRetention;
};

export interface DraftRetention {
max_drafts: number | null;
}

export interface DraftCleanupResult {
operation_id: string;
status: 'pending' | 'completed' | 'failed';
kind: 'retention' | 'squash';
eligible_count: number;
deleted_count: number;
protected_count: number;
target_modified?: string;
release_committed?: boolean;
error?: string;
}

export interface DraftSquashPreview {
lower_bound: string | null;
upper_bound: string;
eligible_count: number;
protected_count: number;
fingerprint: string;
}
9 changes: 8 additions & 1 deletion src/app/classes/release-tracks/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ReleaseTrackType } from './enums';
import { SnapshotCreationCause } from './snapshot-creation-cause';
import { SnapshotCreationActor } from './snapshot-creation-actor';
import { VersionHistoryEntry } from './history';
import { SnapshotSchedule } from './release-track';
import { DraftCleanupResult, SnapshotSchedule } from './release-track';
import {
CandidateEntry,
MemberEntry,
Expand Down Expand Up @@ -49,6 +49,9 @@ export class ReleaseTrackSnapshot {
public composition?: LoadedComposition;
public composition_resolution?: CompositionResolution;
public snapshot_schedule?: SnapshotSchedule;
public draft_cleanup?: DraftCleanupResult;
public snapshot_count?: number;
public tagged_release_count?: number;

constructor(raw?: any) {
if (raw) this.deserialize(raw);
Expand Down Expand Up @@ -181,6 +184,10 @@ export class ReleaseTrackSnapshot {

if ('snapshot_schedule' in raw)
this.snapshot_schedule = raw.snapshot_schedule;
if ('draft_cleanup' in raw) this.draft_cleanup = raw.draft_cleanup;
if ('snapshot_count' in raw) this.snapshot_count = raw.snapshot_count;
if ('tagged_release_count' in raw)
this.tagged_release_count = raw.tagged_release_count;

if ('composition_resolution' in raw && raw.composition_resolution) {
const cr = raw.composition_resolution;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CreateDraftDialogComponent } from './create-draft-dialog.component';

describe('CreateDraftDialogComponent', () => {
const close = vi.fn();

beforeEach(async () => {
close.mockReset();
await TestBed.configureTestingModule({
imports: [CreateDraftDialogComponent, NoopAnimationsModule],
providers: [
{ provide: MatDialogRef, useValue: { close } },
{
provide: MAT_DIALOG_DATA,
useValue: {
trackName: 'Virtual track',
description: 'Existing notes',
canManageRetention: true,
},
},
],
}).compileComponents();
});

it('requires a fresh opt-in for every draft, and submits a validated limit only for that draft', () => {
const fixture = TestBed.createComponent(CreateDraftDialogComponent);
const component = fixture.componentInstance;
fixture.detectChanges();
component.save();
expect(close).toHaveBeenLastCalledWith({ description: 'Existing notes' });
close.mockClear();
component.retentionEnabled = true;
for (const limit of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
component.maxDrafts = limit;
component.save();
}
expect(close).not.toHaveBeenCalled();
component.maxDrafts = 2;
component.save();
expect(close).toHaveBeenCalledWith({
description: 'Existing notes',
draft_retention: { max_drafts: 2 },
});
fixture.destroy();
const reopened = TestBed.createComponent(CreateDraftDialogComponent);
reopened.componentInstance.save();
expect(close).toHaveBeenLastCalledWith({ description: 'Existing notes' });
});

it('does not render or submit a destructive option for non-administrators', () => {
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: { trackName: 'Virtual track', canManageRetention: false },
});
const fixture = TestBed.createComponent(CreateDraftDialogComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('mat-slide-toggle')).toBeNull();
fixture.componentInstance.retentionEnabled = true;
fixture.componentInstance.maxDrafts = 1;
fixture.componentInstance.save();
expect(close).toHaveBeenCalledWith({ description: '' });
});
});
Loading
Loading