diff --git a/src/app/classes/release-tracks/snapshot.spec.ts b/src/app/classes/release-tracks/snapshot.spec.ts
new file mode 100644
index 000000000..242003702
--- /dev/null
+++ b/src/app/classes/release-tracks/snapshot.spec.ts
@@ -0,0 +1,69 @@
+import { ReleaseTrackSnapshot } from './snapshot';
+
+describe('ReleaseTrackSnapshot', () => {
+ it('should preserve candidate and staged display fields', () => {
+ const snapshot = new ReleaseTrackSnapshot({
+ candidates: [
+ {
+ object_ref: 'attack-pattern--candidate',
+ object_modified: '2026-01-01T00:00:00.000Z',
+ object_status: 'work-in-progress',
+ attack_id: 'T1234',
+ name: 'Candidate Technique',
+ description: 'Candidate description',
+ modified_by_user: {
+ id: 'user-account--candidate',
+ username: 'candidateuser',
+ name: 'Candidate Reviewer',
+ },
+ },
+ ],
+ staged: [
+ {
+ object_ref: 'attack-pattern--staged',
+ object_modified: '2026-01-02T00:00:00.000Z',
+ object_status: 'reviewed',
+ attack_id: 'T5678',
+ name: 'Staged Technique',
+ description: 'Staged description',
+ modified_by_user: {
+ id: 'user-account--staged',
+ username: 'stageduser',
+ name: 'Staged Reviewer',
+ },
+ },
+ ],
+ members: [
+ {
+ object_ref: 'attack-pattern--member',
+ object_modified: '2026-01-03T00:00:00.000Z',
+ attack_id: 'T9999',
+ name: 'Member Technique',
+ },
+ ],
+ });
+
+ expect(snapshot.candidates?.[0]).toEqual(
+ expect.objectContaining({
+ attack_id: 'T1234',
+ name: 'Candidate Technique',
+ description: 'Candidate description',
+ modified_by_user: expect.objectContaining({
+ name: 'Candidate Reviewer',
+ }),
+ })
+ );
+ expect(snapshot.staged?.[0]).toEqual(
+ expect.objectContaining({
+ attack_id: 'T5678',
+ name: 'Staged Technique',
+ description: 'Staged description',
+ modified_by_user: expect.objectContaining({
+ name: 'Staged Reviewer',
+ }),
+ })
+ );
+ expect(snapshot.members[0]).not.toHaveProperty('attack_id');
+ expect(snapshot.members[0]).not.toHaveProperty('name');
+ });
+});
diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts
index 93e663156..e45b42d52 100644
--- a/src/app/classes/release-tracks/snapshot.ts
+++ b/src/app/classes/release-tracks/snapshot.ts
@@ -112,6 +112,7 @@ export class ReleaseTrackSnapshot {
if ('staged' in raw && Array.isArray(raw.staged)) {
this.staged = raw.staged.map((s: any) => ({
+ ...s,
object_ref: s.object_ref,
object_modified: new Date(s.object_modified),
object_status: s.object_status,
@@ -124,6 +125,7 @@ export class ReleaseTrackSnapshot {
if ('candidates' in raw && Array.isArray(raw.candidates)) {
this.candidates = raw.candidates.map((c: any) => ({
+ ...c,
object_ref: c.object_ref,
object_modified: new Date(c.object_modified),
object_status: c.object_status,
diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts
index 3625b7d28..1f80343d1 100644
--- a/src/app/classes/release-tracks/tiers.ts
+++ b/src/app/classes/release-tracks/tiers.ts
@@ -1,11 +1,25 @@
import { WorkflowStatusType } from 'src/app/utils/types';
+export interface TierEntryModifiedByUser {
+ id?: string;
+ username?: string;
+ displayName?: string;
+ name?: string;
+}
+
+export interface TierEntryDisplayFields {
+ attack_id?: string;
+ name?: string;
+ description?: string;
+ modified_by_user?: TierEntryModifiedByUser;
+}
+
export interface MemberEntry {
object_ref: string;
object_modified: Date;
}
-export interface StagedEntry {
+export interface StagedEntry extends TierEntryDisplayFields {
object_ref: string;
object_modified: Date;
object_status: WorkflowStatusType;
@@ -13,7 +27,7 @@ export interface StagedEntry {
object_staged_by: string;
}
-export interface CandidateEntry {
+export interface CandidateEntry extends TierEntryDisplayFields {
object_ref: string;
object_modified: Date;
object_status: WorkflowStatusType;
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 e3bbfe28a..c9dac33c5 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,19 +25,19 @@
CANDIDATES
- {{ track.candidates?.length || 'TODO' }}
+ {{ track.stats?.candidates ?? 0 }}
STAGED
- {{ track.staged?.length || 'TODO' }}
+ {{ track.stats?.staged ?? 0 }}
MEMBERS
- {{ track.members?.length || 'TODO' }}
+ {{ track.stats?.members ?? 0 }}
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.html b/src/app/components/release-track-object-card/release-track-object-card.component.html
index 24dece347..50cd9631b 100644
--- a/src/app/components/release-track-object-card/release-track-object-card.component.html
+++ b/src/app/components/release-track-object-card/release-track-object-card.component.html
@@ -18,9 +18,7 @@
-
- {{ description }}
-
+
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.scss b/src/app/components/release-track-object-card/release-track-object-card.component.scss
index bfffb216a..30d81ea4d 100644
--- a/src/app/components/release-track-object-card/release-track-object-card.component.scss
+++ b/src/app/components/release-track-object-card/release-track-object-card.component.scss
@@ -115,14 +115,29 @@
}
.object-description {
- display: -webkit-box;
+ display: block;
min-height: 30px;
margin: 0;
font-size: 14px;
font-weight: 500;
line-height: 22px;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 3;
+
+ ::ng-deep p {
+ display: -webkit-box;
+ overflow: hidden;
+ margin: 0;
+ text-overflow: ellipsis;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ }
+
+ ::ng-deep :last-child {
+ margin-bottom: 0;
+ }
+
+ ::ng-deep a {
+ font-weight: 700;
+ }
.light & {
color: colors.on-color(light);
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts
new file mode 100644
index 000000000..bca280f35
--- /dev/null
+++ b/src/app/components/release-track-object-card/release-track-object-card.component.spec.ts
@@ -0,0 +1,85 @@
+import { ReleaseTrackObjectCardComponent } from './release-track-object-card.component';
+
+describe('ReleaseTrackObjectCardComponent', () => {
+ let component: ReleaseTrackObjectCardComponent;
+
+ beforeEach(() => {
+ component = new ReleaseTrackObjectCardComponent();
+ });
+
+ it('should show tier entry display fields when available', () => {
+ component.item = {
+ object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
+ object_modified: '2026-01-01T00:00:00.000Z',
+ attack_id: 'T1234',
+ name: 'Technique Name',
+ description: 'Technique description\n\nAdditional details.',
+ modified_by_user: {
+ id: 'user-account--1234',
+ username: 'reviewer1',
+ displayName: 'Review User',
+ name: 'Review User',
+ },
+ };
+
+ expect(component.title).toBe('Technique Name');
+ expect(component.subtitle).toBe('T1234');
+ expect(component.description).toBe('Technique description');
+ expect(component.modifiedByName).toBe('Review User');
+ });
+
+ it('should use username when modified by user has no display name', () => {
+ component.item = {
+ object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
+ modified_by_user: {
+ username: 'reviewer1',
+ },
+ };
+
+ expect(component.modifiedByName).toBe('reviewer1');
+ });
+
+ it('should preserve markdown syntax in descriptions for rendering', () => {
+ component.item = {
+ object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
+ description: '**Markdown** [description](https://example.com)',
+ };
+
+ expect(component.description).toBe(
+ '**Markdown** [description](https://example.com)'
+ );
+ });
+
+ it('should prefer display name over username and name', () => {
+ component.item = {
+ object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
+ modified_by_user: {
+ username: 'releaseuser',
+ displayName: 'Release Reviewer',
+ name: 'Fallback Name',
+ },
+ };
+
+ expect(component.modifiedByName).toBe('Release Reviewer');
+ });
+
+ it('should prefer modified by user info over staged identity ids', () => {
+ component.item = {
+ object_ref: 'attack-pattern--990e4d99-5c6f-4f34-b6f0-7221c55f39cb',
+ object_modified: '2026-04-22T17:57:26.218Z',
+ object_status: 'reviewed',
+ object_staged_at: '2026-06-24T19:50:19.859Z',
+ object_staged_by: 'identity--00000000-0000-4000-8000-000000000001',
+ attack_id: 'T1640.001',
+ name: '0 New Sub',
+ modified_by_user: {
+ id: 'identity--00000000-0000-4000-8000-000000000001',
+ username: 'releaseuser',
+ displayName: 'Release Reviewer',
+ name: 'Release Reviewer',
+ },
+ };
+
+ expect(component.modifiedByName).toBe('Release Reviewer');
+ });
+});
diff --git a/src/app/components/release-track-object-card/release-track-object-card.component.ts b/src/app/components/release-track-object-card/release-track-object-card.component.ts
index 279d14116..45517e40c 100644
--- a/src/app/components/release-track-object-card/release-track-object-card.component.ts
+++ b/src/app/components/release-track-object-card/release-track-object-card.component.ts
@@ -5,9 +5,11 @@ import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import moment from 'moment';
+import { MarkdownModule } from 'ngx-markdown';
import { StixTypeToAttackType } from 'src/app/utils/type-mappings';
import { StixType, WorkflowStatusType } from 'src/app/utils/types';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
+import type { TierEntryModifiedByUser } from 'src/app/classes/release-tracks';
export interface ReleaseTrackObjectItem {
object_ref: string;
@@ -17,6 +19,10 @@ export interface ReleaseTrackObjectItem {
object_added_by?: string;
object_staged_at?: Date | string;
object_staged_by?: string;
+ attack_id?: string;
+ name?: string;
+ description?: string;
+ modified_by_user?: TierEntryModifiedByUser;
[key: string]: any;
}
@@ -29,6 +35,7 @@ export interface ReleaseTrackObjectItem {
MatCardModule,
MatIconModule,
MatTooltipModule,
+ MarkdownModule,
UserAvatarComponent,
],
templateUrl: './release-track-object-card.component.html',
@@ -46,15 +53,20 @@ export class ReleaseTrackObjectCardComponent {
@Output() diffObject = new EventEmitter();
public get title(): string {
- return this.item?.name || this.fallbackObjectLabel;
+ return (
+ this.item?.name || this.item?.object_name || this.fallbackObjectLabel
+ );
}
public get subtitle(): string {
- return this.item?.attackId || '<>';
+ return this.item?.attack_id || this.item?.attackId || '<>';
}
public get description(): string {
- const description = this.item?.description || 'No description available.';
+ const description =
+ this.item?.description ||
+ this.item?.object_description ||
+ 'No description available.';
return this.firstParagraph(description);
}
@@ -79,11 +91,13 @@ export class ReleaseTrackObjectCardComponent {
public get modifiedByName(): string {
return (
- this.item?.modified_by_user ||
- this.item?.object_modified_by ||
- this.item?.object_added_by ||
- this.item?.object_staged_by ||
- this.item?.modified_by_ref ||
+ this.userDisplayName(this.item?.modified_by_user, false) ||
+ this.userDisplayName(
+ this.item?.object_modified_by ||
+ this.item?.object_added_by ||
+ this.item?.object_staged_by ||
+ this.item?.modified_by_ref
+ ) ||
'Unknown User'
);
}
@@ -125,4 +139,16 @@ export class ReleaseTrackObjectCardComponent {
.filter(Boolean);
return paragraph || 'No description available.';
}
+
+ private userDisplayName(value: any, includeId = true): string | null {
+ if (!value) return null;
+ if (typeof value === 'string') return value;
+
+ return (
+ value.displayName ||
+ value.username ||
+ value.name ||
+ (includeId ? value.id : null)
+ );
+ }
}
diff --git a/src/app/components/user-avatar/user-avatar.component.spec.ts b/src/app/components/user-avatar/user-avatar.component.spec.ts
index 5394c2905..5d5daad95 100644
--- a/src/app/components/user-avatar/user-avatar.component.spec.ts
+++ b/src/app/components/user-avatar/user-avatar.component.spec.ts
@@ -22,52 +22,58 @@ describe('UserAvatarComponent', () => {
});
it('should show first and last initials for a display name with middle names', () => {
- component.name = 'Cassandra Lauren Smith';
+ component.name = 'Example Middle User';
- expect(component.initials).toBe('CS');
+ expect(component.initials).toBe('EU');
});
it('should show first and last initials for a two-word display name', () => {
- component.name = 'Laurent Hacks';
+ component.name = 'Release Analyst';
- expect(component.initials).toBe('LH');
+ expect(component.initials).toBe('RA');
+ });
+
+ it('should show first and last initials for the modified by user display name', () => {
+ component.name = 'Review User';
+
+ expect(component.initials).toBe('RU');
});
it('should show the first two letters for usernames', () => {
- component.name = 'clsmith';
+ component.name = 'exampleuser';
- expect(component.initials).toBe('CL');
+ expect(component.initials).toBe('EX');
});
it('should show the first two letters for usernames that match display initials', () => {
- component.name = 'lhacks';
+ component.name = 'releaseuser';
- expect(component.initials).toBe('LH');
+ expect(component.initials).toBe('RE');
});
it('should ignore prefixes and suffixes when building display initials', () => {
- component.name = 'Dr. Cassandra Lauren Smith Jr.';
+ component.name = 'Dr. Example Middle User Jr.';
- expect(component.initials).toBe('CS');
+ expect(component.initials).toBe('EU');
});
it('should ignore professional suffixes when building display initials', () => {
- component.name = 'Laurent Hacks, PhD';
+ component.name = 'Release Analyst, PhD';
- expect(component.initials).toBe('LH');
+ expect(component.initials).toBe('RA');
});
it('should build initials from the remaining name when only a prefix is ignored', () => {
- component.name = 'Dr. Cassandra';
+ component.name = 'Dr. Example';
- expect(component.initials).toBe('CA');
+ expect(component.initials).toBe('EX');
});
it('should assign the same background to the same name', () => {
- component.name = 'Mary Jackson';
+ component.name = 'Stable User';
const background = component.background;
- component.name = 'Mary Jackson';
+ component.name = 'Stable User';
expect(component.background).toBe(background);
});
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 98385320c..c77e576dc 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
@@ -57,11 +57,7 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy {
private loadTracks(): void {
this.subscription = this.connector.listReleaseTracks().subscribe(result => {
- if (!result || !result.data) {
- this.allTracks = [];
- return;
- }
- this.allTracks = this.tracksWithComputedData(result.data);
+ this.allTracks = this.tracksWithComputedData(result?.data ?? []);
});
}
@@ -74,80 +70,19 @@ export class ReleaseManagementComponent implements OnInit, OnDestroy {
}
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 data.map((track: any) => {
+ const summary = track.summary ?? {};
return {
...track,
- latestVersion,
- latestModified,
- stats,
+ latestVersion: track.latest_tagged_version ?? 'No tagged releases',
+ latestModified: track.latest_snapshot_modified,
+ stats: {
+ candidates: summary.candidates_count ?? 0,
+ staged: summary.staged_count ?? 0,
+ members: summary.members_count ?? 0,
+ quarantined: 0,
+ },
};
});
}