From dc3203a4231ebf3c3f7f01a6609a7fb7d302941e Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:41:30 -0400 Subject: [PATCH 001/159] feat(release-tracks): add release track type definitions and data models --- .../classes/release-tracks/component-track.ts | 34 +++ src/app/classes/release-tracks/composition.ts | 30 ++ src/app/classes/release-tracks/config.ts | 39 +++ src/app/classes/release-tracks/enums.ts | 199 ++++++++++++ src/app/classes/release-tracks/history.ts | 15 + src/app/classes/release-tracks/index.ts | 8 + src/app/classes/release-tracks/registry.ts | 24 ++ src/app/classes/release-tracks/snapshot.ts | 283 ++++++++++++++++++ src/app/classes/release-tracks/tiers.ts | 31 ++ 9 files changed, 663 insertions(+) create mode 100644 src/app/classes/release-tracks/component-track.ts create mode 100644 src/app/classes/release-tracks/composition.ts create mode 100644 src/app/classes/release-tracks/config.ts create mode 100644 src/app/classes/release-tracks/enums.ts create mode 100644 src/app/classes/release-tracks/history.ts create mode 100644 src/app/classes/release-tracks/index.ts create mode 100644 src/app/classes/release-tracks/registry.ts create mode 100644 src/app/classes/release-tracks/snapshot.ts create mode 100644 src/app/classes/release-tracks/tiers.ts diff --git a/src/app/classes/release-tracks/component-track.ts b/src/app/classes/release-tracks/component-track.ts new file mode 100644 index 000000000..28d798b18 --- /dev/null +++ b/src/app/classes/release-tracks/component-track.ts @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------------- +// Component Tracks +// +// Release track (standard or virtual) referenced by a virtual release track +// ----------------------------------------------------------------------------- + +import { ResolutionStrategyType } from './enums'; + +export interface ComponentTrackFilters { + object_types?: string[]; + domains?: string[]; +} + +export interface ComponentTrack { + track_id: string; + resolution_strategy: ResolutionStrategyType; + priority: number; + version?: string | null; + snapshot?: Date; + filters?: ComponentTrackFilters; +} + +export interface ComponentSnapshotResolution { + track_id: string; + track_name: string; + track_type: string; + resolved_snapshot_id: Date; + resolved_version?: string | null; + strategy_used: string; + filters_applied?: ComponentTrackFilters; + total_objects_in_source: number; + objects_after_filter: number; + objects_contributed: number; +} diff --git a/src/app/classes/release-tracks/composition.ts b/src/app/classes/release-tracks/composition.ts new file mode 100644 index 000000000..2e953b36f --- /dev/null +++ b/src/app/classes/release-tracks/composition.ts @@ -0,0 +1,30 @@ +// ----------------------------------------------------------------------------- +// Composition +// +// Rules and configuration that defines how a virtual release track +// aggregates content from component tracks +// ----------------------------------------------------------------------------- + +import { DeduplicationStrategyType } from './enums'; +import { ComponentTrack, ComponentSnapshotResolution } from './component-track'; + +export interface Composition { + component_tracks?: ComponentTrack[]; + deduplication?: { + strategy?: DeduplicationStrategyType; + }; +} + +export interface DeduplicationReport { + total_objects_before?: number; + total_objects_after?: number; + duplicates_found?: number; + conflicts_resolved?: any[]; +} + +export interface CompositionResolution { + resolved_at?: Date; + component_snapshots?: ComponentSnapshotResolution[]; + deduplication?: DeduplicationReport; + summary?: any; +} diff --git a/src/app/classes/release-tracks/config.ts b/src/app/classes/release-tracks/config.ts new file mode 100644 index 000000000..2e84353e1 --- /dev/null +++ b/src/app/classes/release-tracks/config.ts @@ -0,0 +1,39 @@ +// ----------------------------------------------------------------------------- +// Release Track Configuration +// ----------------------------------------------------------------------------- + +import { + ConflictPolicyType, + WorkflowStatusType, + MemberSyncBehaviorType, + MemberSyncPolicyType, + MemberSyncStrategyType, +} from './enums'; + +export interface PromotionConflicts { + candidates_to_staged?: ConflictPolicyType; + staged_to_members?: ConflictPolicyType; +} + +export interface IncludeSecondaryObjects { + enabled?: boolean; + status_threshold?: WorkflowStatusType; +} + +export interface MemberSyncSupplant { + behavior?: MemberSyncBehaviorType; + status_policy?: MemberSyncPolicyType; +} + +export interface MemberSync { + strategy?: MemberSyncStrategyType; + supplant?: MemberSyncSupplant; +} + +export interface ReleaseTrackConfig { + candidacy_threshold?: WorkflowStatusType; + auto_promote?: boolean; + include_secondary_objects?: IncludeSecondaryObjects; + promotion_conflicts?: PromotionConflicts; + member_sync?: MemberSync; +} diff --git a/src/app/classes/release-tracks/enums.ts b/src/app/classes/release-tracks/enums.ts new file mode 100644 index 000000000..88a7e1ba1 --- /dev/null +++ b/src/app/classes/release-tracks/enums.ts @@ -0,0 +1,199 @@ +// ----------------------------------------------------------------------------- +// Release Track Type +// ----------------------------------------------------------------------------- + +export enum ReleaseTrack { + Standard = 'standard', + Virtual = 'virtual', +} + +export type ReleaseTrackType = ReleaseTrack.Standard | ReleaseTrack.Virtual; + +export const RELEASE_TRACK_TYPE_OPTIONS: ReleaseTrackType[] = Object.values( + ReleaseTrack +) as ReleaseTrackType[]; + +// ----------------------------------------------------------------------------- +// Conflict Resolution +// ----------------------------------------------------------------------------- + +export enum ConflictPolicy { + AlwaysOverwrite = 'always_overwrite', // replace with incoming entry + AlwaysReject = 'always_reject', // always reject incoming + PreferLatest = 'prefer_latest', // keep whichever has newer object_modified + Abort = 'abort', // throw error on any conflict +} + +export type ConflictPolicyType = + | ConflictPolicy.AlwaysOverwrite + | ConflictPolicy.AlwaysReject + | ConflictPolicy.PreferLatest + | ConflictPolicy.Abort; + +export const CONFLICT_POLICY_OPTIONS: ConflictPolicyType[] = Object.values( + ConflictPolicy +) as ConflictPolicyType[]; + +// ----------------------------------------------------------------------------- +// Deduplication Strategy +// ----------------------------------------------------------------------------- + +export enum DeduplicationStrategy { + PrioritizeLatestObject = 'prioritize_latest_object', // keep version with newest object_modified + PrioritizeLatestSnapshot = 'prioritize_latest_snapshot', // keep version from most recently modified snapshot + PrioritizeHigherPriority = 'prioritize_higher_priority', // keep version from the higher-priority conmponent (lower number) + Quarantine = 'quarantine', // send all conflicting versions to quarantine for manual review +} + +export type DeduplicationStrategyType = + | DeduplicationStrategy.PrioritizeLatestObject + | DeduplicationStrategy.PrioritizeLatestSnapshot + | DeduplicationStrategy.PrioritizeHigherPriority + | DeduplicationStrategy.Quarantine; + +export const DEDUPLICATION_STRATEGY_OPTIONS: DeduplicationStrategyType[] = + Object.values(DeduplicationStrategy) as DeduplicationStrategyType[]; + +// ----------------------------------------------------------------------------- +// Export Format +// ----------------------------------------------------------------------------- + +export enum ExportFormat { + Snapshot = 'snapshot', + Bundle = 'bundle', + Workbench = 'workbench', + FileSystemStore = 'filesystemstore', +} + +export type ExportFormatType = + | ExportFormat.Snapshot + | ExportFormat.Bundle + | ExportFormat.Workbench + | ExportFormat.FileSystemStore; + +export const EXPORT_FORMAT_OPTIONS: ExportFormatType[] = Object.values( + ExportFormat +) as ExportFormatType[]; + +// ----------------------------------------------------------------------------- +// Release Track Snapshot Tiers +// ----------------------------------------------------------------------------- + +export enum SnapshotTier { + Member = 'released', + Staged = 'staged', + Candidate = 'candidate', + All = 'all', +} + +export type SnapshotTierType = + | SnapshotTier.Member + | SnapshotTier.Staged + | SnapshotTier.Candidate + | SnapshotTier.All; + +export const SNAPSHOT_TIER_OPTIONS: SnapshotTierType[] = Object.values( + SnapshotTier +) as SnapshotTierType[]; + +// ----------------------------------------------------------------------------- +// Workflow Statuses +// ----------------------------------------------------------------------------- + +export enum WorkflowStatus { + WorkInProgress = 'work-in-progress', + AwaitingReview = 'awaiting-review', + Reviewed = 'reviewed', +} + +export type WorkflowStatusType = + | WorkflowStatus.WorkInProgress + | WorkflowStatus.AwaitingReview + | WorkflowStatus.Reviewed; + +// ----------------------------------------------------------------------------- +// Candidacy Thresholds +// ----------------------------------------------------------------------------- + +export const CANDIDACY_THRESHOLD_OPTIONS: WorkflowStatusType[] = Object.values( + WorkflowStatus +) as WorkflowStatusType[]; + +// ----------------------------------------------------------------------------- +// Resolution Strategy +// ----------------------------------------------------------------------------- + +export enum ResolutionStrategy { + LatestTagged = 'latest_tagged', + SpecificVersion = 'specific_version', + SpecificSnapshot = 'specific_snapshot', +} + +export type ResolutionStrategyType = + | ResolutionStrategy.LatestTagged + | ResolutionStrategy.SpecificVersion + | ResolutionStrategy.SpecificSnapshot; + +export const RESOLUTION_STRATEGY_OPTIONS: ResolutionStrategyType[] = + Object.values(ResolutionStrategy) as ResolutionStrategyType[]; + +// ----------------------------------------------------------------------------- +// Snapshot Schedule Modes +// ----------------------------------------------------------------------------- + +export enum SnapshotScheduleMode { + Manual = 'manual', + Cron = 'cron', + Dates = 'dates', +} + +export type SnapshotScheduleModeType = + | SnapshotScheduleMode.Manual + | SnapshotScheduleMode.Cron + | SnapshotScheduleMode.Dates; + +export const SNAPSHOT_MODE_OPTIONS: SnapshotScheduleModeType[] = Object.values( + SnapshotScheduleMode +) as SnapshotScheduleModeType[]; + +// ----------------------------------------------------------------------------- +// Member Sync +// ----------------------------------------------------------------------------- + +export enum MemberSyncStrategy { + TrackLatest = 'track_latest', + Manual = 'manual', +} + +export type MemberSyncStrategyType = + | MemberSyncStrategy.TrackLatest + | MemberSyncStrategy.Manual; + +export const MEMBER_SYNC_STRATEGY_OPTIONS: MemberSyncStrategyType[] = + Object.values(MemberSyncStrategy) as MemberSyncStrategyType[]; + +export enum MemberSyncBehavior { + Replace = 'replace', + Queue = 'queue', + Ignore = 'ignore', +} + +export type MemberSyncBehaviorType = + | MemberSyncBehavior.Replace + | MemberSyncBehavior.Queue + | MemberSyncBehavior.Ignore; + +export const MEMBER_SYNC_BEHAVIOR_OPTIONS: MemberSyncBehaviorType[] = + Object.values(MemberSyncBehavior) as MemberSyncBehaviorType[]; + +export enum MemberSyncPolicy { + Reset = 'reset', + Preserve = 'preserve', +} + +export type MemberSyncPolicyType = + | MemberSyncPolicy.Reset + | MemberSyncPolicy.Preserve; + +export const MEMBER_SYNC_STATUS_POLICY_OPTIONS: MemberSyncPolicyType[] = + Object.values(MemberSyncPolicy) as MemberSyncPolicyType[]; diff --git a/src/app/classes/release-tracks/history.ts b/src/app/classes/release-tracks/history.ts new file mode 100644 index 000000000..870e2f9a4 --- /dev/null +++ b/src/app/classes/release-tracks/history.ts @@ -0,0 +1,15 @@ +export interface VersionHistorySummary { + members_count?: number; + promoted_count?: number; + staged_count?: number; + candidate_count?: number; +} + +export interface VersionHistoryEntry { + version: string; + tagged_at: Date; + tagged_by: string; + snapshot_id: Date; + summary?: VersionHistorySummary; + component_versions?: any; // virtual tracks only +} diff --git a/src/app/classes/release-tracks/index.ts b/src/app/classes/release-tracks/index.ts new file mode 100644 index 000000000..9a2b0e5cf --- /dev/null +++ b/src/app/classes/release-tracks/index.ts @@ -0,0 +1,8 @@ +export * from './component-track'; +export * from './composition'; +export * from './config'; +export * from './enums'; +export * from './history'; +export * from './registry'; +export * from './snapshot'; +export * from './tiers'; diff --git a/src/app/classes/release-tracks/registry.ts b/src/app/classes/release-tracks/registry.ts new file mode 100644 index 000000000..52308527a --- /dev/null +++ b/src/app/classes/release-tracks/registry.ts @@ -0,0 +1,24 @@ +import { ReleaseTrackType, SnapshotScheduleModeType } from './enums'; + +export interface ReleaseTrackRegistry { + track_id: string; + type: ReleaseTrackType; + name: string; + description?: string; + created_at: Date; + updated_at: Date; + + latest_snapshot_modified?: Date | null; + latest_tagged_version?: string | null; + snapshot_count?: number; + tagged_release_count?: number; + + // virtual tracks only + snapshot_schedule?: SnapshotSchedule; +} + +export interface SnapshotSchedule { + mode?: SnapshotScheduleModeType; + cron?: string | null; + dates?: Date[] | undefined; +} diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts new file mode 100644 index 000000000..e2e5850e4 --- /dev/null +++ b/src/app/classes/release-tracks/snapshot.ts @@ -0,0 +1,283 @@ +import { Composition, CompositionResolution } from './composition'; +import { ReleaseTrackConfig } from './config'; +import { ReleaseTrack, ReleaseTrackType } from './enums'; +import { VersionHistoryEntry } from './history'; +import { + CandidateEntry, + MemberEntry, + QuarantineEntry, + StagedEntry, +} from './tiers'; + +export class ReleaseTrackSnapshot { + public id = ''; + public type: ReleaseTrackType; + public modified: Date = new Date(); + public version?: string | null; + public name = ''; + public description?: string; + public created: Date = new Date(); + public created_by_ref?: string; + public object_marking_refs?: string[]; + + public config: ReleaseTrackConfig = {} as ReleaseTrackConfig; + public version_history: VersionHistoryEntry[] = []; + + // standard track tiers + public members: MemberEntry[] = []; + public staged?: StagedEntry[]; + public candidates?: CandidateEntry[]; + + // virtual track tiers + public quarantine?: QuarantineEntry[]; + + // virtual track composition + public composition?: Composition; + public composition_resolution?: CompositionResolution; + + constructor(raw?: any) { + if (raw) this.deserialize(raw); + } + + public get isVirtual(): boolean { + return this.type === ReleaseTrack.Virtual; + } + + public get isStandard(): boolean { + return this.type === ReleaseTrack.Standard; + } + + public get memberCount(): number { + return this.members ? this.members.length : 0; + } + + public get stagedCount(): number { + return this.staged ? this.staged.length : 0; + } + + public get candidateCount(): number { + return this.candidates ? this.candidates.length : 0; + } + + public get quarantineCount(): number { + return this.quarantine ? this.quarantine.length : 0; + } + + public get isTagged(): boolean { + return !!this.version; + } + + public get latestTaggedVersion(): string | null { + if (!this.version_history || this.version_history.length === 0) return null; + const sorted = [...this.version_history].sort((a, b) => { + const ta = a.tagged_at ? new Date(a.tagged_at).getTime() : 0; + const tb = b.tagged_at ? new Date(b.tagged_at).getTime() : 0; + return tb - ta; + }); + return sorted[0].version || null; + } + + // Populate release track snapshot fields from a raw object + public deserialize(raw: any) { + if (!raw) return; + + if ('id' in raw) this.id = raw.id; + if ('type' in raw) this.type = raw.type; + if ('modified' in raw) this.modified = new Date(raw.modified); + if ('version' in raw) this.version = raw.version; + if ('name' in raw) this.name = raw.name; + if ('description' in raw) this.description = raw.description; + if ('created' in raw) this.created = new Date(raw.created); + if ('created_by_ref' in raw) this.created_by_ref = raw.created_by_ref; + if ('object_marking_refs' in raw && Array.isArray(raw.object_marking_refs)) + this.object_marking_refs = raw.object_marking_refs.slice(); + + if ('config' in raw) this.config = raw.config; + + if ('version_history' in raw && Array.isArray(raw.version_history)) { + this.version_history = raw.version_history.map((v: any) => { + const entry: VersionHistoryEntry = { ...v } as any; + if (v.tagged_at) entry.tagged_at = new Date(v.tagged_at); + if (v.snapshot_id) entry.snapshot_id = new Date(v.snapshot_id); + return entry; + }); + } + + if ('members' in raw && Array.isArray(raw.members)) { + this.members = raw.members.map((m: any) => ({ + object_ref: m.object_ref, + object_modified: new Date(m.object_modified), + })); + } + + if ('staged' in raw && Array.isArray(raw.staged)) { + this.staged = raw.staged.map((s: any) => ({ + object_ref: s.object_ref, + object_modified: new Date(s.object_modified), + object_status: s.object_status, + object_staged_at: s.object_staged_at + ? new Date(s.object_staged_at) + : undefined, + object_staged_by: s.object_staged_by, + })); + } + + if ('candidates' in raw && Array.isArray(raw.candidates)) { + this.candidates = raw.candidates.map((c: any) => ({ + object_ref: c.object_ref, + object_modified: new Date(c.object_modified), + object_status: c.object_status, + object_added_at: c.object_added_at + ? new Date(c.object_added_at) + : undefined, + object_added_by: c.object_added_by, + })); + } + + if ('quarantine' in raw && Array.isArray(raw.quarantine)) { + this.quarantine = raw.quarantine.map((q: any) => ({ + object_ref: q.object_ref, + object_modified: new Date(q.object_modified), + source_track_id: q.source_track_id, + source_track_name: q.source_track_name, + source_snapshot_version: q.source_snapshot_version, + conflict_reason: q.conflict_reason, + })); + } + + if ('composition' in raw) this.composition = raw.composition; + + if ('composition_resolution' in raw && raw.composition_resolution) { + const cr = raw.composition_resolution; + const resolved: CompositionResolution = { ...cr } as any; + if (cr.resolved_at) resolved.resolved_at = new Date(cr.resolved_at); + if (cr.component_snapshots && Array.isArray(cr.component_snapshots)) { + resolved.component_snapshots = cr.component_snapshots.map( + (cs: any) => ({ + ...cs, + resolved_snapshot_id: cs.resolved_snapshot_id + ? new Date(cs.resolved_snapshot_id) + : undefined, + }) + ); + } + this.composition_resolution = resolved; + } + } + + // Generate object representation of the release track snapshot + public serialize(): any { + return { + id: this.id, + type: this.type, + modified: this.modified ? this.modified.toISOString() : undefined, + version: this.version, + name: this.name, + description: this.description, + created: this.created ? this.created.toISOString() : undefined, + created_by_ref: this.created_by_ref, + object_marking_refs: this.object_marking_refs, + config: this.config, + version_history: this.version_history?.map(v => ({ + ...v, + tagged_at: v.tagged_at + ? (v.tagged_at as any).toISOString() + : v.tagged_at, + snapshot_id: v.snapshot_id + ? (v.snapshot_id as any).toISOString() + : v.snapshot_id, + })), + members: this.members?.map(m => ({ + ...m, + object_modified: m.object_modified + ? (m.object_modified as any).toISOString() + : m.object_modified, + })), + staged: this.staged?.map(s => ({ + ...s, + object_modified: s.object_modified + ? (s.object_modified as any).toISOString() + : s.object_modified, + object_staged_at: s.object_staged_at + ? (s.object_staged_at as any).toISOString() + : s.object_staged_at, + })), + candidates: this.candidates?.map(c => ({ + ...c, + object_modified: c.object_modified + ? (c.object_modified as any).toISOString() + : c.object_modified, + object_added_at: c.object_added_at + ? (c.object_added_at as any).toISOString() + : c.object_added_at, + })), + quarantine: this.quarantine?.map(q => ({ + ...q, + object_modified: q.object_modified + ? (q.object_modified as any).toISOString() + : q.object_modified, + })), + composition: this.composition, + composition_resolution: this.composition_resolution, + }; + } + + // Check if an object with the given STIX ID exists in members + public hasMember(objectRef: string): boolean { + return !!this.members.find(m => m.object_ref === objectRef); + } + + // Check if an object with the given STIX ID exists in candidates + public hasCandidate(objectRef: string): boolean { + return !!this.candidates?.find(c => c.object_ref === objectRef); + } + + // Check if an object with the given STIX ID exists in staged + public hasStaged(objectRef: string): boolean { + return !!this.staged?.find(c => c.object_ref === objectRef); + } + + // Get the member entry for the given STIX ID + public findMember(objectRef: string): MemberEntry | undefined { + return this.members.find(m => m.object_ref === objectRef); + } + + // Get the candidate entry for the given STIX ID + public findCandidate(objectRef: string): CandidateEntry | undefined { + return this.candidates?.find(c => c.object_ref === objectRef); + } + + // Get the staged entry for the given STIX ID + public findStaged(objectRef: string): StagedEntry | undefined { + return this.staged?.find(c => c.object_ref === objectRef); + } + + // Add candidate entry + public addCandidate(entry: CandidateEntry): void { + if (!this.candidates) this.candidates = []; + if (!this.hasCandidate(entry.object_ref)) this.candidates.push(entry); + } + + // Remove candidate entry + public removeCandidate(objectRef: string): void { + if (!this.candidates) return; + this.candidates = this.candidates.filter(c => c.object_ref !== objectRef); + } + + // Get a summary of the snapshot object + public toSummary(): any { + return { + id: this.id, + name: this.name, + type: this.type, + version: this.version, + modified: this.modified, + counts: { + members: this.memberCount, + staged: this.stagedCount, + candidates: this.candidateCount, + quarantine: this.quarantineCount, + }, + }; + } +} diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts new file mode 100644 index 000000000..69a9aa1fe --- /dev/null +++ b/src/app/classes/release-tracks/tiers.ts @@ -0,0 +1,31 @@ +import { WorkflowStatusType } from './enums'; + +export interface MemberEntry { + object_ref: string; + object_modified: Date; +} + +export interface StagedEntry { + object_ref: string; + object_modified: Date; + object_status: WorkflowStatusType; + object_staged_at: Date; + object_staged_by: string; +} + +export interface CandidateEntry { + object_ref: string; + object_modified: Date; + object_status: WorkflowStatusType; + object_added_at: Date; + object_added_by: string; +} + +export interface QuarantineEntry { + object_ref: string; + object_modified: Date; + source_track_id: string; + source_track_name: string; + source_snapshot_version?: string | null; + conflict_reason: string; +} From bfeeabbb01b229a34705bf166de9b7ac4752681b Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:01:03 -0400 Subject: [PATCH 002/159] feat(release-tracks): implement release track API client methods --- .../release-tracks.integration.spec.ts | 144 ++++ .../rest-api/release-tracks.service.ts | 728 ++++++++++++++++++ 2 files changed, 872 insertions(+) create mode 100644 src/app/services/connectors/rest-api/release-tracks.integration.spec.ts create mode 100644 src/app/services/connectors/rest-api/release-tracks.service.ts diff --git a/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts new file mode 100644 index 000000000..ce5a3518b --- /dev/null +++ b/src/app/services/connectors/rest-api/release-tracks.integration.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { environment } from 'src/environments/environment'; + +// Integration tests that exercise the real REST API. These tests will +// silently no-op (skip) if the configured API URL is unreachable. + +const apiUrl = environment.integrations.rest_api.url.replace(/\/+$/, ''); +let serverAvailable = false; +const commonHeaders: Record = { + 'Content-Type': 'application/json', +}; +let discoveredTrackId: string | null = null; + +async function probe(url: string, timeoutMs = 3000): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { method: 'GET', signal: controller.signal }); + return res; + } finally { + clearTimeout(id); + } +} + +beforeAll(async () => { + try { + const res = await probe(`${apiUrl}/release-tracks`); + if (res && res.ok) { + serverAvailable = true; + // try to read and pick an id if available + try { + const body = await res.json().catch(() => null); + // server may return paginated object or array + if (Array.isArray(body) && body.length > 0 && body[0].id) { + discoveredTrackId = body[0].id; + } else if ( + body && + body.items && + Array.isArray(body.items) && + body.items.length > 0 && + body.items[0].id + ) { + discoveredTrackId = body.items[0].id; + } + } catch (e) { + console.error(e); + } + } else { + serverAvailable = false; + // leave discoveredTrackId null + } + } catch (e) { + // network error or timeout + serverAvailable = false; + console.error(e); + } +}); + +describe('Release Tracks API integration (real server)', () => { + it('GET /release-tracks (list) should return 200 when server available', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping /release-tracks test because server is unreachable' + ); + return; + } + const res = await fetch(`${apiUrl}/release-tracks`, { + headers: commonHeaders, + }); + expect(res.ok).toBe(true); + const body = await res.json().catch(() => null); + expect(body).toBeTruthy(); + }); + + it('GET /release-tracks/ephemeral/:domain should return a bundle when available', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping ephemeral bundle test because server is unreachable' + ); + return; + } + const domain = 'enterprise'; + const res = await fetch( + `${apiUrl}/release-tracks/ephemeral/${domain}?format=bundle`, + { headers: commonHeaders } + ); + // The API may return different formats; assert no network error and a JSON body when possible + expect(res.ok).toBe(true); + // attempt to parse JSON but do not fail if parsing isn't possible + try { + const json = await res.json(); + expect(json).toBeDefined(); + } catch (e) { + console.error(e); + } + }); + + it('GET /release-tracks/:id -> getLatestSnapshot should return snapshot when tracks exist', async () => { + if (!serverAvailable) { + console.warn( + 'Skipping getLatestSnapshot test because server is unreachable' + ); + return; + } + if (!discoveredTrackId) { + console.warn( + 'Skipping getLatestSnapshot test because no tracks were found in /release-tracks' + ); + return; + } + const res = await fetch( + `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}`, + { headers: commonHeaders } + ); + expect(res.ok).toBe(true); + const json = await res.json().catch(() => null); + expect(json).toBeTruthy(); + // If the API returns a shape compatible with ReleaseTrackSnapshot, it usually has id/name/modified + if (json) { + expect(json).toHaveProperty('id'); + } + }); + + it('GET /release-tracks/:id/staged should return 200 (if track exists)', async () => { + if (!serverAvailable) { + console.warn('Skipping listStaged test because server is unreachable'); + return; + } + if (!discoveredTrackId) { + console.warn( + 'Skipping listStaged test because no tracks were found in /release-tracks' + ); + return; + } + const res = await fetch( + `${apiUrl}/release-tracks/${encodeURIComponent(discoveredTrackId)}/staged`, + { headers: commonHeaders } + ); + expect(res.ok).toBe(true); + // body may be array or paginated object + const body = await res.json().catch(() => null); + expect(body).toBeDefined(); + }); +}); diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts new file mode 100644 index 000000000..96c7dcf3b --- /dev/null +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -0,0 +1,728 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Observable } from 'rxjs'; +import { catchError, share, tap, map } from 'rxjs/operators'; +import { environment } from '../../../../environments/environment'; +import { logger } from '../../../utils/logger'; +import { ApiConnector } from '../api-connector'; +import { + ReleaseTrackSnapshot, + ReleaseTrackType, + WorkflowStatusType, +} from 'src/app/classes/release-tracks'; + +// ----------------------------------------------------------------------------- +// Request Payload Definitions for Release Tracks API Requests +// ----------------------------------------------------------------------------- +export type StixObjectRef = string | { id: string; modified?: string }; + +export interface CreateReleaseTrackPayload { + name: string; + description?: string; + external_references?: any[]; + object_marking_refs?: any[]; + type?: ReleaseTrackType; + composition?: any; + snapshot_schedule?: any; +} + +export interface StixBundlePayload { + type: 'bundle'; + id?: string; + objects: any[]; +} + +export interface UpdateMetadataPayload { + name?: string; + description?: string; + external_references?: any[]; + object_marking_refs?: any[]; +} + +export interface UpdateContentsPayload { + x_mitre_contents: string[]; +} + +export interface BumpPayload { + type?: 'major' | 'minor'; + version?: string; + dry_run?: boolean; +} + +export interface ClonePayload { + name?: string; +} + +export interface ReviewPayload { + from: string; + to: string; + object_refs?: StixObjectRef[]; +} + +export interface UpdateVersionPayload { + old_modified?: string; + new_modified?: string; +} + +export interface ConfigPayload { + candidacy_threshold?: WorkflowStatusType; + auto_promote?: boolean; +} + +// ----------------------------------------------------------------------------- +// Release Tracks API Connector Service +// ----------------------------------------------------------------------------- + +@Injectable({ + providedIn: 'root', +}) +export class ReleaseTracksConnectorService extends ApiConnector { + private get apiUrl(): string { + return environment.integrations.rest_api.url; + } + + constructor( + private http: HttpClient, + snackbar: MatSnackBar + ) { + super(snackbar); + } + + // ----------------------------------------------------------------------------- + // Ephemeral + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/ephemeral/:domain + * Retrieve an ephemeral STIX bundle for the given domain. + * @param domain Path parameter selecting ATT&CK domain ('enterprise'|'ics'|'mobile') + * @param format Optional query parameter controlling output format ('bundle'|'filesystemstore'|'workbench') + * @returns Observable Server response + */ + public getEphemeralBundle( + domain: string, + format = 'bundle' + ): Observable { + let params = new HttpParams(); + if (format) params = params.set('format', format); + const url = `${this.apiUrl}/release-tracks/ephemeral/${domain}`; + return this.http.get(url, { params }).pipe( + tap(result => logger.log(`retrieved ephemeral ${domain} bundle`, result)), + catchError(this.handleError_continue(null)), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Track management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks + * List release tracks. + * @param options Query options: type, releases, limit, offset, search + * @returns Observable paginated list + */ + public listReleaseTracks(options?: { + type?: string; + releases?: 'only'; + limit?: number; + offset?: number; + search?: string; + }): Observable { + let params = new HttpParams(); + if (options?.type) params = params.set('type', options.type); + if (options?.releases) params = params.set('releases', options.releases); + if (options?.limit) params = params.set('limit', options.limit.toString()); + if (options?.offset) + params = params.set('offset', options.offset.toString()); + if (options?.search) params = params.set('search', options.search); + const url = `${this.apiUrl}/release-tracks`; + return this.http.get(url, { params }).pipe( + tap(() => logger.log('retrieved release tracks list')), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/new + * Create a new release track. + * @param body Request payload + * @returns Observable created track + */ + public createReleaseTrack(body: CreateReleaseTrackPayload): Observable { + const url = `${this.apiUrl}/release-tracks/new`; + return this.http.post(url, body).pipe( + tap(result => logger.log('created release track', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/new-from-bundle + * Bootstrap a release track from a STIX bundle. + * @param body STIX bundle + * @returns Observable creation result + */ + public createReleaseTrackFromBundle( + body: StixBundlePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/new-from-bundle`; + return this.http.post(url, body).pipe( + tap(result => logger.log('created release track from bundle', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/import + * Import a release track (server may return 501 Not Implemented). + * @param body Import payload (TBD) + * @returns Observable server response + */ + public importReleaseTrack(body: any): Observable { + const url = `${this.apiUrl}/release-tracks/import`; + return this.http.post(url, body).pipe( + tap(result => logger.log('imported release track', result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id + * Get latest snapshot for a track. + * @param id Release track id + * @param options Query options forwarded to endpoint + * @returns Observable snapshot or list + */ + public getLatestSnapshot( + id: string, + options?: { format?: string; [key: string]: any } + ): Observable { + let params = new HttpParams(); + if (options) { + Object.keys(options).forEach(k => { + const v = (options as any)[k]; + if (v !== undefined && v !== null) params = params.set(k, String(v)); + }); + } + const url = `${this.apiUrl}/release-tracks/${id}`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`retrieved latest snapshot for track ${id}`, result) + ), + map(result => (result ? new ReleaseTrackSnapshot(result) : null)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/:id/meta + * Update metadata for latest snapshot (creates new snapshot). + * @param id Release track id + * @param body Metadata payload + * @param userAccountId Optional user account id appended to payload + * @returns Observable + */ + public updateMetadataByLatest( + id: string, + body: UpdateMetadataPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/meta`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`updated metadata for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/contents + * Update member contents for latest snapshot. + * @param id Release track id + * @param body Contents payload + * @param userAccountId Optional user + * @returns Observable + */ + public updateContentsByLatest( + id: string, + body: UpdateContentsPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/contents`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`updated contents for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/bump/preview + * Preview the next release for a track. + * @param id Release track id + * @param format Optional output format + * @returns Observable preview payload + */ + public previewBump(id: string, format = 'workbench'): Observable { + let params = new HttpParams(); + if (format) params = params.set('format', format); + const url = `${this.apiUrl}/release-tracks/${id}/bump/preview`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`generated bump preview for track ${id}`, result) + ), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/:id/bump + * Bump/tag the latest snapshot. + * @param id Release track id + * @param body Bump options (type, version, dry_run) + * @returns Observable + */ + public bumpByLatest(id: string, body: BumpPayload): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/bump`; + return this.http.post(url, body).pipe( + tap(result => logger.log(`bumped version for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/clone + * Clone a new release track from the latest snapshot. + * @param id Source release track id + * @param body Clone options + * @returns Observable + */ + public cloneByLatest(id: string, body?: ClonePayload): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/clone`; + return this.http.post(url, body || {}).pipe( + tap(result => logger.log(`cloned track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id + * Delete a release track. + * @param id Release track id + * @returns Observable + */ + public deleteReleaseTrack(id: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}`; + return this.http.delete(url).pipe( + tap(() => logger.log(`deleted track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Snapshot Operations + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/snapshots/:modified + * Get a specific snapshot by modified timestamp. + * @param id Release track id + * @param modified ISO timestamp identifying the snapshot + * @param options Optional query params + * @returns Observable + */ + public retrieveSnapshotByModified( + id: string, + modified: string, + options?: Record + ): Observable { + let params = new HttpParams(); + if (options) { + Object.keys(options).forEach(k => { + const v = options[k]; + if (v !== undefined && v !== null) params = params.set(k, String(v)); + }); + } + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log(`retrieved snapshot ${modified} for track ${id}`, result) + ), + map(result => (result ? new ReleaseTrackSnapshot(result) : null)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/meta + * Update metadata for a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Metadata payload + * @param userAccountId Optional user + * @returns Observable + */ + public updateMetadataByModified( + id: string, + modified: string, + body: UpdateMetadataPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}/meta`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => + logger.log(`updated metadata for snapshot ${modified}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/contents + * Update contents for a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Contents payload + * @param userAccountId Optional user + * @returns Observable + */ + public updateContentsByModified( + id: string, + modified: string, + body: UpdateContentsPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}/contents`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => + logger.log(`updated contents for snapshot ${modified}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/bump + * Tag/bump a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Bump options + * @returns Observable + */ + public bumpByModified( + id: string, + modified: string, + body: BumpPayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}/bump`; + return this.http.post(url, body).pipe( + tap(result => + logger.log(`bumped version for snapshot ${modified}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/snapshots/:modified/clone + * Clone a new release track from a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @param body Clone options + * @returns Observable + */ + public cloneByModified( + id: string, + modified: string, + body?: ClonePayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}/clone`; + return this.http.post(url, body || {}).pipe( + tap(result => logger.log(`cloned from snapshot ${modified}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id/snapshots/:modified + * Delete a specific snapshot. + * @param id Release track id + * @param modified Snapshot modified timestamp + * @returns Observable + */ + public deleteSnapshotByModified( + id: string, + modified: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${modified}`; + return this.http.delete(url).pipe( + tap(() => logger.log(`deleted snapshot ${modified} from track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Candidate Management + // ----------------------------------------------------------------------------- + + /** + * POST /api/release-tracks/:id/candidates + * Add candidate references to latest draft. + * @param id Release track id + * @param object_refs Array of STIX ids or object refs + * @param userAccountId Optional user + * @returns Observable + */ + public addCandidates( + id: string, + object_refs: StixObjectRef[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`added candidates to track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * GET /api/release-tracks/:id/candidates + * List candidates from latest snapshot. + * @param id Release track id + * @param options Query options: status|limit|offset + * @returns Observable + */ + public listCandidates( + id: string, + options?: { status?: string; limit?: number; offset?: number } + ): Observable { + let params = new HttpParams(); + if (options?.status) params = params.set('status', options.status); + if (options?.limit) params = params.set('limit', options.limit.toString()); + if (options?.offset) + params = params.set('offset', options.offset.toString()); + const url = `${this.apiUrl}/release-tracks/${id}/candidates`; + return this.http.get(url, { params }).pipe( + tap(() => logger.log(`listed candidates for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * DELETE /api/release-tracks/:id/candidates/:objectRef + * Remove a candidate. + * @param id Release track id + * @param objectRef STIX object id + * @returns Observable + */ + public removeCandidate(id: string, objectRef: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/${objectRef}`; + return this.http.delete(url).pipe( + tap(() => logger.log(`removed candidate ${objectRef} from track ${id}`)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/review + * Bulk transition candidate statuses. + * @param id Release track id + * @param body Review payload + * @param userAccountId Optional user + * @returns Observable + */ + public reviewCandidates( + id: string, + body: ReviewPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/review`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`reviewed candidates for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/promote + * Promote candidates to staged. + * @param id Release track id + * @param object_refs Array of STIX ids + * @param userAccountId Optional user + * @returns Observable + */ + public promoteCandidates( + id: string, + object_refs: string[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/promote`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => logger.log(`promoted candidates for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + /** + * POST /api/release-tracks/:id/candidates/:objectRef/update-version + * Update candidate version pin. + * @param id Release track id + * @param objectRef STIX object id + * @param body Version update payload + * @returns Observable + */ + public updateCandidateVersion( + id: string, + objectRef: string, + body: UpdateVersionPayload + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/candidates/${objectRef}/update-version`; + return this.http.post(url, body).pipe( + tap(result => + logger.log(`updated version for candidate ${objectRef}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Staged Object Management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/staged + * List staged objects. + * @param id Release track id + * @returns Observable + */ + public listStaged(id: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/staged`; + return this.http.get(url).pipe( + tap(() => logger.log(`listed staged objects for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * POST /api/release-tracks/:id/staged/demote + * Demote staged objects to candidates. + * @param id Release track id + * @param object_refs Array of STIX ids + * @param userAccountId Optional user + * @returns Observable + */ + public demoteStaged( + id: string, + object_refs: StixObjectRef[], + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/staged/demote`; + const payload = userAccountId + ? { object_refs, userAccountId } + : { object_refs }; + return this.http.post(url, payload).pipe( + tap(result => + logger.log(`demoted staged objects for track ${id}`, result) + ), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Release Track Configuration + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/config + * Get track configuration. + * @param id Release track id + * @returns Observable + */ + public getConfig(id: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/config`; + return this.http.get(url).pipe( + tap(() => logger.log(`retrieved config for track ${id}`)), + catchError(this.handleError_continue(null)), + share() + ); + } + + /** + * PUT /api/release-tracks/:id/config + * Update track configuration. + * @param id Release track id + * @param body Configuration payload + * @param userAccountId Optional user + * @returns Observable + */ + public updateConfig( + id: string, + body: ConfigPayload, + userAccountId?: string + ): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/config`; + const payload = userAccountId ? { ...body, userAccountId } : body; + return this.http.put(url, payload).pipe( + tap(result => logger.log(`updated config for track ${id}`, result)), + catchError(this.handleError_raise()), + share() + ); + } + + // ----------------------------------------------------------------------------- + // Version Management + // ----------------------------------------------------------------------------- + + /** + * GET /api/release-tracks/:id/objects/:objectRef/versions + * List object versions in a track. + * @param id Release track id + * @param objectRef STIX object id + * @returns Observable + */ + public listObjectVersions(id: string, objectRef: string): Observable { + const url = `${this.apiUrl}/release-tracks/${id}/objects/${objectRef}/versions`; + return this.http.get(url).pipe( + tap(() => logger.log(`listed versions for object ${objectRef}`)), + catchError(this.handleError_continue(null)), + share() + ); + } +} From 59e0d098183cc601e986dc19a532afd29d7e7ec7 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:30:53 -0400 Subject: [PATCH 003/159] feat: redesign object pages to use tab layout --- src/app/app.module.ts | 2 + .../stix-page-tabs.component.html | 41 ++ .../stix-page-tabs.component.scss | 16 + .../stix-page-tabs.component.ts | 21 + .../analytic-view.component.html | 248 ++++---- .../stix/asset-view/asset-view.component.html | 290 ++++----- .../campaign-view.component.html | 284 ++++----- .../data-component-view.component.html | 246 ++++---- .../data-source-view.component.html | 244 ++++---- .../detection-strategy-view.component.html | 200 +++--- .../stix/group-view/group-view.component.html | 205 ++++--- .../matrix-view/matrix-view.component.html | 272 ++++---- .../mitigation-view.component.html | 198 +++--- .../software-view.component.html | 279 +++++---- .../tactic-view/tactic-view.component.html | 191 +++--- .../technique-view.component.html | 580 +++++++++--------- 16 files changed, 1737 insertions(+), 1580 deletions(-) create mode 100644 src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html create mode 100644 src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss create mode 100644 src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 54b5bb72b..26608c6b7 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -227,6 +227,7 @@ import { DictionaryPropertyComponent } from './components/stix/dictionary-proper import { DictionaryEditComponent } from './components/stix/dictionary-property/dictionary-edit/dictionary-edit.component'; import { DictionaryViewComponent } from './components/stix/dictionary-property/dictionary-view/dictionary-view.component'; import { DictionaryDiffComponent } from './components/stix/dictionary-property/dictionary-diff/dictionary-diff.component'; +import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-page-tabs.component'; export function initConfig(appConfigService: AppConfigService) { return () => appConfigService.loadAppConfig(); @@ -284,6 +285,7 @@ export function initConfig(appConfigService: AppConfigService) { DatepickerPropertyComponent, IconViewComponent, LandingPageComponent, + StixPageTabsComponent, HelpPageComponent, DashboardPageComponent, DataQualityComponent, diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html new file mode 100644 index 000000000..45c5acbe4 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html @@ -0,0 +1,41 @@ + + + + DETAILS + + +
+ +
+
+
+ + + + + {{ rel.label | uppercase }} + + + + + + + + + + HISTORY + + + + + + + + + NOTES + + + + + +
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss new file mode 100644 index 000000000..82af05621 --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss @@ -0,0 +1,16 @@ +.nav-tab-label { + font-family: Roboto, Arial, sans-serif; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.4px; + display: inline-block; + padding: 0 8px; +} + +.mat-mdc-tab-header .mat-mdc-tab-label .nav-tab-label { + color: inherit; +} + +.details-tpl-wrapper { + margin-top: 20px; +} diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts new file mode 100644 index 000000000..ade468d8a --- /dev/null +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts @@ -0,0 +1,21 @@ +import { Component, Input, TemplateRef } from '@angular/core'; +import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; + +interface RelationshipTab { + label: string; + template: TemplateRef; +} + +@Component({ + selector: 'app-stix-page-tabs', + templateUrl: './stix-page-tabs.component.html', + styleUrls: ['./stix-page-tabs.component.scss'], + standalone: false, +}) +export class StixPageTabsComponent { + @Input() config!: StixViewConfig; + @Input() detailsTemplate!: TemplateRef; + @Input() relationshipTabs: RelationshipTab[] = []; + @Input() showHistory = true; + @Input() showNotes = true; +} diff --git a/src/app/views/stix/analytic-view/analytic-view.component.html b/src/app/views/stix/analytic-view/analytic-view.component.html index 7ed1ebd25..ed0cf3c8e 100644 --- a/src/app/views/stix/analytic-view/analytic-view.component.html +++ b/src/app/views/stix/analytic-view/analytic-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -8,126 +8,136 @@ object: config.object, }"> + [config]="config">
-
-
- - - -
-
- - -
-
-
- -
- -
-
- - -
-
- @if (config.mode === 'view') { - - -
-
- + + + + + +
+
+
+ + + +
+
+ + +
+
+
+ +
+ +
+
+ + +
+
+ @if (config.mode === 'view') { + + +
+
+ +
+
+ } +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
- } -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
+
diff --git a/src/app/views/stix/asset-view/asset-view.component.html b/src/app/views/stix/asset-view/asset-view.component.html index ae6fb287e..a2fb9567f 100644 --- a/src/app/views/stix/asset-view/asset-view.component.html +++ b/src/app/views/stix/asset-view/asset-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,131 +7,152 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + -
-
-
-
- - + }"> +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Techniques Used

@@ -163,27 +184,6 @@

Techniques Used

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+ +
diff --git a/src/app/views/stix/campaign-view/campaign-view.component.html b/src/app/views/stix/campaign-view/campaign-view.component.html index 0877f923e..24e5bca90 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.html +++ b/src/app/views/stix/campaign-view/campaign-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -7,131 +7,139 @@ mode: config.mode, object: config.object, }"> - +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
-
- -
- + + +

Groups

@@ -163,8 +171,9 @@

Groups

}">
+
- +

Techniques Used

@@ -196,8 +205,9 @@

Techniques Used

}">
+
- +

Software Used

@@ -229,27 +239,5 @@

Software Used

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/data-component-view/data-component-view.component.html b/src/app/views/stix/data-component-view/data-component-view.component.html index 7f05e50a8..22d2dd877 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.html +++ b/src/app/views/stix/data-component-view/data-component-view.component.html @@ -1,4 +1,4 @@ -
+
+ [config]="{ mode: config.mode, object: config.object }">
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - @if ( - (config.showRelationships || !config.hasOwnProperty('showRelationships')) && - !editing - ) { -
+ + + + + +
-

Techniques Detected

- + + +
+
+ +
- + - + mode: config.mode, + object: config.object, + field: 'domains', + editType: 'select', + }">
-
- } - -
-
-
-

References

+
+
+ + +
-
-
-
- +
+
+ + +
+
+ + @if ( + (config.showRelationships || !config.hasOwnProperty('showRelationships')) && + !editing + ) { +
+
+
+

Techniques Detected

+ +
+
+
+
+ + +
+
+
+ } + +
+
+
+

References

+
+
+
+
+ +
+
-
+
diff --git a/src/app/views/stix/data-source-view/data-source-view.component.html b/src/app/views/stix/data-source-view/data-source-view.component.html index 84f62cdff..1b1e70520 100644 --- a/src/app/views/stix/data-source-view/data-source-view.component.html +++ b/src/app/views/stix/data-source-view/data-source-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -8,102 +8,131 @@ object: config.object, }"> + [config]="config">
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Data Components

@@ -124,8 +153,9 @@

Data Components

(refresh)="getDataComponents()">
+
- +

Techniques Detected

@@ -145,27 +175,5 @@

Techniques Detected

(refresh)="getDataComponents()">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html index d54a7ba50..26f361b84 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -8,85 +8,114 @@ object: config.object, }"> + [config]="config">
-
-
- - -
-
- - -
-
-
-
- - @if (config.mode === 'edit') { - - } @else if (!loading) { - - } @else { -
-
- + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + @if (config.mode === 'edit') { + + } @else if (!loading) { + + } @else { +
+
+ +
+ analytics +
+ } +
+
+ + +
+
+ @if ( + detectionStrategy.external_references.list().length > 0 || + previous?.external_references.list().length > 0 + ) { +
+
+

References

+
+
+
+
+
- analytics
}
-
- - -
-
- - @if ( - (config.showRelationships || !config.hasOwnProperty('showRelationships')) && - !editing - ) { - + + +

Techniques

@@ -116,26 +145,5 @@

Techniques

}">
- } - - @if ( - detectionStrategy.external_references.list().length > 0 || - previous?.external_references.list().length > 0 - ) { -
-
-

References

-
-
-
-
- -
-
- } +
diff --git a/src/app/views/stix/group-view/group-view.component.html b/src/app/views/stix/group-view/group-view.component.html index eac275116..4c90b7d86 100644 --- a/src/app/views/stix/group-view/group-view.component.html +++ b/src/app/views/stix/group-view/group-view.component.html @@ -1,4 +1,4 @@ -
+
- + +
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Campaigns

@@ -109,7 +141,9 @@

Campaigns

}">
- +
+ +

Techniques Used

@@ -141,8 +175,9 @@

Techniques Used

}">
+
- +

Software Used

@@ -174,27 +209,5 @@

Software Used

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/matrix/matrix-view/matrix-view.component.html b/src/app/views/stix/matrix/matrix-view/matrix-view.component.html index c9f9d341c..d830a058a 100644 --- a/src/app/views/stix/matrix/matrix-view/matrix-view.component.html +++ b/src/app/views/stix/matrix/matrix-view/matrix-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
- -
-
- -

TACTICS

-
-
-
- -
- -
-
- + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ +

TACTICS

+
+
+
+ +
+ +
+
+ +
+ tactics +
+
+ +
+
+
+

References

+
+
+
+
+ +
+
- tactics
-
- -
-
-

MATRIX

-
-
- - - -
- - Matrix View - - Side - Flat - - - - + +
+
+

MATRIX

- - +
+ + + +
+ + Matrix View + + Side + Flat + + + + +
+ + +
+ + +
- - + + - - - - - - -
-
-
-

References

-
-
-
-
- -
-
diff --git a/src/app/views/stix/mitigation-view/mitigation-view.component.html b/src/app/views/stix/mitigation-view/mitigation-view.component.html index f7edb22b4..ff76f0766 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.html +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.html @@ -1,4 +1,4 @@ -
+
+ [config]="config"> +
-
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
- -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
+ + +
-

Techniques Addressed by Mitigation

+

Techniques Addressed

@@ -110,27 +140,5 @@

Techniques Addressed by Mitigation

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/software-view/software-view.component.html b/src/app/views/stix/software-view/software-view.component.html index b6640b04f..d3621b905 100644 --- a/src/app/views/stix/software-view/software-view.component.html +++ b/src/app/views/stix/software-view/software-view.component.html @@ -1,4 +1,4 @@ -
+
- +
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
- -
- + + +

Techniques Used

@@ -148,8 +178,9 @@

Techniques Used

}">
+
- +

Associated Groups

@@ -181,8 +212,9 @@

Associated Groups

}">
+
- +

Campaigns

@@ -214,27 +246,6 @@

Campaigns

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+ +
diff --git a/src/app/views/stix/tactic-view/tactic-view.component.html b/src/app/views/stix/tactic-view/tactic-view.component.html index 0abba79b6..71c298a02 100644 --- a/src/app/views/stix/tactic-view/tactic-view.component.html +++ b/src/app/views/stix/tactic-view/tactic-view.component.html @@ -1,4 +1,4 @@ -
+
+ [config]="config">
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+

References

+
+
+
+
+ +
+
+
-
+ - - -
+

Techniques

@@ -96,28 +122,5 @@

Techniques

}">
-
- - -
-
-
-

References

-
-
-
-
- -
-
-
+
diff --git a/src/app/views/stix/technique-view/technique-view.component.html b/src/app/views/stix/technique-view/technique-view.component.html index 08b8dd666..32ef1f515 100644 --- a/src/app/views/stix/technique-view/technique-view.component.html +++ b/src/app/views/stix/technique-view/technique-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -11,322 +11,340 @@ ? technique.parentTechnique : null, }"> - +
-
-
- - -
-
- -
-
- - -
-
- -
-
- -
-
- -
-
- -
- - -
- arrow_forward -
-
- -
- + + + +
+
+
+ + + }">
- -
+
+ +
+
+ + +
+
+ +
+
- -
+
+ + +
+
-
- -
-
-
- - -
-
- - -
-
- -
-
- -
-
-
-
- -
-
-
-
- - -
-
- -
-
- -
- - -
- arrow_forward -
-
- -
+ technique.supportsDomainSpecificFields || + previous?.supportsDomainSpecificFields + "> + +
+ arrow_forward +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+
- -
+
+
- -
+
+
+
- -
+
+
+
- -
- +
+
+ + +
+
+ +
+
+ + field: 'tactics', + editType: 'stixList', + disabled: + editing && + (technique.domains.length === 0 || technique.is_subtechnique), + }"> +
+ + +
+ arrow_forward +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+

References

+
+
+
+
+ +
- -
-
-
- -
-
- -
- + + +
@@ -382,7 +400,9 @@

- + + +

Campaigns

@@ -414,7 +434,9 @@

Campaigns

}">
- +
+ +

Mitigations

@@ -444,7 +466,9 @@

Mitigations

}">
- +
+ +

Procedure Examples

@@ -474,7 +498,9 @@

Procedure Examples

}">
- +
+ +
@@ -502,7 +528,9 @@

Data Sources

- +
+ +

Detection Strategies

@@ -534,7 +562,9 @@

Detection Strategies

}">
- +
+ +

Assets

@@ -565,27 +595,5 @@

Assets

}">
-
- -
-
-
-

References

-
-
-
-
- -
-
-
+
From 9cddb6cf5c4ea77bef266f09024497857cb07aaf Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:31:08 -0400 Subject: [PATCH 004/159] chore: formatting --- .../analytic-view.component.html | 3 +-- .../stix/asset-view/asset-view.component.html | 19 ++++++++++++------- .../campaign-view.component.html | 2 +- .../data-component-view.component.html | 6 +++--- .../data-source-view.component.html | 5 ++--- .../detection-strategy-view.component.html | 7 ++----- .../stix/group-view/group-view.component.html | 4 ++-- .../matrix-view/matrix-view.component.html | 7 +++---- .../mitigation-view.component.html | 8 ++------ .../software-view.component.html | 3 +-- .../tactic-view/tactic-view.component.html | 7 ++----- .../technique-view.component.html | 19 +++++++++++++------ 12 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/app/views/stix/analytic-view/analytic-view.component.html b/src/app/views/stix/analytic-view/analytic-view.component.html index ed0cf3c8e..68bc8717e 100644 --- a/src/app/views/stix/analytic-view/analytic-view.component.html +++ b/src/app/views/stix/analytic-view/analytic-view.component.html @@ -7,8 +7,7 @@ mode: config.mode === 'edit' ? 'view' : config.mode, object: config.object, }"> - +
diff --git a/src/app/views/stix/asset-view/asset-view.component.html b/src/app/views/stix/asset-view/asset-view.component.html index a2fb9567f..da60f317a 100644 --- a/src/app/views/stix/asset-view/asset-view.component.html +++ b/src/app/views/stix/asset-view/asset-view.component.html @@ -14,9 +14,7 @@ @@ -105,8 +103,16 @@ label: 'related assets', subtypeFields: [ { name: 'name', editType: 'string', required: true, key: true }, - { name: 'related_asset_sectors', label: 'sectors', editType: 'select' }, - { name: 'description', supportsReferences: true, editType: 'description' } + { + name: 'related_asset_sectors', + label: 'sectors', + editType: 'select', + }, + { + name: 'description', + supportsReferences: true, + editType: 'description', + }, ], tooltip: 'a related asset', }"> @@ -126,7 +132,7 @@ }">
- +
Techniques Used
-
diff --git a/src/app/views/stix/campaign-view/campaign-view.component.html b/src/app/views/stix/campaign-view/campaign-view.component.html index 24e5bca90..d707ebbf9 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.html +++ b/src/app/views/stix/campaign-view/campaign-view.component.html @@ -17,7 +17,7 @@ [relationshipTabs]="[ { label: 'Techniques', template: techniquesTpl }, { label: 'Groups', template: groupsTpl }, - { label: 'Software', template: softwareTpl } + { label: 'Software', template: softwareTpl }, ]" [showHistory]="true" [showNotes]="true"> diff --git a/src/app/views/stix/data-component-view/data-component-view.component.html b/src/app/views/stix/data-component-view/data-component-view.component.html index 22d2dd877..a1b14122d 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.html +++ b/src/app/views/stix/data-component-view/data-component-view.component.html @@ -6,8 +6,7 @@ mode: config.mode, object: config.object, }"> - +
@@ -99,7 +98,8 @@
@if ( - (config.showRelationships || !config.hasOwnProperty('showRelationships')) && + (config.showRelationships || + !config.hasOwnProperty('showRelationships')) && !editing ) {
diff --git a/src/app/views/stix/data-source-view/data-source-view.component.html b/src/app/views/stix/data-source-view/data-source-view.component.html index 1b1e70520..18a286403 100644 --- a/src/app/views/stix/data-source-view/data-source-view.component.html +++ b/src/app/views/stix/data-source-view/data-source-view.component.html @@ -7,8 +7,7 @@ mode: config.mode, object: config.object, }"> - +
@@ -17,7 +16,7 @@ [detailsTemplate]="detailsTpl" [relationshipTabs]="[ { label: 'Data Components', template: componentsTpl }, - { label: 'Techniques', template: techniquesTpl } + { label: 'Techniques', template: techniquesTpl }, ]" [showHistory]="true" [showNotes]="true"> diff --git a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html index 26f361b84..f3fa7fccd 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html @@ -7,17 +7,14 @@ mode: config.mode, object: config.object, }"> - +
diff --git a/src/app/views/stix/group-view/group-view.component.html b/src/app/views/stix/group-view/group-view.component.html index 4c90b7d86..16b1cb601 100644 --- a/src/app/views/stix/group-view/group-view.component.html +++ b/src/app/views/stix/group-view/group-view.component.html @@ -17,7 +17,7 @@ [relationshipTabs]="[ { label: 'Techniques', template: techniqueTpl }, { label: 'Campaigns', template: campaignTpl }, - { label: 'Software', template: softwareTpl } + { label: 'Software', template: softwareTpl }, ]" [showHistory]="true" [showNotes]="true"> @@ -84,7 +84,7 @@ }">
- +
+ [relationshipTabs]="[{ label: 'Matrix', template: matrixTpl }]" + [showHistory]="true" + [showNotes]="true"> diff --git a/src/app/views/stix/mitigation-view/mitigation-view.component.html b/src/app/views/stix/mitigation-view/mitigation-view.component.html index ff76f0766..48c4b0f75 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.html +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.html @@ -6,18 +6,14 @@ mode: config.mode, object: config.object, }"> - - +
diff --git a/src/app/views/stix/software-view/software-view.component.html b/src/app/views/stix/software-view/software-view.component.html index d3621b905..72b2fa2f2 100644 --- a/src/app/views/stix/software-view/software-view.component.html +++ b/src/app/views/stix/software-view/software-view.component.html @@ -17,7 +17,7 @@ [relationshipTabs]="[ { label: 'Techniques', template: techniquesTpl }, { label: 'Groups', template: groupsTpl }, - { label: 'Campaigns', template: campaignsTpl } + { label: 'Campaigns', template: campaignsTpl }, ]" [showHistory]="true" [showNotes]="true"> @@ -247,5 +247,4 @@

Campaigns

-
diff --git a/src/app/views/stix/tactic-view/tactic-view.component.html b/src/app/views/stix/tactic-view/tactic-view.component.html index 71c298a02..ce99010fd 100644 --- a/src/app/views/stix/tactic-view/tactic-view.component.html +++ b/src/app/views/stix/tactic-view/tactic-view.component.html @@ -6,17 +6,14 @@ mode: config.mode, object: config.object, }"> - +
diff --git a/src/app/views/stix/technique-view/technique-view.component.html b/src/app/views/stix/technique-view/technique-view.component.html index 32ef1f515..f29f96dca 100644 --- a/src/app/views/stix/technique-view/technique-view.component.html +++ b/src/app/views/stix/technique-view/technique-view.component.html @@ -24,7 +24,7 @@ { label: 'Mitigations', template: mitigationsTpl }, { label: 'Detection Strategies', template: detectsTpl }, { label: 'Campaigns', template: campaignsTpl }, - { label: 'Assets', template: assetsTpl } + { label: 'Assets', template: assetsTpl }, ]" [showHistory]="true" [showNotes]="true"> @@ -77,7 +77,7 @@ }">
- +
@@ -102,7 +102,9 @@
Date: Wed, 8 Apr 2026 15:26:15 -0400 Subject: [PATCH 005/159] fix: call setOrganizationIdentity when identity is saved instead of postIdentity setOrganizationIdentity will chain postIdentity with setOrganizationIdentity and should have been called all along. --- .../org-settings-page/org-settings-page.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts index b491d081e..7568be3e2 100644 --- a/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts +++ b/src/app/views/dashboard-page/org-settings-page/org-settings-page.component.ts @@ -61,7 +61,7 @@ export class OrgSettingsPageComponent implements OnInit { saveIdentity(): void { const subscription = this.restAPIConnector - .postIdentity(this.organizationIdentity) + .setOrganizationIdentity(this.organizationIdentity) .subscribe({ next: identity => (this.organizationIdentity = identity), complete: () => subscription.unsubscribe(), From a72bbb681f46be60e0ebfa2f65954fb050828e53 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:27:42 -0400 Subject: [PATCH 006/159] feat(release-tracks): added release management dashboard page --- src/app/app-routing.module.ts | 10 ++ src/app/app.module.ts | 7 + .../new-track-dialog.component.html | 86 +++++++++ .../new-track-dialog.component.scss | 62 +++++++ .../new-track-dialog.component.ts | 117 ++++++++++++ .../rest-api/release-tracks.service.ts | 3 +- .../dashboard-page.component.html | 6 + .../release-management.component.html | 165 +++++++++++++++++ .../release-management.component.scss | 140 +++++++++++++++ .../release-management.component.ts | 167 ++++++++++++++++++ src/style/typography.scss | 3 + 11 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 src/app/components/new-track-dialog/new-track-dialog.component.html create mode 100644 src/app/components/new-track-dialog/new-track-dialog.component.scss create mode 100644 src/app/components/new-track-dialog/new-track-dialog.component.ts create mode 100644 src/app/views/dashboard-page/release-management/release-management.component.html create mode 100644 src/app/views/dashboard-page/release-management/release-management.component.scss create mode 100644 src/app/views/dashboard-page/release-management/release-management.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ea3e93aa0..f3a20208d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { AuthorizationGuard } from './services/helpers/authorization.guard'; import { Role } from './classes/authn/role'; import { TeamsListPageComponent } from './views/dashboard-page/teams/teams-list-page/teams-list-page.component'; import { TeamsViewPageComponent } from './views/dashboard-page/teams/teams-view-page/teams-view-page.component'; +import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; const editRoles = [Role.EDITOR, Role.TEAM_LEAD, Role.ADMIN]; @@ -54,6 +55,15 @@ const routes: Routes = [ }, component: DashboardPageComponent, }, + { + path: 'release-management', + data: { + breadcrumb: 'release management', + title: 'Release Management', + roles: [Role.ADMIN, Role.TEAM_LEAD], + }, + component: ReleaseManagementComponent, + }, { path: 'teams', data: { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 26608c6b7..dd837ba29 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -45,6 +45,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule } from '@angular/material/sort'; import { MatStepperModule } from '@angular/material/stepper'; @@ -228,6 +229,8 @@ import { DictionaryEditComponent } from './components/stix/dictionary-property/d import { DictionaryViewComponent } from './components/stix/dictionary-property/dictionary-view/dictionary-view.component'; import { DictionaryDiffComponent } from './components/stix/dictionary-property/dictionary-diff/dictionary-diff.component'; import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-page-tabs.component'; +import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; +import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; export function initConfig(appConfigService: AppConfigService) { return () => appConfigService.loadAppConfig(); @@ -374,11 +377,14 @@ export function initConfig(appConfigService: AppConfigService) { DictionaryEditComponent, DictionaryViewComponent, DictionaryDiffComponent, + ReleaseManagementComponent, + NewTrackDialogComponent, ], exports: [ MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, @@ -424,6 +430,7 @@ export function initConfig(appConfigService: AppConfigService) { MatToolbarModule, MatButtonModule, MatSidenavModule, + MatSlideToggleModule, MatIconModule, MatTableModule, MatSortModule, 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 new file mode 100644 index 000000000..b1a786e1c --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.html @@ -0,0 +1,86 @@ +
+

Create New Release Track

+ +
+ + Track Name + + + + + Description + + + + + +
+
+
Auto-promote Candidates
+
+ Automatically promote Candidates to "Staged" when they meet the threshold. +
+
+ + + Auto-promote + + +
+
+ + Candidacy Threshold + + {{ + opt + }} + + +
+ + + + + Member Sync Strategy + + {{ + opt + }} + + + Determines if new revisions to existing member objects are automatically added as candidates. + + + + + Supplant Behavior + + {{ + opt + }} + + + Dictates how to handle a new version of an object that is already staged. + + +
+ + + + + +
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 new file mode 100644 index 000000000..0a2514091 --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.scss @@ -0,0 +1,62 @@ +@use '../../../style/globals'; +@use '../../../style/colors'; + +.new-track-dialog { + margin: 0 24px; + height: 100%; + display: flex; + flex-direction: column; + + .divider { + margin: 12px 0; + } + + form { + display: flex; + flex-direction: column; + gap: 20px; + } + + .section-block { + display: flex; + justify-content: space-between; + align-items: center; + } + + .section-title { + font-weight: 600; + color: rgba(0, 0, 0, 0.85); + } + .section-sub { + font-size: 0.9em; + color: rgba(0, 0, 0, 0.6); + } + + .mat-mdc-form-field { + width: 100%; + } + + .no-hint .mat-mdc-form-field-subscript-wrapper { + display: none; // remove bottom padding used for hint + } + + mat-hint { + color: rgba(0, 0, 0, 0.65); + } + + // actions pinned to bottom of dialog + mat-dialog-actions { + display: flex; + justify-content: flex-end; + margin: 16px 0px; + padding: 0px; + + button + button { + margin-left: 12px; + } + + .success { + color: colors.color(success); + } + } +} 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 new file mode 100644 index 000000000..7a6030852 --- /dev/null +++ b/src/app/components/new-track-dialog/new-track-dialog.component.ts @@ -0,0 +1,117 @@ +import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { + WorkflowStatus, + MemberSyncStrategy, + MemberSyncBehavior, + ReleaseTrack, +} from 'src/app/classes/release-tracks/enums'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; + +@Component({ + standalone: false, + selector: 'app-new-track-dialog', + templateUrl: './new-track-dialog.component.html', + styleUrls: ['./new-track-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class NewTrackDialogComponent implements OnInit { + public form: FormGroup; + public loading = false; + + public WorkflowStatus = WorkflowStatus; + public MemberSyncStrategy = MemberSyncStrategy; + public MemberSyncBehavior = MemberSyncBehavior; + + public candidacyOptions = Object.values(WorkflowStatus); + public memberSyncOptions = Object.values(MemberSyncStrategy); + public supplantOptions = Object.values(MemberSyncBehavior); + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, + private fb: FormBuilder, + private connector: ReleaseTracksConnectorService + ) { + this.form = this.fb.group({ + name: ['', [Validators.required]], + description: [''], + autoPromote: [false], + candidacyThreshold: [{ value: WorkflowStatus.Reviewed, disabled: true }], + memberSync: [MemberSyncStrategy.TrackLatest], + supplantBehavior: [MemberSyncBehavior.Replace], + }); + } + + ngOnInit(): void { + if (this.data) { + const init = this.data || {}; + if (init.name) this.form.get('name')?.setValue(init.name); + if (init.description) + this.form.get('description')?.setValue(init.description); + if (typeof init.autoPromote !== 'undefined') + this.form.get('autoPromote')?.setValue(!!init.autoPromote); + if (init.candidacyThreshold) + this.form.get('candidacyThreshold')?.setValue(init.candidacyThreshold); + if (init.memberSync) + this.form.get('memberSync')?.setValue(init.memberSync); + if (init.supplantBehavior) + this.form.get('supplantBehavior')?.setValue(init.supplantBehavior); + } + + const autoCtrl = this.form.get('autoPromote'); + const candidacyCtrl = this.form.get('candidacyThreshold'); + if (autoCtrl && candidacyCtrl) { + // Initialize based on current value + if (autoCtrl.value) candidacyCtrl.enable({ emitEvent: false }); + else candidacyCtrl.disable({ emitEvent: false }); + + // Subscribe to changes to enable/disable control + autoCtrl.valueChanges.subscribe(value => { + if (value) candidacyCtrl.enable(); + else candidacyCtrl.disable(); + }); + } + } + + public closeDialog(): void { + this.dialogRef.close(); + } + + public isFormValid(): boolean { + return this.form.valid && !!this.form.get('name')?.value?.trim(); + } + + public handleCreate(): void { + if (!this.isFormValid() || this.loading) return; + + const payload: any = { + name: this.form.get('name')?.value, + description: this.form.get('description')?.value, + config: { + auto_promote: !!this.form.get('autoPromote')?.value, + candidacy_threshold: this.form.get('candidacyThreshold')?.value, + member_sync: { + strategy: this.form.get('memberSync')?.value, + supplant: this.form.get('supplantBehavior')?.value, + }, + }, + type: ReleaseTrack.Standard, + }; + + 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(); + }, + }); + } +} 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 96c7dcf3b..3ede228cf 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -11,6 +11,7 @@ import { ReleaseTrackType, WorkflowStatusType, } from 'src/app/classes/release-tracks'; +import { Paginated } from './rest-api-connector.service'; // ----------------------------------------------------------------------------- // Request Payload Definitions for Release Tracks API Requests @@ -130,7 +131,7 @@ export class ReleaseTracksConnectorService extends ApiConnector { limit?: number; offset?: number; search?: string; - }): Observable { + }): Observable> { let params = new HttpParams(); if (options?.type) params = params.set('type', options.type); if (options?.releases) params = params.set('releases', options.releases); diff --git a/src/app/views/dashboard-page/dashboard-page.component.html b/src/app/views/dashboard-page/dashboard-page.component.html index aa9236846..8a37e0013 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.html +++ b/src/app/views/dashboard-page/dashboard-page.component.html @@ -7,6 +7,12 @@

Dashboard

+ +
+
+ +
+
+ +
+

+ Release Tracks ({{ standardTracks.length }}) +

+
+
+
+ + {{ track.name }} + + Standard +
+ + @if (track.description) { + + {{ track.description }} + + } + +
+
+
CANDIDATES
+
+ {{ track.stats?.candidates || track.snapshot_count || 0 }} +
+
+
+
STAGED
+
{{ track.stats?.staged || 0 }}
+
+
+
MEMBERS
+
+ {{ track.stats?.members || track.tagged_release_count || 0 }} +
+
+
+ + + + +
+
+
+ + +
+

+ Virtual Tracks ({{ virtualTracks.length }}) +

+
+
+
+ + {{ track.name }} + + Virtual +
+ + @if (track.description) { + + {{ track.description }} + + } + +
+
+
COMPONENTS
+
+ {{ track.composition?.component_tracks?.length || 0 }} +
+
+
+
QUARANTINED
+
+ {{ track.stats?.quarantined || 0 }} +
+
+
+
MEMBERS
+
{{ track.stats?.members || 0 }}
+
+
+ + + + +
+
+
+
+
+
diff --git a/src/app/views/dashboard-page/release-management/release-management.component.scss b/src/app/views/dashboard-page/release-management/release-management.component.scss new file mode 100644 index 000000000..f44325ba6 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.scss @@ -0,0 +1,140 @@ +@use '../../../../style/globals'; +@use '../../../../style/colors'; + +.release-management { + .controls { + margin-bottom: 16px; + } + + .sections-row { + /* Force the sections to stack vertically so Standard is always above Virtual */ + display: block; + width: 100%; + } + + .cards-container { + display: flex; + flex-direction: column; + gap: 12px; + padding: 10px; + + .cards-grid { + /* responsive grid for cards inside each column */ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 24px; + } + + /* lightweight wrapper for each card to ensure consistent surface styles + Added explicit fallback colors (do not rely solely on color() accessors) + to guard against build-time issues or runtime theming differences. */ + .cards-grid > .list-card, + .cards-grid > div.list-card { + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + justify-content: space-between; + border: 1px solid rgba(0, 0, 0, 0.06); + min-height: 10rem; + box-sizing: border-box; + /* Fallback surface colors so cards render even without theme context */ + background: #ffffff; + color: #111111; + + /* Theme-aware overrides (preferred) */ + .light & { + background: colors.color(light); + color: colors.on-color(light); + } + .dark & { + background: colors.color(dark); + color: colors.on-color(dark); + } + } + + .card-standard, + .card-virtual { + /* card surface */ + border-radius: 8px; + padding: 24px; + min-height: 12rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); + display: flex; + flex-direction: column; + justify-content: space-between; + + .light & { + background: colors.color(light); + } + + .dark & { + background: colors.color(dark); + } + + .card-top { + display: flex; + justify-content: space-between; + align-items: center; + margin: 8px 0; + } + + .card-desc { + color: colors.on-color-deemphasis(dark); + margin: 6px 0; + } + + .card-stats { + display: flex; + gap: 12px; + background: rgba(0, 0, 0, 0.06); + padding: 12px; + border-radius: 8px; + margin-top: 12px; + } + + .stat { + flex: 1; + text-align: center; + } + + .stat-label { + font-size: 11px; + color: colors.on-color-deemphasis(dark); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; + } + + .stat-value { + font-weight: 700; + font-size: 1.25rem; + } + + .card-sep { + margin: 12px 0; + } + + .card-footer { + display: flex; + justify-content: space-between; + color: colors.on-color-deemphasis(dark); + font-size: 13px; + } + } + + .tiny-icon { + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + line-height: 14px !important; + margin-right: 6px; + } + + .version { + display: flex; + align-items: center; + } + } +} diff --git a/src/app/views/dashboard-page/release-management/release-management.component.ts b/src/app/views/dashboard-page/release-management/release-management.component.ts new file mode 100644 index 000000000..8b52dfe67 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-management.component.ts @@ -0,0 +1,167 @@ +import { + Component, + OnInit, + OnDestroy, + Output, + EventEmitter, + ViewEncapsulation, +} from '@angular/core'; +import { Subscription } from 'rxjs'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { ReleaseTrack } from 'src/app/classes/release-tracks'; +import { MatDialog } from '@angular/material/dialog'; +import { NewTrackDialogComponent } from 'src/app/components/new-track-dialog/new-track-dialog.component'; + +@Component({ + selector: 'app-release-tracks-list', + templateUrl: './release-management.component.html', + styleUrls: ['./release-management.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, +}) +export class ReleaseManagementComponent implements OnInit, OnDestroy { + @Output() viewTrack = new EventEmitter(); + + public filter: 'all' | 'Standard' | 'Virtual' = 'all'; + public allTracks: any[] = []; + + private subscription: Subscription | null = null; + + public get standardTracks(): any[] { + return this.allTracks.filter( + t => t.type === ReleaseTrack.Standard || t.type === 'standard' + ); + } + + public get virtualTracks(): any[] { + return this.allTracks.filter( + t => t.type === ReleaseTrack.Virtual || t.type === 'virtual' + ); + } + + constructor( + private connector: ReleaseTracksConnectorService, + private dialog: MatDialog + ) {} + + ngOnInit(): void { + this.loadTracks(); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + + private loadTracks(): void { + this.subscription = this.connector.listReleaseTracks().subscribe(result => { + if (!result || !result.data) { + this.allTracks = []; + return; + } + this.allTracks = this.tracksWithComputedData(result.data); + }); + } + + public setFilter(value: 'all' | 'Standard' | 'Virtual'): void { + this.filter = value; + } + + public onViewTrack(id: string): void { + this.viewTrack.emit(id); + } + + private tracksWithComputedData(data: any[]): any[] { + return data?.map((track: any) => { + // If snapshots are available, compute stats from them. Otherwise use + // server-provided summary fields like latest_snapshot_modified. + const snapshots: any[] = Array.isArray(track.snapshots) + ? track.snapshots + : []; + + let latestVersion: any = 'No tagged releases'; + let latestModified: any = + track.latest_snapshot_modified || + track.created_at || + track.updated_at || + null; + let stats: any = { + candidates: 0, + staged: 0, + members: 0, + quarantined: 0, + }; + + if (snapshots.length) { + const sortedByModified = [...snapshots].sort((a, b) => { + const ma = a?.modified || ''; + const mb = b?.modified || ''; + return String(mb).localeCompare(String(ma)); + }); + + const latestSnapshot = sortedByModified[0] || null; + const taggedSnapshots = sortedByModified.filter( + (s: any) => s && s.version + ); + const latestTaggedSnapshot = + taggedSnapshots.length > 0 ? taggedSnapshots[0] : null; + + latestVersion = latestTaggedSnapshot + ? latestTaggedSnapshot.version + : 'No tagged releases'; + latestModified = + latestSnapshot?.modified || latestSnapshot?.created || latestModified; + + stats = { + candidates: + latestSnapshot?.candidates?.length || + latestSnapshot?.contents?.candidates?.length || + 0, + staged: + latestSnapshot?.staged?.length || + latestSnapshot?.contents?.staged?.length || + 0, + members: + latestTaggedSnapshot?.members?.length || + latestTaggedSnapshot?.contents?.members?.length || + 0, + quarantined: + latestSnapshot?.quarantine?.length || + latestSnapshot?.contents?.quarantine?.length || + 0, + }; + } else { + latestVersion = track.latest_tagged_version || 'No tagged releases'; + latestModified = track.latest_snapshot_modified || latestModified; + stats = { + candidates: track.snapshot_count || 0, + staged: 0, + members: track.tagged_release_count || 0, + quarantined: 0, + }; + } + + return { + ...track, + latestVersion, + latestModified, + stats, + }; + }); + } + + public openNewTrackDialog(): void { + const dialogRef = this.dialog.open(NewTrackDialogComponent, { + width: '50em', + autoFocus: false, + }); + + dialogRef.afterClosed().subscribe(result => { + if (!result) return; + // attempt to extract an id from the result + const createdId = result?.id || result?.track_id || null; + if (createdId) this.viewTrack.emit(createdId); + // refresh list to include the newly-created track + this.loadTracks(); + }); + } +} diff --git a/src/style/typography.scss b/src/style/typography.scss index 6e98787dc..e5a2d5115 100644 --- a/src/style/typography.scss +++ b/src/style/typography.scss @@ -79,6 +79,9 @@ color: rgba(255, 255, 255, 0.6); } } +.description-text { + @extend .placeholder-text; +} .mdc-label { line-height: 24px; padding: 0px !important; From 25f8e6dcc9f51ba847a15de07c8df1d929503d61 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:29:54 -0400 Subject: [PATCH 007/159] chore: formatting --- .../new-track-dialog.component.html | 13 +++++++------ .../dashboard-page/dashboard-page.component.html | 5 ++++- 2 files changed, 11 insertions(+), 7 deletions(-) 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 b1a786e1c..7e5163158 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 @@ -26,13 +26,12 @@

Create New Release Track

Auto-promote Candidates
- Automatically promote Candidates to "Staged" when they meet the threshold. + Automatically promote Candidates to "Staged" when they meet the + threshold.
- - Auto-promote - + Auto-promote
@@ -56,7 +55,8 @@

Create New Release Track

}} - Determines if new revisions to existing member objects are automatically added as candidates. + Determines if new revisions to existing member objects are automatically + added as candidates. @@ -68,7 +68,8 @@

Create New Release Track

}} - Dictates how to handle a new version of an object that is already staged. + Dictates how to handle a new version of an object that is already + staged. diff --git a/src/app/views/dashboard-page/dashboard-page.component.html b/src/app/views/dashboard-page/dashboard-page.component.html index 8a37e0013..6c52cd8ac 100644 --- a/src/app/views/dashboard-page/dashboard-page.component.html +++ b/src/app/views/dashboard-page/dashboard-page.component.html @@ -7,7 +7,10 @@

Dashboard

-
- + diff --git a/src/app/views/stix/data-component-view/data-component-view.component.html b/src/app/views/stix/data-component-view/data-component-view.component.html index 01c9e8e4e..51a5c1a45 100644 --- a/src/app/views/stix/data-component-view/data-component-view.component.html +++ b/src/app/views/stix/data-component-view/data-component-view.component.html @@ -11,9 +11,7 @@
- + diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.html b/src/app/views/stix/stix-dialog/stix-dialog.component.html index d104f8d14..4836cfd91 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.html +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.html @@ -104,73 +104,48 @@ } @case ('intrusion-set') { - - + } @case ('campaign') { - - + } @case ('malware') { - - + } @case ('tool') { - - + } @case ('x-mitre-matrix') { - - + } @case ('course-of-action') { - - + } @case ('attack-pattern') { - - + } @case ('x-mitre-data-source') { - - + } @case ('x-mitre-data-component') { - + } @case ('x-mitre-asset') { - - + } @case ('x-mitre-tactic') { - - + } @case ('x-mitre-collection') { - - + } @case ('x-mitre-detection-strategy') { - + } @case ('x-mitre-analytic') { - - + } }
From 77aa023ecca7959088b0c49afeb84e7270e6b7c6 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:06:13 -0400 Subject: [PATCH 017/159] chore: commenting out incomplete feature --- .../release-management/release-management.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/views/dashboard-page/release-management/release-management.component.html b/src/app/views/dashboard-page/release-management/release-management.component.html index 384c7ccf3..d9871491c 100644 --- a/src/app/views/dashboard-page/release-management/release-management.component.html +++ b/src/app/views/dashboard-page/release-management/release-management.component.html @@ -1,7 +1,7 @@

Release Management

-
+
From 0fb63bcae88150a9d1607344108b7a9ad913e618 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:22 -0400 Subject: [PATCH 018/159] fix(release-tracks): enable create new track --- .../release-management/release-management.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/views/dashboard-page/release-management/release-management.component.html b/src/app/views/dashboard-page/release-management/release-management.component.html index d9871491c..384c7ccf3 100644 --- a/src/app/views/dashboard-page/release-management/release-management.component.html +++ b/src/app/views/dashboard-page/release-management/release-management.component.html @@ -1,7 +1,7 @@

Release Management

- +
From b32fb0f02120bd38901ac591cca19e7d97942013 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:45:34 -0400 Subject: [PATCH 019/159] chore: formatting --- .../new-track-dialog/new-track-dialog.component.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 57b2987d8..27010867a 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 @@ -98,7 +98,8 @@

{{ formatOption(opt) }} + >{{ formatOption(opt) }} How conflicting objects from component tracks are @@ -113,7 +114,8 @@

{{ formatOption(opt) }} + >{{ formatOption(opt) }} Prefer objects from this tier when resolving conflicts @@ -126,7 +128,8 @@

{{ formatOption(opt) }} + >{{ formatOption(opt) }} Prefer objects with this status when resolving conflicts From f33d83a622ea2efa602dbb01ec755ac2ebe34ce5 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 5 May 2026 16:04:08 -0400 Subject: [PATCH 020/159] feat(workflow-status): add object status chip --- src/app/app.module.ts | 2 ++ .../status-chip/status-chip.component.html | 5 +++++ .../status-chip/status-chip.component.scss | 16 ++++++++++++++++ .../status-chip/status-chip.component.ts | 16 ++++++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 src/app/components/status-chip/status-chip.component.html create mode 100644 src/app/components/status-chip/status-chip.component.scss create mode 100644 src/app/components/status-chip/status-chip.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 484d8ad49..24b867c88 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -232,6 +232,7 @@ import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-pag import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; import { ReleaseTrackCardComponent } from './components/release-track-card/release-track-card.component'; import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; +import { StatusChipComponent } from './components/status-chip/status-chip.component'; export function initConfig(appConfigService: AppConfigService) { return () => appConfigService.loadAppConfig(); @@ -380,6 +381,7 @@ export function initConfig(appConfigService: AppConfigService) { DictionaryDiffComponent, ReleaseManagementComponent, NewTrackDialogComponent, + StatusChipComponent, ], exports: [ MatToolbarModule, diff --git a/src/app/components/status-chip/status-chip.component.html b/src/app/components/status-chip/status-chip.component.html new file mode 100644 index 000000000..b02b3639b --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.html @@ -0,0 +1,5 @@ +
+ + {{ label }} + +
\ No newline at end of file diff --git a/src/app/components/status-chip/status-chip.component.scss b/src/app/components/status-chip/status-chip.component.scss new file mode 100644 index 000000000..3b2735c49 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.scss @@ -0,0 +1,16 @@ +@use 'src/style/colors.scss' as colors; + +.status-chip { + .mat-mdc-chip.awaiting-review, + .awaiting-review { + background: colors.color('pending'); + color: colors.on-color('pending'); + } + + .mat-mdc-chip.reviewed, + .reviewed { + background: colors.color('active'); + color: colors.on-color('active'); + } +} + diff --git a/src/app/components/status-chip/status-chip.component.ts b/src/app/components/status-chip/status-chip.component.ts new file mode 100644 index 000000000..d25fb9a31 --- /dev/null +++ b/src/app/components/status-chip/status-chip.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; +import { WorkflowState, WorkflowStates } from '../../utils/types'; + +@Component({ + selector: 'app-status-chip', + standalone: false, + templateUrl: './status-chip.component.html', + styleUrls: ['./status-chip.component.scss'], +}) +export class StatusChipComponent { + @Input() status!: WorkflowState; + + public get label(): string { + return WorkflowStates[this.status] ?? String(this.status); + } +} From 5de7a90243e32cb6afbf7d3d2e6d8c1f69b258b0 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 5 May 2026 16:04:34 -0400 Subject: [PATCH 021/159] chore: formatting --- .../status-chip/status-chip.component.html | 2 +- .../status-chip/status-chip.component.scss | 23 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/app/components/status-chip/status-chip.component.html b/src/app/components/status-chip/status-chip.component.html index b02b3639b..62344e73f 100644 --- a/src/app/components/status-chip/status-chip.component.html +++ b/src/app/components/status-chip/status-chip.component.html @@ -2,4 +2,4 @@ {{ label }} -

\ No newline at end of file +
diff --git a/src/app/components/status-chip/status-chip.component.scss b/src/app/components/status-chip/status-chip.component.scss index 3b2735c49..f235f241f 100644 --- a/src/app/components/status-chip/status-chip.component.scss +++ b/src/app/components/status-chip/status-chip.component.scss @@ -1,16 +1,15 @@ @use 'src/style/colors.scss' as colors; .status-chip { - .mat-mdc-chip.awaiting-review, - .awaiting-review { - background: colors.color('pending'); - color: colors.on-color('pending'); - } - - .mat-mdc-chip.reviewed, - .reviewed { - background: colors.color('active'); - color: colors.on-color('active'); - } -} + .mat-mdc-chip.awaiting-review, + .awaiting-review { + background: colors.color('pending'); + color: colors.on-color('pending'); + } + .mat-mdc-chip.reviewed, + .reviewed { + background: colors.color('active'); + color: colors.on-color('active'); + } +} From e17d076c03aa14a625e75f56dee71da4d2a80a86 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Tue, 5 May 2026 16:36:50 -0400 Subject: [PATCH 022/159] fix(tabs): display custom tab options properly --- .../stix-page-tabs.component.html | 8 ++-- .../stix-page-tabs.component.scss | 39 ++++++++++++------- .../stix-page-tabs.component.ts | 6 ++- .../stix/asset-view/asset-view.component.html | 2 +- .../campaign-view.component.html | 2 +- .../data-source-view.component.html | 2 +- .../detection-strategy-view.component.html | 2 +- .../stix/group-view/group-view.component.html | 2 +- .../matrix-view/matrix-view.component.html | 2 +- .../mitigation-view.component.html | 2 +- .../software-view.component.html | 2 +- .../tactic-view/tactic-view.component.html | 2 +- .../technique-view.component.html | 2 +- 13 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html index 45c5acbe4..f78ec49b3 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html @@ -1,4 +1,4 @@ - + DETAILS @@ -10,13 +10,13 @@ - + - {{ rel.label | uppercase }} + {{ tab.label | uppercase }} - + diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss index 82af05621..9671f6a22 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss @@ -1,16 +1,25 @@ -.nav-tab-label { - font-family: Roboto, Arial, sans-serif; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.4px; - display: inline-block; - padding: 0 8px; -} +@use '../../../../style/colors.scss'; +@use '../../../../style/globals.scss'; -.mat-mdc-tab-header .mat-mdc-tab-label .nav-tab-label { - color: inherit; -} - -.details-tpl-wrapper { - margin-top: 20px; -} +.stix-page-tabs { + .mat-mdc-tab-header { + .dark & { + border-bottom: 1px solid colors.border-color(dark) !important; + } + .light & { + border-bottom: 1px solid colors.border-color(light) !important; + } + } + .nav-tab-label { + font-family: Roboto, Arial, sans-serif; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.4px; + display: inline-block; + padding: 0 8px; + } + + .details-tpl-wrapper { + margin-top: 20px; + } +} \ No newline at end of file diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts index ade468d8a..184c5526c 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts @@ -1,7 +1,8 @@ +import { ViewEncapsulation } from '@angular/core'; import { Component, Input, TemplateRef } from '@angular/core'; import { StixViewConfig } from 'src/app/views/stix/stix-view-page'; -interface RelationshipTab { +interface CustomTab { label: string; template: TemplateRef; } @@ -11,11 +12,12 @@ interface RelationshipTab { templateUrl: './stix-page-tabs.component.html', styleUrls: ['./stix-page-tabs.component.scss'], standalone: false, + encapsulation: ViewEncapsulation.None, }) export class StixPageTabsComponent { @Input() config!: StixViewConfig; @Input() detailsTemplate!: TemplateRef; - @Input() relationshipTabs: RelationshipTab[] = []; + @Input() customTabs: CustomTab[] = []; @Input() showHistory = true; @Input() showNotes = true; } diff --git a/src/app/views/stix/asset-view/asset-view.component.html b/src/app/views/stix/asset-view/asset-view.component.html index 255579ddc..105e50ae0 100644 --- a/src/app/views/stix/asset-view/asset-view.component.html +++ b/src/app/views/stix/asset-view/asset-view.component.html @@ -14,7 +14,7 @@ + [customTabs]="[{ label: 'Techniques', template: techniquesTpl }]"> diff --git a/src/app/views/stix/campaign-view/campaign-view.component.html b/src/app/views/stix/campaign-view/campaign-view.component.html index a342e0f13..8f6b49d1a 100644 --- a/src/app/views/stix/campaign-view/campaign-view.component.html +++ b/src/app/views/stix/campaign-view/campaign-view.component.html @@ -14,7 +14,7 @@ diff --git a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html index 2608e5b42..08afc3dca 100644 --- a/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html +++ b/src/app/views/stix/detection-strategy-view/detection-strategy-view.component.html @@ -14,7 +14,7 @@ + [customTabs]="[{ label: 'Techniques', template: techniquesTpl }]"> diff --git a/src/app/views/stix/group-view/group-view.component.html b/src/app/views/stix/group-view/group-view.component.html index b15ecba28..eab33e8ac 100644 --- a/src/app/views/stix/group-view/group-view.component.html +++ b/src/app/views/stix/group-view/group-view.component.html @@ -13,7 +13,7 @@ + [customTabs]="[{ label: 'Matrix', template: matrixTpl }]"> diff --git a/src/app/views/stix/mitigation-view/mitigation-view.component.html b/src/app/views/stix/mitigation-view/mitigation-view.component.html index 891e11201..47f4affed 100644 --- a/src/app/views/stix/mitigation-view/mitigation-view.component.html +++ b/src/app/views/stix/mitigation-view/mitigation-view.component.html @@ -13,7 +13,7 @@ + [customTabs]="[{ label: 'Techniques', template: techniquesTpl }]"> diff --git a/src/app/views/stix/software-view/software-view.component.html b/src/app/views/stix/software-view/software-view.component.html index 84e812887..d21a5ac38 100644 --- a/src/app/views/stix/software-view/software-view.component.html +++ b/src/app/views/stix/software-view/software-view.component.html @@ -14,7 +14,7 @@ + [customTabs]="[{ label: 'Techniques', template: techniquesTpl }]"> diff --git a/src/app/views/stix/technique-view/technique-view.component.html b/src/app/views/stix/technique-view/technique-view.component.html index c176f3000..b5b879cb2 100644 --- a/src/app/views/stix/technique-view/technique-view.component.html +++ b/src/app/views/stix/technique-view/technique-view.component.html @@ -18,7 +18,7 @@ Date: Tue, 5 May 2026 16:37:19 -0400 Subject: [PATCH 023/159] chore: formatting --- .../stix/stix-page-tabs/stix-page-tabs.component.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss index 9671f6a22..e9128a9a7 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.scss @@ -18,8 +18,8 @@ display: inline-block; padding: 0 8px; } - + .details-tpl-wrapper { margin-top: 20px; } -} \ No newline at end of file +} From c2bc45b33bd836477e03757c919865c18679f349 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 09:13:16 -0400 Subject: [PATCH 024/159] refactor(workflow-status): cleanup workflow status types and enums --- src/app/classes/release-tracks/composition.ts | 7 ++----- src/app/classes/release-tracks/config.ts | 2 +- src/app/classes/release-tracks/enums.ts | 17 ++--------------- src/app/classes/release-tracks/tiers.ts | 2 +- src/app/classes/serializable.ts | 6 +++--- src/app/classes/stix/analytic.ts | 4 ++-- src/app/classes/stix/asset.ts | 4 ++-- src/app/classes/stix/campaign.ts | 4 ++-- src/app/classes/stix/collection.ts | 4 ++-- src/app/classes/stix/data-component.ts | 4 ++-- src/app/classes/stix/data-source.ts | 4 ++-- src/app/classes/stix/detection-strategy.ts | 4 ++-- src/app/classes/stix/group.ts | 4 ++-- src/app/classes/stix/identity.ts | 4 ++-- src/app/classes/stix/marking-definition.ts | 4 ++-- src/app/classes/stix/matrix.ts | 4 ++-- src/app/classes/stix/mitigation.ts | 4 ++-- src/app/classes/stix/note.ts | 4 ++-- src/app/classes/stix/relationship.ts | 12 ++++++------ src/app/classes/stix/software.ts | 4 ++-- src/app/classes/stix/stix-object.ts | 13 ++++--------- src/app/classes/stix/tactic.ts | 4 ++-- src/app/classes/stix/technique.ts | 4 ++-- .../new-track-dialog.component.ts | 2 +- .../object-status/object-status.component.ts | 4 ++-- .../save-dialog/save-dialog.component.ts | 15 ++++++++++----- .../status-chip/status-chip.component.ts | 6 +++--- .../rest-api/release-tracks.service.ts | 2 +- src/app/utils/types.ts | 18 ++++++++++++------ 29 files changed, 80 insertions(+), 90 deletions(-) diff --git a/src/app/classes/release-tracks/composition.ts b/src/app/classes/release-tracks/composition.ts index 37b06a71a..ebab47ea8 100644 --- a/src/app/classes/release-tracks/composition.ts +++ b/src/app/classes/release-tracks/composition.ts @@ -5,12 +5,9 @@ // aggregates content from component tracks // ----------------------------------------------------------------------------- -import { - DeduplicationStrategyType, - SnapshotTierType, - WorkflowStatusType, -} from './enums'; +import { DeduplicationStrategyType, SnapshotTierType } from './enums'; import { ComponentTrack, ComponentSnapshotResolution } from './component-track'; +import { WorkflowStatusType } from 'src/app/utils/types'; export interface Composition { component_tracks?: ComponentTrack[]; diff --git a/src/app/classes/release-tracks/config.ts b/src/app/classes/release-tracks/config.ts index 2e84353e1..698ccd22b 100644 --- a/src/app/classes/release-tracks/config.ts +++ b/src/app/classes/release-tracks/config.ts @@ -2,9 +2,9 @@ // Release Track Configuration // ----------------------------------------------------------------------------- +import { WorkflowStatusType } from 'src/app/utils/types'; import { ConflictPolicyType, - WorkflowStatusType, MemberSyncBehaviorType, MemberSyncPolicyType, MemberSyncStrategyType, diff --git a/src/app/classes/release-tracks/enums.ts b/src/app/classes/release-tracks/enums.ts index 9defdf202..963eb73a6 100644 --- a/src/app/classes/release-tracks/enums.ts +++ b/src/app/classes/release-tracks/enums.ts @@ -1,3 +1,5 @@ +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; + // ----------------------------------------------------------------------------- // Release Track Type // ----------------------------------------------------------------------------- @@ -94,21 +96,6 @@ export const SNAPSHOT_TIER_OPTIONS: SnapshotTierType[] = Object.values( SnapshotTier ) as SnapshotTierType[]; -// ----------------------------------------------------------------------------- -// Workflow Statuses -// ----------------------------------------------------------------------------- - -export enum WorkflowStatus { - WorkInProgress = 'work-in-progress', - AwaitingReview = 'awaiting-review', - Reviewed = 'reviewed', -} - -export type WorkflowStatusType = - | WorkflowStatus.WorkInProgress - | WorkflowStatus.AwaitingReview - | WorkflowStatus.Reviewed; - // ----------------------------------------------------------------------------- // Candidacy Thresholds // ----------------------------------------------------------------------------- diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index 69a9aa1fe..3625b7d28 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -1,4 +1,4 @@ -import { WorkflowStatusType } from './enums'; +import { WorkflowStatusType } from 'src/app/utils/types'; export interface MemberEntry { object_ref: string; diff --git a/src/app/classes/serializable.ts b/src/app/classes/serializable.ts index 286bf37c4..b4bdce1d8 100644 --- a/src/app/classes/serializable.ts +++ b/src/app/classes/serializable.ts @@ -1,6 +1,6 @@ import { Observable } from 'rxjs'; import { RestApiConnectorService } from '../services/connectors/rest-api/rest-api-connector.service'; -import { WorkflowState } from '../utils/types'; +import { WorkflowStatusType } from '../utils/types'; /** * Objects which are serializable to the REST API implement this class @@ -17,7 +17,7 @@ export abstract class Serializable { * @abstract * @param {*} raw the raw object to parse */ - public abstract deserialize(raw: any); + public abstract deserialize(raw: any): any; /** * Validate the current object state and return information on the result of the validation @@ -29,7 +29,7 @@ export abstract class Serializable { public abstract validate( restAPIService: RestApiConnectorService, options?: any, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable; } diff --git a/src/app/classes/stix/analytic.ts b/src/app/classes/stix/analytic.ts index 9ea63968c..b00d1a1c0 100644 --- a/src/app/classes/stix/analytic.ts +++ b/src/app/classes/stix/analytic.ts @@ -3,7 +3,7 @@ import { logger } from '../../utils/logger'; import { Observable, of, switchMap } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { ValidationData } from '../serializable'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Analytic extends StixObject { public name = ''; @@ -179,7 +179,7 @@ export class Analytic extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( switchMap(result => { diff --git a/src/app/classes/stix/asset.ts b/src/app/classes/stix/asset.ts index 06f2be97f..1c6372655 100644 --- a/src/app/classes/stix/asset.ts +++ b/src/app/classes/stix/asset.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Asset extends StixObject { public name = ''; @@ -141,7 +141,7 @@ export class Asset extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/campaign.ts b/src/app/classes/stix/campaign.ts index c6483bdb8..47f69fbc5 100644 --- a/src/app/classes/stix/campaign.ts +++ b/src/app/classes/stix/campaign.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Campaign extends StixObject { public name = ''; @@ -161,7 +161,7 @@ export class Campaign extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/collection.ts b/src/app/classes/stix/collection.ts index ed9d5ac39..ce6836624 100644 --- a/src/app/classes/stix/collection.ts +++ b/src/app/classes/stix/collection.ts @@ -19,7 +19,7 @@ import { Tactic, Technique, } from '../stix'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; /** * auto-generated changelog/report about an import @@ -498,7 +498,7 @@ export class Collection extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/data-component.ts b/src/app/classes/stix/data-component.ts index 6bc177822..f698eca4f 100644 --- a/src/app/classes/stix/data-component.ts +++ b/src/app/classes/stix/data-component.ts @@ -4,7 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { DataSource } from './data-source'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DataComponent extends StixObject { public name = ''; @@ -133,7 +133,7 @@ export class DataComponent extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/data-source.ts b/src/app/classes/stix/data-source.ts index 24a215a96..f4a4d9687 100644 --- a/src/app/classes/stix/data-source.ts +++ b/src/app/classes/stix/data-source.ts @@ -4,7 +4,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable } from 'rxjs'; import { ValidationData } from '../serializable'; import { DataComponent } from './data-component'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DataSource extends StixObject { public name = ''; @@ -136,7 +136,7 @@ export class DataSource extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/detection-strategy.ts b/src/app/classes/stix/detection-strategy.ts index a1cfa794a..17c9f5b87 100644 --- a/src/app/classes/stix/detection-strategy.ts +++ b/src/app/classes/stix/detection-strategy.ts @@ -3,7 +3,7 @@ import { logger } from '../../utils/logger'; import { Observable } from 'rxjs'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { ValidationData } from '../serializable'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class DetectionStrategy extends StixObject { public name = ''; @@ -88,7 +88,7 @@ export class DetectionStrategy extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/group.ts b/src/app/classes/stix/group.ts index 761495329..52b188f59 100644 --- a/src/app/classes/stix/group.ts +++ b/src/app/classes/stix/group.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable } from 'rxjs'; import { ValidationData } from '../serializable'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Group extends StixObject { public name = ''; @@ -100,7 +100,7 @@ export class Group extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/identity.ts b/src/app/classes/stix/identity.ts index efcff67f0..c39dceb71 100644 --- a/src/app/classes/stix/identity.ts +++ b/src/app/classes/stix/identity.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Identity extends StixObject { public name: string; // identity name @@ -106,7 +106,7 @@ export class Identity extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/marking-definition.ts b/src/app/classes/stix/marking-definition.ts index 458e7e744..dc4cd1902 100644 --- a/src/app/classes/stix/marking-definition.ts +++ b/src/app/classes/stix/marking-definition.ts @@ -4,7 +4,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { Observable, of } from 'rxjs'; import { ValidationData } from '../serializable'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class MarkingDefinition extends StixObject { public name = ''; @@ -104,7 +104,7 @@ export class MarkingDefinition extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { diff --git a/src/app/classes/stix/matrix.ts b/src/app/classes/stix/matrix.ts index fde7e2e99..90b3eced7 100644 --- a/src/app/classes/stix/matrix.ts +++ b/src/app/classes/stix/matrix.ts @@ -4,7 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Tactic } from './tactic'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Matrix extends StixObject { public name = ''; @@ -90,7 +90,7 @@ export class Matrix extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { // TODO verify all tactics exist return this.base_validate(restAPIService, tempWorkflowState); diff --git a/src/app/classes/stix/mitigation.ts b/src/app/classes/stix/mitigation.ts index e2b0e942c..660763200 100644 --- a/src/app/classes/stix/mitigation.ts +++ b/src/app/classes/stix/mitigation.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Mitigation extends StixObject { public name = ''; @@ -94,7 +94,7 @@ export class Mitigation extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/note.ts b/src/app/classes/stix/note.ts index bbf0e80e5..b9a44ca68 100644 --- a/src/app/classes/stix/note.ts +++ b/src/app/classes/stix/note.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Note extends StixObject { public title = ''; @@ -88,7 +88,7 @@ export class Note extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index b05c0a071..23873f415 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -17,7 +17,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; export class Relationship extends StixObject { public source_ref = ''; @@ -486,7 +486,7 @@ export class Relationship extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { @@ -623,9 +623,9 @@ export class Relationship extends StixObject { ): Observable { if (!this.workflow) { // Initialize the workflow object if it doesn't exist - this.workflow = { state: 'work-in-progress' }; + this.workflow = { state: WorkflowStatus.WorkInProgress }; } - this.workflow.state = 'work-in-progress'; + this.workflow.state = WorkflowStatus.WorkInProgress; const postObservable = restAPIService.postRelationship(this); const subscription = postObservable.subscribe({ next: result => { @@ -694,9 +694,9 @@ export class Relationship extends StixObject { // Check if the workflow object exists if (!object.workflow) { // Initialize the workflow object if it doesn't exist - object.workflow = { state: 'work-in-progress' }; + object.workflow = { state: WorkflowStatus.WorkInProgress }; } - object.workflow.state = 'work-in-progress'; + object.workflow.state = WorkflowStatus.WorkInProgress; object.update(restAPIService).subscribe({ next: response => { console.log('Object updated successfully:', response); diff --git a/src/app/classes/stix/software.ts b/src/app/classes/stix/software.ts index 49be00804..2dda508ff 100644 --- a/src/app/classes/stix/software.ts +++ b/src/app/classes/stix/software.ts @@ -3,7 +3,7 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; type type_software = 'malware' | 'tool'; export class Software extends StixObject { @@ -142,7 +142,7 @@ export class Software extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/stix-object.ts b/src/app/classes/stix/stix-object.ts index 96e3831a5..2460b665f 100644 --- a/src/app/classes/stix/stix-object.ts +++ b/src/app/classes/stix/stix-object.ts @@ -12,18 +12,13 @@ import { AttackTypeToRoute, StixTypeToAttackType, } from 'src/app/utils/type-mappings'; -import { StixType, WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; import { v4 as uuid } from 'uuid'; import { logger } from '../../utils/logger'; import { ExternalReferences } from '../external-references'; import { Serializable, ValidationData } from '../serializable'; import { VersionNumber } from '../version-number'; -export type workflowStates = - | 'work-in-progress' - | 'awaiting-review' - | 'reviewed'; - export abstract class StixObject extends Serializable { public stixID: string; // STIX ID public type: string; // STIX type @@ -40,7 +35,7 @@ export abstract class StixObject extends Serializable { public object_marking_refs: string[] = []; //list of embedded relationships to marking_defs public abstract readonly supportsAttackID: boolean; // boolean to determine if object supports ATT&CK IDs - public tempWorkflowState: WorkflowState; + public tempWorkflowState: WorkflowStatusType; protected abstract get attackIDValidator(): { regex: string; // regex to validate the ID format: string; // format to display to user @@ -67,7 +62,7 @@ export abstract class StixObject extends Serializable { public version: VersionNumber; // version number of the object public external_references: ExternalReferences; public workflow: { - state: workflowStates; + state: WorkflowStatusType; created_by_user_account?: string; }; @@ -92,7 +87,7 @@ export abstract class StixObject extends Serializable { this.external_references = new ExternalReferences(); if (this.type !== 'x-mitre-collection') { this.workflow = { - state: 'work-in-progress', + state: WorkflowStatus.WorkInProgress, }; } this.description = ''; diff --git a/src/app/classes/stix/tactic.ts b/src/app/classes/stix/tactic.ts index 1209d4e4e..4f8879d18 100644 --- a/src/app/classes/stix/tactic.ts +++ b/src/app/classes/stix/tactic.ts @@ -4,7 +4,7 @@ import { ValidationData } from '../serializable'; import { StixObject } from './stix-object'; import { logger } from '../../utils/logger'; import { Technique } from './technique'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Tactic extends StixObject { public name = ''; @@ -105,7 +105,7 @@ export class Tactic extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState); } diff --git a/src/app/classes/stix/technique.ts b/src/app/classes/stix/technique.ts index 167bf68ab..17649200f 100644 --- a/src/app/classes/stix/technique.ts +++ b/src/app/classes/stix/technique.ts @@ -5,7 +5,7 @@ import { logger } from '../../utils/logger'; import { ValidationData } from '../serializable'; import { Relationship } from './relationship'; import { StixObject } from './stix-object'; -import { WorkflowState } from 'src/app/utils/types'; +import { WorkflowStatusType } from 'src/app/utils/types'; export class Technique extends StixObject { public name = ''; @@ -453,7 +453,7 @@ export class Technique extends StixObject { */ public validate( restAPIService: RestApiConnectorService, - tempWorkflowState?: WorkflowState + tempWorkflowState?: WorkflowStatusType ): Observable { return this.base_validate(restAPIService, tempWorkflowState).pipe( map(result => { 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 a17967085..415e98971 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 @@ -2,7 +2,6 @@ import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { - WorkflowStatus, MemberSyncStrategy, MemberSyncBehavior, ReleaseTrackType, @@ -11,6 +10,7 @@ import { 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'; @Component({ standalone: false, diff --git a/src/app/components/object-status/object-status.component.ts b/src/app/components/object-status/object-status.component.ts index 43269603e..cd8a006e6 100644 --- a/src/app/components/object-status/object-status.component.ts +++ b/src/app/components/object-status/object-status.component.ts @@ -9,7 +9,7 @@ import { EditorService } from 'src/app/services/editor/editor.service'; import { AddDialogComponent } from '../add-dialog/add-dialog.component'; import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; import { forkJoin } from 'rxjs'; -import { WorkflowStates } from 'src/app/utils/types'; +import { WorkflowStatusMap } from 'src/app/utils/types'; import { SaveDialogComponent } from '../save-dialog/save-dialog.component'; @Component({ @@ -22,7 +22,7 @@ export class ObjectStatusComponent implements OnInit { public loaded = false; public statusControl: FormControl; public select: SelectionModel; - public workflows = Object.entries(WorkflowStates); + public workflows = Object.entries(WorkflowStatusMap); public objects: StixObject[]; public object: StixObject; public relationships = []; diff --git a/src/app/components/save-dialog/save-dialog.component.ts b/src/app/components/save-dialog/save-dialog.component.ts index a29daad84..e58451b26 100644 --- a/src/app/components/save-dialog/save-dialog.component.ts +++ b/src/app/components/save-dialog/save-dialog.component.ts @@ -6,7 +6,11 @@ import { DetectionStrategy } from 'src/app/classes/stix'; import { StixObject } from 'src/app/classes/stix/stix-object'; import { VersionNumber } from 'src/app/classes/version-number'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { WorkflowState, WorkflowStates } from 'src/app/utils/types'; +import { + WorkflowStatus, + WorkflowStatusMap, + WorkflowStatusType, +} from 'src/app/utils/types'; import { logger } from '../../utils/logger'; @Component({ @@ -23,8 +27,8 @@ export class SaveDialogComponent implements OnInit { public nextMinorVersion: string; public patch_objects = []; public validation: ValidationData = null; - public newState: WorkflowState = 'work-in-progress'; - public workflows = Object.entries(WorkflowStates); + public newState = WorkflowStatus.WorkInProgress; + public workflows = Object.entries(WorkflowStatusMap); public analyticsToPatch = new Set(); // list of stix ids of analytics that need patching public get saveEnabled() { @@ -64,7 +68,8 @@ export class SaveDialogComponent implements OnInit { } ngOnInit(): void { - this.newState = this.config.initialWorkflowState || 'work-in-progress'; + this.newState = + this.config.initialWorkflowState || WorkflowStatus.WorkInProgress; if (this.config.object.attackType === 'detection-strategy') { const det = this.config.object as DetectionStrategy; const newAnalytics = new Set(det.analytics); @@ -279,5 +284,5 @@ export interface SaveDialogConfig { patchId?: string; // previous object ID to patch in LinkByID tags patchAnalytics?: Set; // previous list of analytics related to a detection strategy versionAlreadyIncremented: boolean; - initialWorkflowState?: WorkflowState; + initialWorkflowState?: WorkflowStatusType; } diff --git a/src/app/components/status-chip/status-chip.component.ts b/src/app/components/status-chip/status-chip.component.ts index d25fb9a31..1ac1924e5 100644 --- a/src/app/components/status-chip/status-chip.component.ts +++ b/src/app/components/status-chip/status-chip.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { WorkflowState, WorkflowStates } from '../../utils/types'; +import { WorkflowStatus, WorkflowStatusMap } from '../../utils/types'; @Component({ selector: 'app-status-chip', @@ -8,9 +8,9 @@ import { WorkflowState, WorkflowStates } from '../../utils/types'; styleUrls: ['./status-chip.component.scss'], }) export class StatusChipComponent { - @Input() status!: WorkflowState; + @Input() status!: WorkflowStatus; public get label(): string { - return WorkflowStates[this.status] ?? String(this.status); + return WorkflowStatusMap[this.status] ?? String(this.status); } } 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 f963110df..f4c4d11ac 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -9,9 +9,9 @@ import { ApiConnector } from '../api-connector'; import { ReleaseTrackSnapshot, ReleaseTrackType, - WorkflowStatusType, } from 'src/app/classes/release-tracks'; import { Paginated } from './rest-api-connector.service'; +import { WorkflowStatusType } from 'src/app/utils/types'; // ----------------------------------------------------------------------------- // Request Payload Definitions for Release Tracks API Requests diff --git a/src/app/utils/types.ts b/src/app/utils/types.ts index 50e02d2db..3374eb998 100644 --- a/src/app/utils/types.ts +++ b/src/app/utils/types.ts @@ -44,14 +44,20 @@ export type StixType = | 'x-mitre-analytic'; /** - * Workflow state definitions + * Workflow status definitions */ -export type WorkflowState = 'work-in-progress' | 'awaiting-review' | 'reviewed'; +export enum WorkflowStatus { + WorkInProgress = 'work-in-progress', + AwaitingReview = 'awaiting-review', + Reviewed = 'reviewed', +} -/** - * List of all workflow states - */ -export const WorkflowStates: Record = { +export type WorkflowStatusType = + | WorkflowStatus.WorkInProgress + | WorkflowStatus.AwaitingReview + | WorkflowStatus.Reviewed; + +export const WorkflowStatusMap: Record = { 'work-in-progress': 'work in progress', 'awaiting-review': 'awaiting review', 'reviewed': 'reviewed', From be32e4bd7237625df44b479777d2b92c9615e073 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 14:14:00 -0400 Subject: [PATCH 025/159] feat(release-tracks): release track workspace details tab --- src/app/app-routing.module.ts | 21 +- src/app/app.module.ts | 2 + .../release-track-card.component.html | 6 +- .../status-chip/status-chip.component.scss | 10 +- .../status-chip/status-chip.component.ts | 2 + .../release-management.component.ts | 4 +- .../release-track-page.component.html | 250 ++++++++++++++++++ .../release-track-page.component.scss | 83 ++++++ .../release-track-page.component.spec.ts | 22 ++ .../release-track-page.component.ts | 186 +++++++++++++ src/style/forms.scss | 21 +- src/style/theme.scss | 6 +- 12 files changed, 584 insertions(+), 29 deletions(-) create mode 100644 src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html create mode 100644 src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss create mode 100644 src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts create mode 100644 src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index f3a20208d..c160e9671 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ import { Role } from './classes/authn/role'; import { TeamsListPageComponent } from './views/dashboard-page/teams/teams-list-page/teams-list-page.component'; import { TeamsViewPageComponent } from './views/dashboard-page/teams/teams-view-page/teams-view-page.component'; import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; +import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; const editRoles = [Role.EDITOR, Role.TEAM_LEAD, Role.ADMIN]; @@ -62,7 +63,25 @@ const routes: Routes = [ title: 'Release Management', roles: [Role.ADMIN, Role.TEAM_LEAD], }, - component: ReleaseManagementComponent, + children: [ + { + path: '', + data: { + breadcrumb: 'release management', + title: 'Release Management', + }, + component: ReleaseManagementComponent, + }, + { + path: ':id', + data: { + breadcrumb: 'view release track', + editable: false, + title: 'Release Track', + }, + component: ReleaseTrackPageComponent, + }, + ], }, { path: 'teams', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 24b867c88..4ae3fbc06 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -232,6 +232,7 @@ import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-pag import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; import { ReleaseTrackCardComponent } from './components/release-track-card/release-track-card.component'; import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; +import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; import { StatusChipComponent } from './components/status-chip/status-chip.component'; export function initConfig(appConfigService: AppConfigService) { @@ -381,6 +382,7 @@ export function initConfig(appConfigService: AppConfigService) { DictionaryDiffComponent, ReleaseManagementComponent, NewTrackDialogComponent, + ReleaseTrackPageComponent, StatusChipComponent, ], exports: [ diff --git a/src/app/components/release-track-card/release-track-card.component.html b/src/app/components/release-track-card/release-track-card.component.html index 23e4f0c19..cd7195668 100644 --- a/src/app/components/release-track-card/release-track-card.component.html +++ b/src/app/components/release-track-card/release-track-card.component.html @@ -25,17 +25,17 @@
CANDIDATES
- {{ track.stats?.candidates || track.snapshot_count || 0 }} + {{ track.candidates?.length || 'TODO' }}
STAGED
-
{{ track.stats?.staged || 0 }}
+
{{ track.staged?.length || 'TODO' }}
MEMBERS
- {{ track.stats?.members || track.tagged_release_count || 0 }} + {{ track.members?.length || 'TODO' }}
diff --git a/src/app/components/status-chip/status-chip.component.scss b/src/app/components/status-chip/status-chip.component.scss index f235f241f..e7af04bd2 100644 --- a/src/app/components/status-chip/status-chip.component.scss +++ b/src/app/components/status-chip/status-chip.component.scss @@ -1,14 +1,16 @@ @use 'src/style/colors.scss' as colors; .status-chip { - .mat-mdc-chip.awaiting-review, - .awaiting-review { + .mat-mdc-chip-action-label { + font-size: 12px !important; + } + + .mat-mdc-chip.awaiting-review { background: colors.color('pending'); color: colors.on-color('pending'); } - .mat-mdc-chip.reviewed, - .reviewed { + .mat-mdc-chip.reviewed { background: colors.color('active'); color: colors.on-color('active'); } diff --git a/src/app/components/status-chip/status-chip.component.ts b/src/app/components/status-chip/status-chip.component.ts index 1ac1924e5..749caedfa 100644 --- a/src/app/components/status-chip/status-chip.component.ts +++ b/src/app/components/status-chip/status-chip.component.ts @@ -1,11 +1,13 @@ import { Component, Input } from '@angular/core'; import { WorkflowStatus, WorkflowStatusMap } from '../../utils/types'; +import { ViewEncapsulation } from '@angular/core'; @Component({ selector: 'app-status-chip', standalone: false, templateUrl: './status-chip.component.html', styleUrls: ['./status-chip.component.scss'], + encapsulation: ViewEncapsulation.None, }) export class StatusChipComponent { @Input() status!: WorkflowStatus; diff --git a/src/app/views/dashboard-page/release-management/release-management.component.ts b/src/app/views/dashboard-page/release-management/release-management.component.ts index 40b43199d..98385320c 100644 --- a/src/app/views/dashboard-page/release-management/release-management.component.ts +++ b/src/app/views/dashboard-page/release-management/release-management.component.ts @@ -12,6 +12,7 @@ import { ReleaseTrackType } from 'src/app/classes/release-tracks'; import { MatDialog } from '@angular/material/dialog'; import { NewTrackDialogComponent } from 'src/app/components/new-track-dialog/new-track-dialog.component'; import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { Router } from '@angular/router'; @Component({ selector: 'app-release-tracks-list', @@ -42,6 +43,7 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy { constructor( private connector: ReleaseTracksConnectorService, + private router: Router, private dialog: MatDialog ) {} @@ -68,7 +70,7 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy { } public onViewTrack(id: string): void { - this.viewTrack.emit(id); + this.router.navigate([`/dashboard/release-management/${id}`]); } private tracksWithComputedData(data: any[]): any[] { 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 new file mode 100644 index 000000000..1306ce205 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -0,0 +1,250 @@ +
+
+

+ {{ releaseTrack.name }} +

+
+ + + +
+
+ + + + + +
+
+
+
+
+
+ Candidates +  (in this draft) +
+
+

+ {{ releaseTrack.candidates?.length || 0 }} +

+
+
+
+
+
+
+
+
+ Staged +  (for next release) +
+
+

+ {{ releaseTrack.staged?.length || 0 }} +

+
+
+
+
+
+
+
+
+ Members +  (published) +
+
+

+ {{ releaseTrack.members?.length || 0 }} +

+
+
+
+
+
+ +
+
+ +
+
+
+

Candidates ({{ candidates.length }})

+
+ + +
+
+ + + +
+
No candidates
+
+ +
+
+
+
+
+ +
+ +
+
+
+

Staged ({{ staged.length }})

+
+ +
+
+ + + +
+
No staged objects
+
+ +
+
+
+
+
+ +
+ +
+
+
+

Members ({{ members.length }})

+
+ + + +
+
No published objects
+
+ +
+
+
+
+
+
+
+
+ + + + +
+
+ + {{ item.object_ref.split('-')[0] }}-{{ item.object_ref.split('-')[6] }} + + + ⪡ATT&CK ID⪢ + +
+ +
+
+ +
+ + +
+
+ + +
+
+
+
+ + +
+ TODO +
+
+ + +
+ TODO +
+
+ + +
+ TODO +
+
+
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 new file mode 100644 index 000000000..87d2c0362 --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss @@ -0,0 +1,83 @@ +@use '../../../../../style/colors' as colors; +// @use '../../../../../style/typography' as typography; +@use '../../../../../style/theme.scss'; + +.release-track-page { + .promote-color { + color: colors.color(success); + } + .view-color { + color: colors.color(info); + } + .demote-color { + color: colors.color(warn); + } + + .release-track-header { + display: flex; + justify-content: space-between; + margin-bottom: 18px; + } + .space-between { + width: 100%; + display: flex !important; + justify-content: space-between; + } + .labelled-box .content { + display: block; + .card-header { + display: flex; + justify-content: center; + align-items: center; + &.member-list { + justify-content: space-between !important; + margin-bottom: 12px; + } + h3 { + margin: 0; + } + } + .card-body:not(.list) { + display: flex; + justify-content: center; + } + } + + .small-buttons { + .mdc-button + .mdc-button { + margin-left: 6px; + } + .mdc-button { + height: 28px; + padding: 4px 8px; + .mat-mdc-outlined-button .mat-mdc-button-touch-target { + height: 32px !important; + } + } + .mat-icon { + margin-right: 4px; + } + } + + .candidate-card, .staged-card, .member-card { + margin: 12px 6px; + .mat-mdc-card-header { + padding: 12px 8px; + } + .mat-mdc-card-actions { + justify-content: space-between; + } + .card-title { + @extend .text-label; + font-weight: bold; + letter-spacing: normal; + } + .card-subtitle { + @extend .text-label; + display: block; + font-size: 14px; + font-weight: 500; + color: colors.on-color-deemphasis(light) !important; + } + } +} 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 new file mode 100644 index 000000000..47b5ea6de --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReleaseTrackPageComponent } from './release-track-page.component'; + +describe('ReleaseTrackPageComponent', () => { + let component: ReleaseTrackPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ReleaseTrackPageComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ReleaseTrackPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 000000000..ea5188f9a --- /dev/null +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -0,0 +1,186 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ReleaseTrackSnapshot } from 'src/app/classes/release-tracks'; +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 { StixType } from 'src/app/utils/types'; + +@Component({ + selector: 'app-release-track-page', + standalone: false, + templateUrl: './release-track-page.component.html', + styleUrls: ['./release-track-page.component.scss'], +}) +export class ReleaseTrackPageComponent implements OnInit { + public id = ''; + public releaseTrack: ReleaseTrackSnapshot | null = null; + + constructor( + private connector: ReleaseTracksConnectorService, + private breadcrumbService: BreadcrumbService, + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private restApiConnectorService: RestApiConnectorService + ) {} + + ngOnInit(): void { + this.route.params.subscribe(params => { + this.id = params.id; + if (this.id) this.getReleaseTrack(); + }); + } + + public get candidates(): any[] { + return this.releaseTrack?.candidates ?? []; + } + + public get staged(): any[] { + return this.releaseTrack?.staged ?? []; + } + + public get members(): any[] { + return this.releaseTrack?.members ?? []; + } + + public getReleaseTrack(): void { + const subscription = this.connector.getLatestSnapshot(this.id).subscribe({ + next: res => { + this.releaseTrack = res; + this.breadcrumbService.changeBreadcrumb( + this.route.snapshot, + this.releaseTrack!.name + ); + + // this.loadCandidates(); + }, + complete: () => { + subscription.unsubscribe(); + }, + }); + } + + /** + * Load candidates using ReleaseTracksConnectorService.listCandidates() + */ + // private loadCandidates(): void { + // if (!this.id) return; + // const sub = this.connector.listCandidates(this.id).subscribe({ + // next: res => { + // const list = (res && (res as any).data) ? (res as any).data : (Array.isArray(res) ? res : []); + // this._candidates = list as any[]; + // if (this.releaseTrack) this.releaseTrack.candidates = this._candidates; + // }, + // error: err => { + // console.error('Failed to list candidates for track', this.id, err); + // }, + // complete: () => sub.unsubscribe(), + // }); + // } + + public onAddCandidate(): void { + if (!this.releaseTrack) return; + + const selection = new SelectionModel(true); + + const sub = this.restApiConnectorService + .getAllObjects({ deserialize: true }) + .subscribe({ + next: results => { + const objects = (results as any).data || []; + + const dialogRef = this.dialog.open(AddDialogComponent, { + data: { + selectableObjects: objects, + select: selection, + type: 'all', + selectionType: 'many', + buttonLabel: 'Add', + title: 'Add candidates', + clearSelection: true, + }, + maxWidth: '70em', + minWidth: '40vw', + maxHeight: '75vh', + }); + + const closeSub = dialogRef.afterClosed().subscribe({ + next: result => { + if (!result) return; // user cancelled + + const objectRefs: any[] = selection.selected.map(stixId => { + const obj = objects.find( + (o: any) => + o.stixID === stixId || (o.stix && o.stix.id === stixId) + ); + const id = obj ? obj.stixID || obj.stix?.id : stixId; + let modified: string | undefined; + if (obj) { + if (obj.modified instanceof Date) + modified = obj.modified.toISOString(); + else if (obj.modified) modified = obj.modified; + else if (obj.stix && obj.stix.modified) + modified = obj.stix.modified; + } + return modified ? { id, modified } : id; + }); + + const apiSub = this.connector + .addCandidates(this.id, objectRefs) + .subscribe({ + next: () => { + // refresh snapshot from server so local state reflects saved candidates + this.getReleaseTrack(); + }, + error: err => { + console.error('Failed to add candidates', err); + }, + complete: () => apiSub.unsubscribe(), + }); + }, + complete: () => closeSub.unsubscribe(), + }); + }, + complete: () => sub.unsubscribe(), + }); + } + + public getViewUrl(stixId: string): string { + const stixType = stixId.split('-')[0] as StixType; + return `/${StixTypeToAttackType[stixType]}/${stixId}`; + } + + public onPromoteAll(): void { + // TODO: implement batch promote logic + console.log('onPromoteAll clicked'); + } + + public onPromote(item: any): void { + // TODO: implement promote single item + console.log('onPromote', item); + } + + public onDemoteAll(): void { + // TODO: implement batch demote logic + console.log('onDemoteAll clicked'); + } + + public onDemote(item: any): void { + // TODO: implement demote single item + console.log('onDemote', item); + } + + public onView(id: string): void { + this.router.navigate([this.getViewUrl(id)]); + } + + public onDiff(item: any): void { + // TODO: open diff modal for item + console.log('onDiff', item); + } +} diff --git a/src/style/forms.scss b/src/style/forms.scss index 663a6e9e3..a1f24307f 100644 --- a/src/style/forms.scss +++ b/src/style/forms.scss @@ -106,23 +106,10 @@ } .button-group { - // left border removed - .mat-mdc-button:not(:first-child), - .mat-mdc-raised-button:not(:first-child), - .mat-mdc-outlined-button:not(:first-child), - .mat-mdc-unelevated-button:not(:first-child) { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - border-left-width: 0px; - } - // right border radius removed - .mat-mdc-button:not(:last-child), - .mat-mdc-raised-button:not(:last-child), - .mat-mdc-outlined-button:not(:last-child), - .mat-mdc-unelevated-button:not(:last-child) { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } + display: flex; + gap: 8px; + align-items: center; + justify-content: center; } .mat-mdc-form-field-error { diff --git a/src/style/theme.scss b/src/style/theme.scss index 836476339..8dfe45e91 100644 --- a/src/style/theme.scss +++ b/src/style/theme.scss @@ -430,12 +430,12 @@ button.mat-mdc-outlined-button:disabled { } } -.mat-mdc-card .mat-mdc-card-header { +.mat-mdc-card .mat-mdc-card-actions { .light & { - background: colors.color-alternate(light); + border-top: 1px solid colors.color-alternate(light); } .dark & { - background: colors.color-alternate(dark); + border-top: 1px solid colors.color-alternate(dark); } } From 4174080fdee9b31b182be4bc530950c8a80f59c8 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 14:14:14 -0400 Subject: [PATCH 026/159] chore: formatting --- .../release-track-page.component.html | 58 +++++++++++-------- .../release-track-page.component.scss | 4 +- 2 files changed, 36 insertions(+), 26 deletions(-) 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 1306ce205..bf0f99cef 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 @@ -41,7 +41,7 @@

 (in this draft)

-

+

{{ releaseTrack.candidates?.length || 0 }}

@@ -56,7 +56,7 @@

 (for next release)

-

+

{{ releaseTrack.staged?.length || 0 }}

@@ -71,7 +71,7 @@

 (published)

-

+

{{ releaseTrack.members?.length || 0 }}

@@ -88,18 +88,24 @@

Candidates ({{ candidates.length }})

- -
- +
No candidates
@@ -108,7 +114,7 @@

Candidates ({{ candidates.length }})

memberTpl; context: { $implicit: item, - type: 'candidate' + type: 'candidate', } ">
@@ -124,14 +130,17 @@

Candidates ({{ candidates.length }})

Staged ({{ staged.length }})

-
- +
No staged objects
@@ -140,7 +149,7 @@

Staged ({{ staged.length }})

memberTpl; context: { $implicit: item, - type: 'staged' + type: 'staged', } ">
@@ -158,7 +167,7 @@

Members ({{ members.length }})

- +
No published objects
@@ -167,7 +176,7 @@

Members ({{ members.length }})

memberTpl; context: { $implicit: item, - type: 'member' + type: 'member', } ">
@@ -185,7 +194,9 @@

Members ({{ members.length }})

- {{ item.object_ref.split('-')[0] }}-{{ item.object_ref.split('-')[6] }} + {{ item.object_ref.split('-')[0] }}-{{ + item.object_ref.split('-')[6] + }} ⪡ATT&CK ID⪢ @@ -203,7 +214,8 @@

Members ({{ members.length }})

open_in_browser View -
- -
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 87d2c0362..9066059d2 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 @@ -59,7 +59,9 @@ } } - .candidate-card, .staged-card, .member-card { + .candidate-card, + .staged-card, + .member-card { margin: 12px 6px; .mat-mdc-card-header { padding: 12px 8px; From 58c125ac13748c01034fa8f707bc85ad75f8d6c2 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 14:25:33 -0400 Subject: [PATCH 027/159] fix(template): prevent errors when object is accessed before load --- .../release-track-page/release-track-page.component.html | 8 ++++---- .../release-track-page/release-track-page.component.ts | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) 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 bf0f99cef..edaeb47e7 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -1,7 +1,7 @@

- {{ releaseTrack.name }} + {{ releaseTrackName }}

- {{ releaseTrack.candidates?.length || 0 }} + {{ candidates.length || 0 }}

@@ -57,7 +57,7 @@

- {{ releaseTrack.staged?.length || 0 }} + {{ staged.length || 0 }}

@@ -72,7 +72,7 @@

- {{ releaseTrack.members?.length || 0 }} + {{ members.length || 0 }}

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 ea5188f9a..90afec249 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 @@ -36,6 +36,10 @@ export class ReleaseTrackPageComponent implements OnInit { }); } + public get releaseTrackName(): string { + return this.releaseTrack?.name ?? ''; + } + public get candidates(): any[] { return this.releaseTrack?.candidates ?? []; } From 2b5edb5e114cafb6218be2a88ef0ae7a4042e51b Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 15:28:35 -0400 Subject: [PATCH 028/159] feat(release-tracks): implement promote and demote functionality --- .../release-track-page.component.html | 2 +- .../release-track-page.component.ts | 66 +++++++++++++++---- 2 files changed, 56 insertions(+), 12 deletions(-) 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 edaeb47e7..f00367adb 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 @@ -235,7 +235,7 @@

Members ({{ members.length }})

*ngIf="type === 'staged'" class="demote-color" mat-stroked-button - (click)="onDemote(item.object_ref)"> + (click)="onDemote(item)"> arrow_back Demote 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 90afec249..12ec31987 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,7 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ReleaseTrackSnapshot } from 'src/app/classes/release-tracks'; -import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { + ReleaseTracksConnectorService, + StixObjectRef, +} 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'; @@ -159,24 +162,65 @@ export class ReleaseTrackPageComponent implements OnInit { return `/${StixTypeToAttackType[stixType]}/${stixId}`; } + public promote(objectIds: string[]): void { + if (!objectIds.length) return; + const sub = this.connector.promoteCandidates(this.id, objectIds).subscribe({ + next: () => { + this.getReleaseTrack(); + }, + error: err => { + console.error('Failed to promote candidates', err); + }, + complete: () => sub.unsubscribe(), + }); + } + public onPromoteAll(): void { - // TODO: implement batch promote logic - console.log('onPromoteAll clicked'); + if (!this.id || !this.releaseTrack) return; + const candidateIds: string[] = this.candidates + .map(c => c.object_ref) + .filter((r: any) => !!r); + this.promote(candidateIds); } - public onPromote(item: any): void { - // TODO: implement promote single item - console.log('onPromote', item); + public onPromote(candidateId: string): void { + if (!this.id || !candidateId) return; + this.promote([candidateId]); + } + + public demote(objectRefs: StixObjectRef[]): void { + if (!objectRefs.length) return; + const sub = this.connector.demoteStaged(this.id, objectRefs).subscribe({ + next: () => { + this.getReleaseTrack(); + }, + error: err => { + console.error('Failed to demote staged objects', err); + }, + complete: () => sub.unsubscribe(), + }); } public onDemoteAll(): void { - // TODO: implement batch demote logic - console.log('onDemoteAll clicked'); + if (!this.id || !this.releaseTrack) return; + const stagedRefs: StixObjectRef[] = this.staged + .map(s => { + return { + id: s.object_ref, + modified: s.object_modified, + }; + }) + .filter((s: any) => !!s); + this.demote(stagedRefs); } - public onDemote(item: any): void { - // TODO: implement demote single item - console.log('onDemote', item); + public onDemote(staged: any): void { + if (!this.id || !staged) return; + const stagedRef: StixObjectRef = { + id: staged.object_ref, + modified: staged.object_modified, + }; + this.demote([stagedRef]); } public onView(id: string): void { From 144377cc81f4791f80cea2cae3e65a58ae77a170 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 15:38:13 -0400 Subject: [PATCH 029/159] test(release-track-page): fix injector error for http client --- .../testing/mocks/rest-api-connector.mock.ts | 4 ++++ .../release-track-page.component.spec.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/app/testing/mocks/rest-api-connector.mock.ts b/src/app/testing/mocks/rest-api-connector.mock.ts index 1b5a4e7f7..a70a1bf5c 100644 --- a/src/app/testing/mocks/rest-api-connector.mock.ts +++ b/src/app/testing/mocks/rest-api-connector.mock.ts @@ -60,3 +60,7 @@ export function createMockRestApiConnector(config?: any): any { return { ...defaults, ...config }; } + +export function createMockReleaseTrackApiConnector(config?: any): any { + return { ...config }; +} 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 47b5ea6de..5e55bd650 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 @@ -1,14 +1,35 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReleaseTrackPageComponent } from './release-track-page.component'; +import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA } from '@angular/compiler'; +import { + createAsyncObservable, + createMockReleaseTrackApiConnector, + createPaginatedResponse, +} from 'src/app/testing/mocks/rest-api-connector.mock'; describe('ReleaseTrackPageComponent', () => { let component: ReleaseTrackPageComponent; let fixture: ComponentFixture; beforeEach(async () => { + const mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ + getAllUserAccounts: () => + createAsyncObservable(createPaginatedResponse([])), + }); + await TestBed.configureTestingModule({ declarations: [ReleaseTrackPageComponent], + providers: [ + { + provide: ReleaseTracksConnectorService, + useValue: mockReleaseTrackApiConnector, + }, + provideHttpClient(), + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ReleaseTrackPageComponent); From 2d35f0154a0af99b4933783e10f7781b4abefca5 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:56 -0400 Subject: [PATCH 030/159] test(release-track-page): add provider for ActivatedRoute --- .../release-track-page.component.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 5e55bd650..6a291c055 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 @@ -9,6 +9,8 @@ import { createMockReleaseTrackApiConnector, createPaginatedResponse, } from 'src/app/testing/mocks/rest-api-connector.mock'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; describe('ReleaseTrackPageComponent', () => { let component: ReleaseTrackPageComponent; @@ -27,6 +29,13 @@ describe('ReleaseTrackPageComponent', () => { provide: ReleaseTracksConnectorService, useValue: mockReleaseTrackApiConnector, }, + { + provide: ActivatedRoute, + useValue: { + params: of({}), + queryParams: of({}), + }, + }, provideHttpClient(), ], schemas: [NO_ERRORS_SCHEMA], From 043681ace47fd31b71415a34f5e16ecee3951da7 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 10:04:49 -0400 Subject: [PATCH 031/159] style(status-chip): keep chip label font size at 12px --- .../multiple-choice-dialog.component.html | 2 +- .../multiple-choice-dialog.component.ts | 9 +++------ .../components/status-chip/status-chip.component.scss | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html index 3434b8987..c8b7c1560 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.html @@ -5,7 +5,7 @@

{{ config.title }}

+ (click)="dialogRef.close(choice.value || choice.label)">

{{ choice.label }}

{{ choice.description }}

diff --git a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts index 5beaed46a..007bec550 100644 --- a/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts +++ b/src/app/components/multiple-choice-dialog/multiple-choice-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, Inject, ViewEncapsulation } from '@angular/core'; import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; @Component({ @@ -8,15 +8,11 @@ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; encapsulation: ViewEncapsulation.None, standalone: false, }) -export class MultipleChoiceDialogComponent implements OnInit { +export class MultipleChoiceDialogComponent { constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public config: MultipleChoiceDialogConfig ) {} - - ngOnInit(): void { - // intentionally left blank - } } export interface MultipleChoiceDialogConfig { @@ -24,6 +20,7 @@ export interface MultipleChoiceDialogConfig { description?: string; //additional explanation choices: { label: string; + value?: string; description?: string; }[]; } diff --git a/src/app/components/status-chip/status-chip.component.scss b/src/app/components/status-chip/status-chip.component.scss index e7af04bd2..a620d0f7c 100644 --- a/src/app/components/status-chip/status-chip.component.scss +++ b/src/app/components/status-chip/status-chip.component.scss @@ -1,7 +1,7 @@ @use 'src/style/colors.scss' as colors; .status-chip { - .mat-mdc-chip-action-label { + .mat-mdc-chip.mat-mdc-standard-chip .mat-mdc-chip-action-label { font-size: 12px !important; } From 152f3e71472b035fe4911bf75c11b50a218ed765 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 10:50:55 -0400 Subject: [PATCH 032/159] feat(release-tracks): add release track export downloads --- .../rest-api/release-tracks.service.ts | 55 +++++++-- .../release-track-page.component.html | 6 +- .../release-track-page.component.spec.ts | 107 ++++++++++++++++-- .../release-track-page.component.ts | 92 ++++++++++++++- 4 files changed, 241 insertions(+), 19 deletions(-) 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 f4c4d11ac..8e3763199 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -7,6 +7,7 @@ import { environment } from '../../../../environments/environment'; import { logger } from '../../../utils/logger'; import { ApiConnector } from '../api-connector'; import { + ExportFormatType, ReleaseTrackSnapshot, ReleaseTrackType, } from 'src/app/classes/release-tracks'; @@ -82,6 +83,12 @@ export interface ConfigPayload { auto_promote?: boolean; } +export interface ReleaseTrackSnapshotOptions { + format?: ExportFormatType; + include?: string; + [key: string]: any; +} + // ----------------------------------------------------------------------------- // Release Tracks API Connector Service // ----------------------------------------------------------------------------- @@ -101,6 +108,17 @@ export class ReleaseTracksConnectorService extends ApiConnector { super(snackbar); } + private buildHttpParams(options?: Record): HttpParams { + let params = new HttpParams(); + if (options) { + Object.keys(options).forEach(k => { + const v = options[k]; + if (v !== undefined && v !== null) params = params.set(k, String(v)); + }); + } + return params; + } + // ----------------------------------------------------------------------------- // Ephemeral // ----------------------------------------------------------------------------- @@ -214,15 +232,9 @@ export class ReleaseTracksConnectorService extends ApiConnector { */ public getLatestSnapshot( id: string, - options?: { format?: string; [key: string]: any } + options?: ReleaseTrackSnapshotOptions ): Observable { - let params = new HttpParams(); - if (options) { - Object.keys(options).forEach(k => { - const v = (options as any)[k]; - if (v !== undefined && v !== null) params = params.set(k, String(v)); - }); - } + const params = this.buildHttpParams(options); const url = `${this.apiUrl}/release-tracks/${id}`; return this.http.get(url, { params }).pipe( tap(result => @@ -234,6 +246,33 @@ export class ReleaseTracksConnectorService extends ApiConnector { ); } + /** + * GET /api/release-tracks/:id?format=:format + * Retrieve the latest snapshot in an export format without deserializing it. + * @param id Release track id + * @param format Export format + * @param options Additional query options + * @returns Observable formatted export payload + */ + public exportLatestSnapshot( + id: string, + format: ExportFormatType, + options?: Omit + ): Observable { + const params = this.buildHttpParams({ ...options, format }); + const url = `${this.apiUrl}/release-tracks/${id}`; + return this.http.get(url, { params }).pipe( + tap(result => + logger.log( + `retrieved latest snapshot export for track ${id} in ${format} format`, + result + ) + ), + catchError(this.handleError_raise()), + share() + ); + } + /** * POST /api/release-tracks/:id/meta * Update metadata for latest snapshot (creates new snapshot). 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 f00367adb..3de55e98a 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 @@ -4,15 +4,15 @@

{{ releaseTrackName }}

- - - 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 6a291c055..2716a80cf 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 @@ -2,25 +2,46 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReleaseTrackPageComponent } from './release-track-page.component'; import { ReleaseTracksConnectorService } from 'src/app/services/connectors/rest-api/release-tracks.service'; -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/compiler'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { createAsyncObservable, createMockReleaseTrackApiConnector, createPaginatedResponse, } from 'src/app/testing/mocks/rest-api-connector.mock'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { of } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; +import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; describe('ReleaseTrackPageComponent', () => { let component: ReleaseTrackPageComponent; let fixture: ComponentFixture; + let mockReleaseTrackApiConnector: any; + let mockDialog: any; + let mockRestApiConnector: any; beforeEach(async () => { - const mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ - getAllUserAccounts: () => - createAsyncObservable(createPaginatedResponse([])), + mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ + getLatestSnapshot: vi.fn(() => createAsyncObservable(null)), + exportLatestSnapshot: vi.fn(() => createAsyncObservable({})), }); + mockDialog = { + open: vi.fn(), + }; + mockRestApiConnector = { + getAllObjects: vi.fn(() => + createAsyncObservable(createPaginatedResponse([])) + ), + triggerBrowserDownload: vi.fn(), + }; + const mockBreadcrumbService = { + changeBreadcrumb: vi.fn(), + }; + const mockRouter = { + navigate: vi.fn(), + }; await TestBed.configureTestingModule({ declarations: [ReleaseTrackPageComponent], @@ -29,14 +50,30 @@ describe('ReleaseTrackPageComponent', () => { provide: ReleaseTracksConnectorService, useValue: mockReleaseTrackApiConnector, }, + { + provide: RestApiConnectorService, + useValue: mockRestApiConnector, + }, + { + provide: MatDialog, + useValue: mockDialog, + }, + { + provide: BreadcrumbService, + useValue: mockBreadcrumbService, + }, + { + provide: Router, + useValue: mockRouter, + }, { provide: ActivatedRoute, useValue: { params: of({}), queryParams: of({}), + snapshot: {}, }, }, - provideHttpClient(), ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -49,4 +86,60 @@ describe('ReleaseTrackPageComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should download the release track in the selected export format', () => { + const exportPayload = { type: 'bundle', objects: [] }; + mockDialog.open.mockReturnValue({ + afterClosed: () => of('bundle'), + }); + mockReleaseTrackApiConnector.exportLatestSnapshot.mockReturnValue( + of(exportPayload) + ); + component.id = 'release-track--123'; + component.releaseTrack = { name: 'Enterprise Release' } as any; + + component.onExport(); + + expect(mockDialog.open).toHaveBeenCalledWith( + MultipleChoiceDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + choices: expect.arrayContaining([ + expect.objectContaining({ + label: 'STIX Bundle', + value: 'bundle', + }), + ]), + }), + }) + ); + expect( + mockReleaseTrackApiConnector.exportLatestSnapshot + ).toHaveBeenCalledWith('release-track--123', 'bundle', { include: 'all' }); + expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( + exportPayload, + 'enterprise-release-latest-bundle.json' + ); + }); + + it('should not export when the dialog is dismissed', () => { + mockDialog.open.mockReturnValue({ + afterClosed: () => of(null), + }); + component.id = 'release-track--123'; + + component.onExport(); + + expect( + mockReleaseTrackApiConnector.exportLatestSnapshot + ).not.toHaveBeenCalled(); + }); + + it('should not open the export dialog without a release track id', () => { + component.id = ''; + + component.onExport(); + + expect(mockDialog.open).not.toHaveBeenCalled(); + }); }); 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 12ec31987..4bb57e385 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,6 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { ReleaseTrackSnapshot } from 'src/app/classes/release-tracks'; +import { + ExportFormat, + ExportFormatType, + ReleaseTrackSnapshot, +} from 'src/app/classes/release-tracks'; import { ReleaseTracksConnectorService, StixObjectRef, @@ -12,6 +16,8 @@ import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/re import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; import { StixType } from 'src/app/utils/types'; +import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; +import { take } from 'rxjs/operators'; @Component({ selector: 'app-release-track-page', @@ -231,4 +237,88 @@ export class ReleaseTrackPageComponent implements OnInit { // TODO: open diff modal for item console.log('onDiff', item); } + + public onExport(): void { + if (!this.id) return; + + const formatRef = this.dialog.open(MultipleChoiceDialogComponent, { + width: '30em', + autoFocus: false, + data: { + title: `Export latest release snapshot`, + choices: [ + { + label: 'STIX Bundle', + value: 'bundle', + description: + 'A standard STIX 2.1 JSON bundle containing all member objects of the release.', + }, + { + label: 'Snapshot', + value: 'snapshot', + description: + 'The raw release track snapshot, including tiers, version history, and configuration.', + }, + { + label: 'File System Store', + value: 'filesystemstore', + description: + 'A directory structure with individual STIX JSON files, suitable for Git releases.', + }, + { + label: 'Workbench Format', + value: 'workbench', + description: + 'A richer format including workflow statuses, metadata, and other Workbench-specific data.', + }, + ], + }, + }); + + formatRef + .afterClosed() + .pipe(take(1)) + .subscribe(format => { + if (!format) return; + this.downloadLatestReleaseTrack(format as ExportFormat); + }); + } + + private downloadLatestReleaseTrack(format: ExportFormatType): void { + this.connector + .exportLatestSnapshot(this.id, format, { include: 'all' }) + .pipe(take(1)) + .subscribe({ + next: result => { + this.restApiConnectorService.triggerBrowserDownload( + result, + this.getExportFilename(format) + ); + }, + error: err => { + console.error('Failed to export release track', err); + }, + }); + } + + private getExportFilename(format: ExportFormatType): string { + const name = this.releaseTrackName || this.id || 'release-track'; + const safeName = + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'release-track'; + return `${safeName}-latest-${format}.json`; + } + + public onDraft(): void { + // TODO: create draft snapshot + console.log('onDraft'); + } + + public onPreviewRelease(): void { + // TODO: open preview & release modal + console.log('onPreviewRelease'); + } } From 7e4c86b672266cad57299a5e3d9f0f74e23bdf1b Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 11:44:05 -0400 Subject: [PATCH 033/159] style(release-tracks): improve release lane layout and scrolling --- .../release-track-page.component.html | 57 ++-- .../release-track-page.component.scss | 289 +++++++++++++++++- 2 files changed, 307 insertions(+), 39 deletions(-) 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 3de55e98a..7c5e856c9 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -1,9 +1,9 @@
-

+

{{ releaseTrackName }}

-
+
-
+
-
+

Staged ({{ staged.length }})

@@ -142,7 +144,9 @@

Staged ({{ staged.length }})

-
No staged objects
+
+ No staged objects +
Staged ({{ staged.length }})
-
+
-
+

Members ({{ members.length }})

@@ -169,7 +173,9 @@

Members ({{ members.length }})

-
No published objects
+
+ No published objects +
- +
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 9066059d2..685ed76f4 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 @@ -1,6 +1,6 @@ +@use 'sass:color'; @use '../../../../../style/colors' as colors; -// @use '../../../../../style/typography' as typography; -@use '../../../../../style/theme.scss'; +@use '../../../../../style/globals'; .release-track-page { .promote-color { @@ -15,71 +15,330 @@ .release-track-header { display: flex; + align-items: flex-start; justify-content: space-between; + gap: 16px; margin-bottom: 18px; + + h1 { + margin: 0; + font-size: 28px; + line-height: 34px; + } + } + + .release-track-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + } + + .release-track-board { + display: flex; + flex-direction: column; + gap: 16px; + height: calc(100dvh - 148px); + min-height: 0; + } + + .summary-grid, + .release-columns { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + } + + .summary-grid { + flex: 0 0 auto; + } + + .release-columns { + flex: 1 1 auto; + align-items: stretch; + min-height: 0; + } + + .summary-item, + .release-column { + min-width: 0; + min-height: 0; } + + .release-column { + display: flex; + align-self: stretch; + } + + .summary-card, + .track-panel { + height: 100%; + } + + .track-panel { + width: 100%; + min-height: 0; + } + .space-between { width: 100%; display: flex !important; justify-content: space-between; + align-items: flex-start; + gap: 12px; + min-width: 0; } - .labelled-box .content { + + .labelled-box > .content { display: block; + .card-header { display: flex; - justify-content: center; align-items: center; + gap: 2px; + min-width: 0; + &.member-list { - justify-content: space-between !important; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; + + h3 { + line-height: 32px; + } } + h3 { margin: 0; } } + .card-body:not(.list) { display: flex; - justify-content: center; + align-items: center; + } + } + + .summary-card > .content { + position: relative; + overflow: hidden; + min-height: 104px; + padding: 18px 20px 16px; + box-shadow: inset 0 4px 0 colors.border-color(light); + + .card-header { + justify-content: flex-start; + line-height: 20px; + } + + h2 { + margin: 12px 0 0; + font-size: 30px; + font-weight: 800; + line-height: 36px; + } + } + + .candidates-summary > .content, + .candidates-panel > .content { + box-shadow: inset 0 4px 0 colors.color(info); + } + + .staged-summary > .content, + .staged-panel > .content { + box-shadow: inset 0 4px 0 colors.color(success); + } + + .members-summary > .content, + .members-panel > .content { + box-shadow: inset 0 4px 0 colors.border-color(light); + } + + .track-panel > .content { + display: flex; + flex-direction: column; + align-items: stretch; + height: 100%; + min-height: 0; + padding: 14px 12px 16px; + overflow: hidden; + + .card-body.list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 12px; + min-height: 0; + padding-top: 12px; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; } } .small-buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + .mdc-button + .mdc-button { - margin-left: 6px; + margin-left: 0; } + .mdc-button { height: 28px; - padding: 4px 8px; - .mat-mdc-outlined-button .mat-mdc-button-touch-target { + min-width: 0; + padding: 0 8px; + + .mat-mdc-button-touch-target { height: 32px !important; } } + .mat-icon { + width: 18px; + height: 18px; margin-right: 4px; + font-size: 18px; + line-height: 18px; } } - .candidate-card, - .staged-card, - .member-card { - margin: 12px 6px; + .empty-state { + border: 1px dashed; + border-radius: 4px; + padding: 18px 12px; + font-size: 14px; + line-height: 20px; + text-align: center; + + .light & { + color: colors.on-color-deemphasis(light); + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.02); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + } + } + + .release-object-card { + margin: 0; + overflow: hidden; + border-radius: 4px; + box-shadow: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; + + .light & { + background: color.mix(white, colors.color(light), 72%); + border-color: colors.border-color(light); + } + + .dark & { + background: colors.color-alternate(dark); + border-color: colors.border-color(dark); + } + + &:hover { + transform: translateY(-1px); + + .light & { + box-shadow: 0 2px 8px rgba(colors.color(mitre-black), 0.1); + } + + .dark & { + box-shadow: 0 2px 8px rgba(black, 0.28); + } + } + .mat-mdc-card-header { - padding: 12px 8px; + padding: 14px 12px 12px; } + .mat-mdc-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; justify-content: space-between; + padding: 10px 8px !important; + min-height: 44px; + + .small-buttons:last-child { + margin-left: auto; + } } + .card-title { @extend .text-label; + min-width: 0; + padding-left: 10px; + border-left: 3px solid colors.border-color(light); font-weight: bold; letter-spacing: normal; + line-height: 20px; + overflow-wrap: anywhere; + } + + &.is-candidate .card-title { + border-left-color: colors.color(info); } + + &.is-staged .card-title { + border-left-color: colors.color(success); + } + + &.is-member .card-title { + border-left-color: colors.border-color(light); + } + .card-subtitle { @extend .text-label; display: block; font-size: 14px; font-weight: 500; - color: colors.on-color-deemphasis(light) !important; + line-height: 18px; + + .light & { + color: colors.on-color-deemphasis(light) !important; + } + + .dark & { + color: colors.on-color-deemphasis(dark) !important; + } + } + } + + @media (max-width: 960px) { + .release-track-board { + height: auto; + } + + .summary-grid, + .release-columns { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } + + .track-panel > .content { + height: clamp(360px, calc(100dvh - 360px), 720px); + } + } + + @media (max-width: 720px) { + .release-track-header { + flex-direction: column; + align-items: stretch; + } + + .release-track-actions { + justify-content: flex-start; } } } From 68d7d5575b6bb18b46330955ea487642cfa79b36 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 13:52:03 -0400 Subject: [PATCH 034/159] feat(release-tracks): add workflow review board --- src/app/app.module.ts | 4 + .../release-track-review-board.component.html | 47 ++++ .../release-track-review-board.component.scss | 197 ++++++++++++++ .../release-track-review-board.component.ts | 113 ++++++++ .../release-track-review-card.component.html | 65 +++++ .../release-track-review-card.component.scss | 254 ++++++++++++++++++ .../release-track-review-card.component.ts | 110 ++++++++ .../release-track-page.component.html | 101 +++---- .../release-track-page.component.scss | 104 +------ .../release-track-page.component.spec.ts | 129 +++++++++ .../release-track-page.component.ts | 96 ++++++- 11 files changed, 1061 insertions(+), 159 deletions(-) create mode 100644 src/app/components/release-track-review-board/release-track-review-board.component.html create mode 100644 src/app/components/release-track-review-board/release-track-review-board.component.scss create mode 100644 src/app/components/release-track-review-board/release-track-review-board.component.ts create mode 100644 src/app/components/release-track-review-card/release-track-review-card.component.html create mode 100644 src/app/components/release-track-review-card/release-track-review-card.component.scss create mode 100644 src/app/components/release-track-review-card/release-track-review-card.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4ae3fbc06..0b4b37378 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -231,6 +231,8 @@ import { DictionaryDiffComponent } from './components/stix/dictionary-property/d import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-page-tabs.component'; import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; import { ReleaseTrackCardComponent } from './components/release-track-card/release-track-card.component'; +import { ReleaseTrackReviewCardComponent } from './components/release-track-review-card/release-track-review-card.component'; +import { ReleaseTrackReviewBoardComponent } from './components/release-track-review-board/release-track-review-board.component'; import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; import { StatusChipComponent } from './components/status-chip/status-chip.component'; @@ -469,6 +471,8 @@ export function initConfig(appConfigService: AppConfigService) { OverlayModule, MatAutocompleteModule, ReleaseTrackCardComponent, + ReleaseTrackReviewCardComponent, + ReleaseTrackReviewBoardComponent, ], providers: [ AppConfigService, diff --git a/src/app/components/release-track-review-board/release-track-review-board.component.html b/src/app/components/release-track-review-board/release-track-review-board.component.html new file mode 100644 index 000000000..7c3fb7ea3 --- /dev/null +++ b/src/app/components/release-track-review-board/release-track-review-board.component.html @@ -0,0 +1,47 @@ +
+
+ +
+

{{ lane.title }} ({{ laneItems.length }})

+ + +
+ + + +
+
+ {{ lane.emptyLabel }} +
+ + + +
+
+
+
diff --git a/src/app/components/release-track-review-board/release-track-review-board.component.scss b/src/app/components/release-track-review-board/release-track-review-board.component.scss new file mode 100644 index 000000000..da1aed167 --- /dev/null +++ b/src/app/components/release-track-review-board/release-track-review-board.component.scss @@ -0,0 +1,197 @@ +@use 'sass:color'; +@use '../../../style/colors' as colors; + +:host { + display: block; +} + +.review-board { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + height: calc(100dvh - 148px); + min-height: 0; +} + +.review-lane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid; + border-radius: 6px; + padding: 16px; + overflow: hidden; + + .light & { + background: rgba(colors.color(mitre-black), 0.02); + border-color: colors.border-color(light); + } + + .dark & { + background: rgba(colors.color(mitre-black), 0.18); + border-color: colors.border-color(dark); + } +} + +.review-lane-work-in-progress { + .review-lane-header h3 { + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } + } +} + +.review-lane-awaiting-review { + border-color: color.mix(colors.color(warn), colors.color(spark-yellow), 70%); + + .review-lane-header h3 { + color: colors.color(pending); + } +} + +.review-lane-reviewed { + border-color: colors.color(success); + + .review-lane-header h3 { + color: colors.color(success); + } +} + +.review-lane-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 32px; + margin-bottom: 12px; + + h3 { + margin: 0; + overflow: hidden; + font-size: 19px; + font-weight: 800; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.bulk-review-button { + flex: 0 0 auto; + height: 30px; + border-color: color.mix( + colors.color(warn), + colors.color(spark-yellow), + 70% + ) !important; + color: colors.color(pending) !important; + font-size: 12px; + font-weight: 800; + + .mat-icon { + width: 16px; + height: 16px; + margin-right: 4px; + font-size: 16px; + line-height: 16px; + } + + .mat-mdc-button-touch-target { + height: 32px !important; + } +} + +.review-search { + width: 100%; + height: 24px; + border: 0; + border-radius: 5px; + padding: 0 8px; + font: inherit; + font-size: 14px; + line-height: 24px; + outline: none; + + .light & { + background: rgba(colors.color(mitre-black), 0.07); + color: colors.on-color(light); + + &::placeholder { + color: colors.on-color-deemphasis(light); + } + } + + .dark & { + background: color.mix(colors.color(mitre-silver), colors.color(dark), 12%); + color: colors.on-color(dark); + + &::placeholder { + color: colors.on-color-deemphasis(dark); + } + } + + &:focus { + box-shadow: 0 0 0 2px rgba(colors.color(info), 0.45); + } +} + +.review-lane-list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + padding-top: 16px; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.review-empty-state { + border: 1px dashed; + border-radius: 5px; + padding: 20px 12px; + font-size: 14px; + line-height: 20px; + text-align: center; + + .light & { + color: colors.on-color-deemphasis(light); + border-color: colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.02); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + border-color: colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + } +} + +@media (max-width: 1100px) { + .review-board { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + height: auto; + } + + .review-lane { + min-height: 420px; + } +} + +@media (max-width: 720px) { + .review-board { + grid-template-columns: 1fr; + gap: 16px; + } + + .review-lane-header { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/app/components/release-track-review-board/release-track-review-board.component.ts b/src/app/components/release-track-review-board/release-track-review-board.component.ts new file mode 100644 index 000000000..09872bfce --- /dev/null +++ b/src/app/components/release-track-review-board/release-track-review-board.component.ts @@ -0,0 +1,113 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { + ReleaseTrackReviewCardComponent, + ReleaseTrackReviewItem, +} from 'src/app/components/release-track-review-card/release-track-review-card.component'; +import { WorkflowStatus, WorkflowStatusType } from 'src/app/utils/types'; + +interface ReleaseTrackReviewLane { + status: WorkflowStatusType; + title: string; + modifier: string; + searchPlaceholder: string; + emptyLabel: string; +} + +@Component({ + selector: 'app-release-track-review-board', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatIconModule, + ReleaseTrackReviewCardComponent, + ], + templateUrl: './release-track-review-board.component.html', + styleUrls: ['./release-track-review-board.component.scss'], +}) +export class ReleaseTrackReviewBoardComponent { + @Input() items: ReleaseTrackReviewItem[] = []; + + @Output() viewObject = new EventEmitter(); + @Output() diffObject = new EventEmitter(); + @Output() bulkReview = new EventEmitter(); + + public readonly WorkflowStatus = WorkflowStatus; + + public readonly lanes: ReleaseTrackReviewLane[] = [ + { + status: WorkflowStatus.WorkInProgress, + title: 'Work in Progress', + modifier: 'work-in-progress', + searchPlaceholder: 'Search WIP...', + emptyLabel: 'No work in progress', + }, + { + status: WorkflowStatus.AwaitingReview, + title: 'Awaiting Review', + modifier: 'awaiting-review', + searchPlaceholder: 'Search Awaiting Review...', + emptyLabel: 'No objects awaiting review', + }, + { + status: WorkflowStatus.Reviewed, + title: 'Reviewed / Ready', + modifier: 'reviewed', + searchPlaceholder: 'Search Reviewed...', + emptyLabel: 'No reviewed objects', + }, + ]; + + public searchTerms: Record = { + [WorkflowStatus.WorkInProgress]: '', + [WorkflowStatus.AwaitingReview]: '', + [WorkflowStatus.Reviewed]: '', + }; + + public getLaneItems(lane: ReleaseTrackReviewLane): ReleaseTrackReviewItem[] { + const query = this.searchTerms[lane.status].trim().toLowerCase(); + return this.items + .filter( + item => + (item.object_status || WorkflowStatus.WorkInProgress) === lane.status + ) + .filter(item => !query || this.searchText(item).includes(query)); + } + + public isReviewLane(lane: ReleaseTrackReviewLane): boolean { + return lane.status === WorkflowStatus.AwaitingReview; + } + + public onBulkReview(items: ReleaseTrackReviewItem[]): void { + this.bulkReview.emit(items); + } + + public trackByObjectRef( + _index: number, + item: ReleaseTrackReviewItem + ): string { + return `${item.object_ref}-${item.object_modified || ''}`; + } + + private searchText(item: ReleaseTrackReviewItem): string { + try { + return JSON.stringify(item).toLowerCase(); + } catch { + return [ + item.object_ref, + item.object_status, + item.name, + item.description, + item.attackID, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + } + } +} diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.html b/src/app/components/release-track-review-card/release-track-review-card.component.html new file mode 100644 index 000000000..3a54db25a --- /dev/null +++ b/src/app/components/release-track-review-card/release-track-review-card.component.html @@ -0,0 +1,65 @@ + + +
+
+
+ {{ title }} +
+
+ {{ subtitle }} +
+
+ + +
+
+ + +

+ {{ description }} +

+
+ + +
+ + +
+ + +
+
diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.scss b/src/app/components/release-track-review-card/release-track-review-card.component.scss new file mode 100644 index 000000000..cc9a0809b --- /dev/null +++ b/src/app/components/release-track-review-card/release-track-review-card.component.scss @@ -0,0 +1,254 @@ +@use 'sass:color'; +@use '../../../style/colors' as colors; + +.review-object-card { + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; + border-radius: 6px; + box-shadow: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; + + .light & { + background: color.mix(white, colors.color(light), 76%); + border-color: colors.border-color(light); + } + + .dark & { + background: colors.color-alternate(dark); + border-color: colors.border-color(dark); + } + + &:hover { + transform: translateY(-1px); + + .light & { + box-shadow: 0 2px 8px rgba(colors.color(mitre-black), 0.12); + } + + .dark & { + box-shadow: 0 2px 8px rgba(black, 0.3); + } + } + + .mat-mdc-card-header { + display: block; + padding: 16px 16px 0; + } + + .mat-mdc-card-content { + padding: 0px 16px 16px; + } + + .mat-mdc-card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 50px; + margin-top: auto; + padding: 10px 16px !important; + + .light & { + border-top: 1px solid colors.border-color(light); + background: rgba(colors.color(mitre-black), 0.03); + } + + .dark & { + border-top: 1px solid colors.border-color(dark); + background: rgba(colors.color(mitre-silver), 0.04); + } + } +} + +.review-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; + margin-bottom: 12px; +} + +.review-card-title-group { + min-width: 0; + padding-left: 10px; + border-left: 3px solid colors.border-color(light); +} + +.status-work-in-progress .review-card-title-group { + border-left-color: colors.color(error); +} + +.status-awaiting-review .review-card-title-group { + border-left-color: colors.color(pending); +} + +.status-reviewed .review-card-title-group { + border-left-color: colors.color(success); +} + +.review-card-title, +.review-card-subtitle, +.review-description { + overflow: hidden; + text-overflow: ellipsis; +} + +.review-card-title { + display: -webkit-box; + font-size: 16px; + font-weight: 800; + line-height: 20px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + + .light & { + color: colors.on-color(light); + } + + .dark & { + color: colors.on-color(dark); + } +} + +.review-card-subtitle { + margin-top: 2px; + font-size: 13px; + line-height: 17px; + white-space: nowrap; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } +} + +.review-version { + flex: 0 0 auto; + border: 1px solid colors.color(info); + border-radius: 999px; + padding: 1px 8px; + color: colors.color(info); + font-size: 12px; + font-weight: 700; + line-height: 17px; + + .light & { + background: rgba(colors.color(info), 0.08); + } + + .dark & { + background: rgba(colors.color(info), 0.16); + } +} + +.review-description { + display: -webkit-box; + min-height: 66px; + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 22px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + + .light & { + color: colors.on-color(light); + } + + .dark & { + color: colors.on-color(dark); + } +} + +.review-meta, +.review-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.review-meta { + min-width: 0; + font-size: 12px; + line-height: 18px; + + .light & { + color: colors.on-color-deemphasis(light); + } + + .dark & { + color: colors.on-color-deemphasis(dark); + } +} + +.review-avatar { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: colors.color(error); + color: white; + font-size: 10px; + font-weight: 800; + line-height: 20px; +} + +.status-awaiting-review .review-avatar { + background: #c84d84; +} + +.status-reviewed .review-avatar { + background: colors.color(info); +} + +.review-modified { + min-width: 0; + white-space: nowrap; +} + +.review-actions { + .mdc-button { + height: 28px; + min-width: 0; + padding: 0 10px; + } + + .mat-mdc-button-touch-target { + height: 32px !important; + } + + .mat-icon { + width: 16px; + height: 16px; + margin-right: 4px; + font-size: 16px; + line-height: 16px; + } +} + +.view-button { + color: colors.color(info) !important; +} + +.review-approve-button { + width: calc(100% - 16px); + height: 34px; + margin: 8px; + border-radius: 5px; + background: color.mix(colors.color(warn), #d47a00, 64%) !important; + color: white !important; + font-weight: 800; +} diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.ts b/src/app/components/release-track-review-card/release-track-review-card.component.ts new file mode 100644 index 000000000..998643e94 --- /dev/null +++ b/src/app/components/release-track-review-card/release-track-review-card.component.ts @@ -0,0 +1,110 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; +import { StixType, WorkflowStatusType } from 'src/app/utils/types'; + +export interface ReleaseTrackReviewItem { + object_ref: string; + object_modified?: Date | string; + object_status?: WorkflowStatusType; + object_added_at?: Date | string; + object_added_by?: string; + object_staged_at?: Date | string; + object_staged_by?: string; + [key: string]: any; +} + +@Component({ + selector: 'app-release-track-review-card', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatTooltipModule, + ], + templateUrl: './release-track-review-card.component.html', + styleUrls: ['./release-track-review-card.component.scss'], +}) +export class ReleaseTrackReviewCardComponent { + @Input({ required: true }) item!: ReleaseTrackReviewItem; + @Input() cardType: string | null = null; + @Input() laneStatus: WorkflowStatusType | null = null; + @Input() showDescription = true; + @Input() showDiff = true; + @Input() showModifiedMeta = true; + + @Output() viewObject = new EventEmitter(); + @Output() diffObject = new EventEmitter(); + + public get title(): string { + return this.item?.name || this.fallbackObjectLabel; + } + + public get subtitle(): string { + return this.item?.attackId || '<>'; + } + + public get description(): string { + const description = this.item?.description || 'No description available.'; + return this.firstParagraph(description); + } + + public get modified(): Date | string | null { + return this.item?.object_modified || null; + } + + public get modifiedByInitials(): string { + const value = this.item?.modified_by_user || '?'; + const parts = value.split(/\s+/).filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + return value.slice(0, 2).toUpperCase(); + } + + public get cardClasses(): Record { + return { + [`status-${this.laneStatus}`]: !!this.laneStatus, + [`is-${this.cardType}`]: !!this.cardType, + 'has-description': this.showDescription, + }; + } + + public onView(): void { + this.viewObject.emit(this.item); + } + + public onDiff(): void { + this.diffObject.emit(this.item); + } + + private get fallbackObjectLabel(): string { + const [type, id] = (this.item?.object_ref || '').split('--'); + if (!type || !id) return this.item?.object_ref || 'Unknown object'; + return `${this.toDisplayLabel(type as StixType)} ${id.slice(0, 8)}`; + } + + private toDisplayLabel(value: StixType): string { + if (!value) return ''; + return StixTypeToAttackType[value] + .replace(/-/g, ' ') + .replace(/\b\w/g, char => char.toUpperCase()); + } + + private firstParagraph(value: string): string { + const [paragraph] = value + .replace(/\r\n/g, '\n') + .split(/\n\s*\n|\n/) + .map(part => part.trim()) + .filter(Boolean); + return paragraph || 'No description available.'; + } +} 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 7c5e856c9..aa07c6f21 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 @@ -195,66 +195,53 @@

Members ({{ members.length }})

- - -
-
- - {{ item.object_ref.split('-')[0] }}-{{ - item.object_ref.split('-')[6] - }} - - - ⪡ATT&CK ID⪢ - -
- -
-
- -
- - -
-
- - -
-
-
+ + + +
+ + +
+
-
TODO
+
+ + +
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 685ed76f4..f46fe80ae 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 @@ -1,6 +1,4 @@ -@use 'sass:color'; @use '../../../../../style/colors' as colors; -@use '../../../../../style/globals'; .release-track-page { .promote-color { @@ -34,6 +32,10 @@ gap: 8px; } + .review-tpl-wrapper { + margin-top: 20px; + } + .release-track-board { display: flex; flex-direction: column; @@ -80,15 +82,6 @@ min-height: 0; } - .space-between { - width: 100%; - display: flex !important; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - min-width: 0; - } - .labelled-box > .content { display: block; @@ -227,95 +220,6 @@ } } - .release-object-card { - margin: 0; - overflow: hidden; - border-radius: 4px; - box-shadow: none; - transition: - border-color 120ms ease, - box-shadow 120ms ease, - transform 120ms ease; - - .light & { - background: color.mix(white, colors.color(light), 72%); - border-color: colors.border-color(light); - } - - .dark & { - background: colors.color-alternate(dark); - border-color: colors.border-color(dark); - } - - &:hover { - transform: translateY(-1px); - - .light & { - box-shadow: 0 2px 8px rgba(colors.color(mitre-black), 0.1); - } - - .dark & { - box-shadow: 0 2px 8px rgba(black, 0.28); - } - } - - .mat-mdc-card-header { - padding: 14px 12px 12px; - } - - .mat-mdc-card-actions { - display: flex; - flex-wrap: wrap; - gap: 8px; - justify-content: space-between; - padding: 10px 8px !important; - min-height: 44px; - - .small-buttons:last-child { - margin-left: auto; - } - } - - .card-title { - @extend .text-label; - min-width: 0; - padding-left: 10px; - border-left: 3px solid colors.border-color(light); - font-weight: bold; - letter-spacing: normal; - line-height: 20px; - overflow-wrap: anywhere; - } - - &.is-candidate .card-title { - border-left-color: colors.color(info); - } - - &.is-staged .card-title { - border-left-color: colors.color(success); - } - - &.is-member .card-title { - border-left-color: colors.border-color(light); - } - - .card-subtitle { - @extend .text-label; - display: block; - font-size: 14px; - font-weight: 500; - line-height: 18px; - - .light & { - color: colors.on-color-deemphasis(light) !important; - } - - .dark & { - color: colors.on-color-deemphasis(dark) !important; - } - } - } - @media (max-width: 960px) { .release-track-board { height: auto; 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 2716a80cf..4be518ac8 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 @@ -26,6 +26,7 @@ describe('ReleaseTrackPageComponent', () => { mockReleaseTrackApiConnector = createMockReleaseTrackApiConnector({ getLatestSnapshot: vi.fn(() => createAsyncObservable(null)), exportLatestSnapshot: vi.fn(() => createAsyncObservable({})), + reviewCandidates: vi.fn(() => createAsyncObservable({})), }); mockDialog = { open: vi.fn(), @@ -142,4 +143,132 @@ describe('ReleaseTrackPageComponent', () => { expect(mockDialog.open).not.toHaveBeenCalled(); }); + + it('should combine candidates and staged objects for review lanes', () => { + component.releaseTrack = { + candidates: [ + { + object_ref: 'attack-pattern--candidate', + object_status: 'awaiting-review', + }, + ], + staged: [ + { + object_ref: 'attack-pattern--staged', + object_status: 'reviewed', + }, + ], + } as any; + + expect(component.reviewItems).toEqual([ + expect.objectContaining({ + object_ref: 'attack-pattern--candidate', + release_track_tier: 'candidate', + }), + expect.objectContaining({ + object_ref: 'attack-pattern--staged', + release_track_tier: 'staged', + }), + ]); + }); + + it('should review and approve a single awaiting-review candidate', () => { + const refreshSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + component.id = 'release-track--123'; + + component.onReviewAndApprove({ + object_ref: 'attack-pattern--123', + object_modified: new Date('2024-04-20T00:00:00.000Z'), + }); + + expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( + 'release-track--123', + { + from: 'awaiting-review', + to: 'reviewed', + object_refs: [ + { + id: 'attack-pattern--123', + modified: '2024-04-20T00:00:00.000Z', + }, + ], + } + ); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should bulk review all awaiting-review candidates', () => { + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + component.id = 'release-track--123'; + + component.onBulkReviewAll([ + { + object_ref: 'attack-pattern--123', + object_modified: '2024-04-20T00:00:00.000Z', + }, + { + object_ref: 'x-mitre-tactic--456', + }, + ]); + + expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( + 'release-track--123', + { + from: 'awaiting-review', + to: 'reviewed', + object_refs: [ + { + id: 'attack-pattern--123', + modified: '2024-04-20T00:00:00.000Z', + }, + 'x-mitre-tactic--456', + ], + } + ); + }); + + it('should not send staged objects to the candidate review endpoint', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => null); + mockReleaseTrackApiConnector.reviewCandidates.mockReturnValue(of({})); + component.id = 'release-track--123'; + + component.onBulkReviewAll([ + { + object_ref: 'attack-pattern--candidate', + object_modified: '2024-04-20T00:00:00.000Z', + release_track_tier: 'candidate', + }, + { + object_ref: 'attack-pattern--staged', + object_modified: '2024-04-21T00:00:00.000Z', + release_track_tier: 'staged', + }, + ]); + + expect(mockReleaseTrackApiConnector.reviewCandidates).toHaveBeenCalledWith( + 'release-track--123', + { + from: 'awaiting-review', + to: 'reviewed', + object_refs: [ + { + id: 'attack-pattern--candidate', + modified: '2024-04-20T00:00:00.000Z', + }, + ], + } + ); + expect(consoleSpy).toHaveBeenCalledWith( + 'onBulkReviewAll staged objects', + expect.arrayContaining([ + expect.objectContaining({ + object_ref: 'attack-pattern--staged', + }), + ]) + ); + consoleSpy.mockRestore(); + }); }); 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 4bb57e385..dd01126ce 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 @@ -15,7 +15,11 @@ 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 { StixType } from 'src/app/utils/types'; +import { + StixType, + WorkflowStatus, + WorkflowStatusType, +} from 'src/app/utils/types'; import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; import { take } from 'rxjs/operators'; @@ -57,6 +61,19 @@ export class ReleaseTrackPageComponent implements OnInit { return this.releaseTrack?.staged ?? []; } + public get reviewItems(): any[] { + return [ + ...this.candidates.map(item => ({ + ...item, + release_track_tier: 'candidate', + })), + ...this.staged.map(item => ({ + ...item, + release_track_tier: 'staged', + })), + ]; + } + public get members(): any[] { return this.releaseTrack?.members ?? []; } @@ -65,9 +82,11 @@ export class ReleaseTrackPageComponent implements OnInit { const subscription = this.connector.getLatestSnapshot(this.id).subscribe({ next: res => { this.releaseTrack = res; + if (!this.releaseTrack) return; + this.breadcrumbService.changeBreadcrumb( this.route.snapshot, - this.releaseTrack!.name + this.releaseTrack.name ); // this.loadCandidates(); @@ -238,6 +257,34 @@ export class ReleaseTrackPageComponent implements OnInit { console.log('onDiff', item); } + public onReviewAndApprove(item: any): void { + this.reviewCandidateStatus( + WorkflowStatus.AwaitingReview, + WorkflowStatus.Reviewed, + [item] + ); + } + + public onBulkReviewAll(items: any[]): void { + const candidateItems = items.filter( + item => this.getReleaseTrackTier(item) === 'candidate' + ); + const stagedItems = items.filter( + item => this.getReleaseTrackTier(item) === 'staged' + ); + + if (stagedItems.length) { + // TODO: wire staged object review transition once the API is available. + console.log('onBulkReviewAll staged objects', stagedItems); + } + + this.reviewCandidateStatus( + WorkflowStatus.AwaitingReview, + WorkflowStatus.Reviewed, + candidateItems + ); + } + public onExport(): void { if (!this.id) return; @@ -321,4 +368,49 @@ export class ReleaseTrackPageComponent implements OnInit { // TODO: open preview & release modal console.log('onPreviewRelease'); } + + private reviewCandidateStatus( + from: WorkflowStatusType, + to: WorkflowStatusType, + items: any[] + ): void { + if (!this.id || !items.length) return; + + const objectRefs = items + .map(item => this.getReviewObjectRef(item)) + .filter((ref): ref is StixObjectRef => !!ref); + + if (!objectRefs.length) return; + + this.connector + .reviewCandidates(this.id, { + from, + to, + object_refs: objectRefs, + }) + .pipe(take(1)) + .subscribe({ + next: () => this.getReleaseTrack(), + error: err => { + console.error('Failed to update candidate review status', err); + }, + }); + } + + private getReviewObjectRef(item: any): StixObjectRef | null { + if (!item?.object_ref) return null; + const modified = this.toIsoString(item.object_modified); + return modified ? { id: item.object_ref, modified } : item.object_ref; + } + + private getReleaseTrackTier(item: any): 'candidate' | 'staged' | null { + if (item?.release_track_tier) return item.release_track_tier; + if (item?.object_staged_at || item?.object_staged_by) return 'staged'; + return item?.object_ref ? 'candidate' : null; + } + + private toIsoString(value: Date | string | undefined): string | undefined { + if (!value) return undefined; + return value instanceof Date ? value.toISOString() : value; + } } From 0f2c43ff244aa4f0cd42d332394df488bed2eb17 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 13:56:20 -0400 Subject: [PATCH 035/159] style(release-tracks): consistent awaiting review color --- .../release-track-review-board.component.scss | 8 ++++++-- .../release-track-page/release-track-page.component.scss | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/components/release-track-review-board/release-track-review-board.component.scss b/src/app/components/release-track-review-board/release-track-review-board.component.scss index da1aed167..6e361282a 100644 --- a/src/app/components/release-track-review-board/release-track-review-board.component.scss +++ b/src/app/components/release-track-review-board/release-track-review-board.component.scss @@ -50,7 +50,7 @@ border-color: color.mix(colors.color(warn), colors.color(spark-yellow), 70%); .review-lane-header h3 { - color: colors.color(pending); + color: color.mix(colors.color(warn), colors.color(spark-yellow), 70%); } } @@ -89,7 +89,11 @@ colors.color(spark-yellow), 70% ) !important; - color: colors.color(pending) !important; + color: color.mix( + colors.color(warn), + colors.color(spark-yellow), + 70% + ) !important; font-size: 12px; font-weight: 800; 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 f46fe80ae..b30dbc261 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 @@ -1,3 +1,4 @@ +@use 'sass:color'; @use '../../../../../style/colors' as colors; .release-track-page { @@ -8,7 +9,7 @@ color: colors.color(info); } .demote-color { - color: colors.color(warn); + color: color.mix(colors.color(warn), colors.color(spark-yellow), 70%); } .release-track-header { From 159b456912e9e26f0118954111bacf1534d7effd Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 14:03:41 -0400 Subject: [PATCH 036/159] style(release-tracks): adjust text colors --- .../release-track-card/release-track-card.component.html | 6 ++++-- .../release-track-card/release-track-card.component.scss | 8 ++++++++ .../release-track-review-card.component.html | 4 +++- .../release-track-review-card.component.scss | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/components/release-track-card/release-track-card.component.html b/src/app/components/release-track-card/release-track-card.component.html index cd7195668..e3bbfe28a 100644 --- a/src/app/components/release-track-card/release-track-card.component.html +++ b/src/app/components/release-track-card/release-track-card.component.html @@ -24,13 +24,15 @@
CANDIDATES
-
+
{{ track.candidates?.length || 'TODO' }}
STAGED
-
{{ track.staged?.length || 'TODO' }}
+
+ {{ track.staged?.length || 'TODO' }} +
MEMBERS
diff --git a/src/app/components/release-track-card/release-track-card.component.scss b/src/app/components/release-track-card/release-track-card.component.scss index 0aa22d189..0a8ab5b72 100644 --- a/src/app/components/release-track-card/release-track-card.component.scss +++ b/src/app/components/release-track-card/release-track-card.component.scss @@ -91,6 +91,14 @@ font-size: 1.25rem; } + .candidates-stat-value { + color: colors.color(info); + } + + .staged-stat-value { + color: colors.color(success); + } + .card-sep { margin: 12px 0; } diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.html b/src/app/components/release-track-review-card/release-track-review-card.component.html index 3a54db25a..9cccb732e 100644 --- a/src/app/components/release-track-review-card/release-track-review-card.component.html +++ b/src/app/components/release-track-review-card/release-track-review-card.component.html @@ -8,7 +8,9 @@
{{ title }}
-
+
{{ subtitle }}
diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.scss b/src/app/components/release-track-review-card/release-track-review-card.component.scss index cc9a0809b..c1d6feada 100644 --- a/src/app/components/release-track-review-card/release-track-review-card.component.scss +++ b/src/app/components/release-track-review-card/release-track-review-card.component.scss @@ -153,7 +153,7 @@ .review-description { display: -webkit-box; - min-height: 66px; + min-height: 30px; margin: 0; font-size: 14px; font-weight: 500; From 22d1730174a1d6c96da5b19b76553ec3266918d4 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Mon, 11 May 2026 14:41:12 -0400 Subject: [PATCH 037/159] refactor: rename release track object card --- src/app/app.module.ts | 4 +- .../release-track-object-card.component.html} | 22 ++--- .../release-track-object-card.component.scss} | 84 +++++-------------- .../release-track-object-card.component.ts} | 16 ++-- .../release-track-review-board.component.html | 4 +- .../release-track-review-board.component.ts | 24 +++--- .../release-track-page.component.html | 4 +- 7 files changed, 58 insertions(+), 100 deletions(-) rename src/app/components/{release-track-review-card/release-track-review-card.component.html => release-track-object-card/release-track-object-card.component.html} (72%) rename src/app/components/{release-track-review-card/release-track-review-card.component.scss => release-track-object-card/release-track-object-card.component.scss} (70%) rename src/app/components/{release-track-review-card/release-track-review-card.component.ts => release-track-object-card/release-track-object-card.component.ts} (86%) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0b4b37378..267442a1e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -231,7 +231,7 @@ import { DictionaryDiffComponent } from './components/stix/dictionary-property/d import { StixPageTabsComponent } from './components/stix/stix-page-tabs/stix-page-tabs.component'; import { ReleaseManagementComponent } from './views/dashboard-page/release-management/release-management.component'; import { ReleaseTrackCardComponent } from './components/release-track-card/release-track-card.component'; -import { ReleaseTrackReviewCardComponent } from './components/release-track-review-card/release-track-review-card.component'; +import { ReleaseTrackObjectCardComponent } from './components/release-track-object-card/release-track-object-card.component'; import { ReleaseTrackReviewBoardComponent } from './components/release-track-review-board/release-track-review-board.component'; import { NewTrackDialogComponent } from './components/new-track-dialog/new-track-dialog.component'; import { ReleaseTrackPageComponent } from './views/dashboard-page/release-management/release-track-page/release-track-page.component'; @@ -471,7 +471,7 @@ export function initConfig(appConfigService: AppConfigService) { OverlayModule, MatAutocompleteModule, ReleaseTrackCardComponent, - ReleaseTrackReviewCardComponent, + ReleaseTrackObjectCardComponent, ReleaseTrackReviewBoardComponent, ], providers: [ diff --git a/src/app/components/release-track-review-card/release-track-review-card.component.html b/src/app/components/release-track-object-card/release-track-object-card.component.html similarity index 72% rename from src/app/components/release-track-review-card/release-track-review-card.component.html rename to src/app/components/release-track-object-card/release-track-object-card.component.html index 9cccb732e..98e47ec24 100644 --- a/src/app/components/release-track-review-card/release-track-review-card.component.html +++ b/src/app/components/release-track-object-card/release-track-object-card.component.html @@ -1,15 +1,15 @@ -
-
-
+
+
+
{{ title }}
{{ subtitle }}
@@ -20,13 +20,13 @@ -

+

{{ description }}

-
+
-