Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.

Commit 1233948

Browse files
authored
Merge pull request #21 from initstring/codex/add-attack-timeline-tab-with-chart
Add attack timeline visualization to operation detail page
2 parents 2d7351d + d7a4d6a commit 1233948

8 files changed

Lines changed: 443 additions & 5 deletions

File tree

deploy/docker/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ services:
1111
restart: unless-stopped
1212

1313
ttpx-web:
14-
image: ghcr.io/initstring/ttpx:v0.2.1
14+
image: ghcr.io/initstring/ttpx:v0.2.2
1515
container_name: ttpx-web
1616
depends_on:
1717
- ttpx-postgres

docs/getting-started.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ Success metrics are displayed on each technique card.
9191
</a>
9292
</p>
9393

94+
### Attack Timeline
95+
96+
This is a gantt chart that displays horizontal bars for attack technique execution timelines. Hovering over individual techniques will display additional detail, including defensive outcomes.
97+
98+
<p align="center">
99+
<a href="images/attack-timeline.png">
100+
<img src="images/attack-timeline.png" width="540" alt="Screenshot - Attack Timeline" />
101+
</a>
102+
</p>
103+
94104
## 6. Explore Analytics
95105

96106
- Analytics -> Scorecard for high-level effectiveness metrics. Filters at the top limit results by date range and tags.

docs/images/attack-timeline.png

224 KB
Loading
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { OutcomeStatus } from "@prisma/client";
2+
import { OutcomeType } from "@prisma/client";
3+
import type { TechniqueTimelineDatum } from "./types";
4+
5+
const tooltipStyle: React.CSSProperties = {
6+
backgroundColor: "var(--color-surface-elevated)",
7+
border: "1px solid var(--color-accent)",
8+
borderRadius: "var(--radius-md)",
9+
padding: "0.75rem",
10+
maxWidth: 320,
11+
};
12+
13+
const outcomeTypeLabels: Record<OutcomeType, string> = {
14+
[OutcomeType.DETECTION]: "Detection",
15+
[OutcomeType.PREVENTION]: "Prevention",
16+
[OutcomeType.ATTRIBUTION]: "Attribution",
17+
};
18+
19+
const formatStatus = (status: OutcomeStatus) =>
20+
status
21+
.toLowerCase()
22+
.split("_")
23+
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
24+
.join(" ");
25+
26+
const formatDateTime = (value: string | number | Date) =>
27+
new Date(value).toLocaleString(undefined, {
28+
year: "numeric",
29+
month: "short",
30+
day: "numeric",
31+
hour: "2-digit",
32+
minute: "2-digit",
33+
});
34+
35+
interface TooltipPayloadItem {
36+
payload?: unknown;
37+
}
38+
39+
interface AttackTimelineTooltipProps {
40+
active?: boolean;
41+
payload?: TooltipPayloadItem[];
42+
}
43+
44+
function hasTooltipType(value: unknown): value is { tooltipType?: unknown } {
45+
return typeof value === "object" && value !== null && "tooltipType" in value;
46+
}
47+
48+
function isTechniquePayload(value: unknown): value is TechniqueTimelineDatum {
49+
return hasTooltipType(value) && value.tooltipType === "technique";
50+
}
51+
52+
export function AttackTimelineTooltip(props: AttackTimelineTooltipProps) {
53+
const { active, payload } = props;
54+
if (!active || !Array.isArray(payload) || payload.length === 0) {
55+
return null;
56+
}
57+
58+
const typedPayload = payload as unknown[];
59+
const first = typedPayload[0];
60+
if (!first || typeof first !== "object") {
61+
return null;
62+
}
63+
64+
const raw = (first as { payload?: unknown }).payload;
65+
66+
if (!raw) {
67+
return null;
68+
}
69+
70+
if (isTechniquePayload(raw)) {
71+
const success =
72+
raw.executedSuccessfully === true
73+
? "Successful"
74+
: raw.executedSuccessfully === false
75+
? "Failed"
76+
: "Unknown";
77+
78+
return (
79+
<div style={tooltipStyle}>
80+
<p className="text-sm font-semibold text-[var(--color-text-primary)]">{raw.techniqueName}</p>
81+
{raw.tacticName && (
82+
<p className="text-xs text-[var(--color-text-muted)]">{raw.tacticName}</p>
83+
)}
84+
<p className="mt-2 text-xs text-[var(--color-text-secondary)]">
85+
Start: {formatDateTime(raw.startDate)}
86+
</p>
87+
<p className="text-xs text-[var(--color-text-secondary)]">
88+
{raw.endDate ? `End: ${formatDateTime(raw.endDate)}` : "End: Ongoing"}
89+
</p>
90+
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Execution: {success}</p>
91+
{raw.outcomes.length > 0 && (
92+
<div className="mt-2 space-y-1">
93+
{raw.outcomes.map((outcome) => (
94+
<p key={`${outcome.type}-${outcome.id}`} className="text-xs text-[var(--color-text-secondary)]">
95+
{outcomeTypeLabels[outcome.type]}: {formatStatus(outcome.status)}
96+
{outcome.detectionTime
97+
? ` @ ${formatDateTime(outcome.detectionTime)}`
98+
: ""}
99+
</p>
100+
))}
101+
</div>
102+
)}
103+
</div>
104+
);
105+
}
106+
107+
return null;
108+
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"use client";
2+
3+
import { useMemo, useRef } from "react";
4+
import {
5+
Bar,
6+
CartesianGrid,
7+
ComposedChart,
8+
ResponsiveContainer,
9+
Tooltip,
10+
XAxis,
11+
YAxis,
12+
} from "recharts";
13+
import { CalendarClock } from "lucide-react";
14+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
15+
import { ExportToPngButton } from "@features/shared/export";
16+
import { formatDate, formatMonthYear } from "@/lib/formatDate";
17+
import { AttackTimelineTooltip } from "./attack-timeline-tooltip";
18+
import type { Operation, TechniqueTimelineDatum, Technique } from "./types";
19+
20+
interface AttackTimelineProps {
21+
operation: Operation;
22+
}
23+
24+
const toSafeTimestamp = (value: Date | string | null | undefined) => {
25+
if (!value) return null;
26+
const timestamp = typeof value === "string" ? new Date(value).getTime() : value.getTime();
27+
return Number.isFinite(timestamp) ? timestamp : null;
28+
};
29+
30+
const buildTechniqueLabel = (technique: Technique) => {
31+
const id = technique.mitreSubTechnique?.id ?? technique.mitreTechnique?.id ?? "CUSTOM";
32+
const name = technique.mitreSubTechnique?.name ?? technique.mitreTechnique?.name ?? "Custom Technique";
33+
return `${id}${name}`;
34+
};
35+
36+
const MIN_CHART_HEIGHT = 160;
37+
const MIN_BAR_SIZE = 10;
38+
const MAX_BAR_SIZE = 18;
39+
const MIN_TIMESPAN_MS = 15 * 60 * 1000;
40+
const MIN_PADDING_MS = 5 * 60 * 1000;
41+
const SPAN_PADDING_RATIO = 0.1;
42+
43+
const getRowHeight = (count: number) => {
44+
if (count <= 4) return 28;
45+
if (count <= 8) return 32;
46+
if (count <= 12) return 36;
47+
return 40;
48+
};
49+
50+
export default function AttackTimeline({ operation }: AttackTimelineProps) {
51+
const cardRef = useRef<HTMLDivElement>(null);
52+
53+
const { chartData, domain, baseTimestamp, chartHeight, barSize } = useMemo(() => {
54+
const candidates: TechniqueTimelineDatum[] = [];
55+
let earliest: number | null = null;
56+
let latest: number | null = null;
57+
58+
for (const technique of operation.techniques) {
59+
const startTime = toSafeTimestamp(technique.startTime);
60+
const endTime = toSafeTimestamp(technique.endTime);
61+
const createdAt = toSafeTimestamp(technique.createdAt);
62+
63+
const startTimestamp = startTime ?? createdAt ?? endTime;
64+
if (startTimestamp == null) {
65+
continue;
66+
}
67+
68+
const safeEnd = endTime != null && endTime > startTimestamp ? endTime : startTimestamp;
69+
70+
earliest = earliest == null ? startTimestamp : Math.min(earliest, startTimestamp);
71+
latest = latest == null ? safeEnd : Math.max(latest, safeEnd);
72+
73+
const techniqueName = technique.mitreSubTechnique?.name ?? technique.mitreTechnique?.name ?? "Custom Technique";
74+
const tacticName = technique.mitreTechnique?.tactic?.name ?? null;
75+
const label = buildTechniqueLabel(technique);
76+
77+
candidates.push({
78+
techniqueId: technique.id,
79+
label,
80+
techniqueName,
81+
tacticName,
82+
startDate: new Date(startTimestamp).toISOString(),
83+
endDate: endTime != null ? new Date(endTime).toISOString() : null,
84+
startTimestamp,
85+
endTimestamp: endTime ?? null,
86+
executedSuccessfully: technique.executedSuccessfully,
87+
outcomes: technique.outcomes ?? [],
88+
offset: 0,
89+
duration: 0,
90+
tooltipType: "technique",
91+
});
92+
}
93+
94+
if (candidates.length === 0 || earliest == null || latest == null) {
95+
const now = Date.now();
96+
return {
97+
chartData: [] as TechniqueTimelineDatum[],
98+
domain: [0, 1] as [number, number],
99+
baseTimestamp: now,
100+
chartHeight: MIN_CHART_HEIGHT,
101+
barSize: MIN_BAR_SIZE,
102+
};
103+
}
104+
105+
const baseTimestamp = earliest;
106+
let maxTimestamp = latest;
107+
108+
const chartData = candidates
109+
.map((item) => {
110+
const endTimestamp =
111+
item.endTimestamp != null && item.endTimestamp >= item.startTimestamp
112+
? item.endTimestamp
113+
: item.startTimestamp;
114+
if (endTimestamp > maxTimestamp) {
115+
maxTimestamp = endTimestamp;
116+
}
117+
118+
const offset = item.startTimestamp - baseTimestamp;
119+
const duration = Math.max(0, endTimestamp - item.startTimestamp);
120+
121+
if (!Number.isFinite(offset) || !Number.isFinite(duration)) {
122+
return null;
123+
}
124+
125+
return {
126+
...item,
127+
offset,
128+
duration,
129+
} satisfies TechniqueTimelineDatum;
130+
})
131+
.filter((value): value is TechniqueTimelineDatum => value !== null);
132+
133+
if (chartData.length === 0) {
134+
const now = Date.now();
135+
return {
136+
chartData: [] as TechniqueTimelineDatum[],
137+
domain: [0, 1] as [number, number],
138+
baseTimestamp: now,
139+
chartHeight: MIN_CHART_HEIGHT,
140+
barSize: MIN_BAR_SIZE,
141+
};
142+
}
143+
144+
const rawSpan = Math.max(maxTimestamp - baseTimestamp, 0);
145+
const spanBase = rawSpan === 0 ? MIN_TIMESPAN_MS : rawSpan;
146+
const paddedSpan = spanBase + Math.max(MIN_PADDING_MS, Math.round(spanBase * SPAN_PADDING_RATIO));
147+
const rowCount = chartData.length;
148+
const rowHeight = getRowHeight(rowCount);
149+
const chartHeight = Math.max(MIN_CHART_HEIGHT, rowCount * rowHeight + 48);
150+
const computedBarSize = Math.round(rowHeight * 0.6);
151+
const barSize = Math.min(MAX_BAR_SIZE, Math.max(MIN_BAR_SIZE, computedBarSize));
152+
153+
return {
154+
chartData,
155+
domain: [0, paddedSpan] as [number, number],
156+
baseTimestamp,
157+
chartHeight,
158+
barSize,
159+
};
160+
}, [operation.techniques]);
161+
162+
const spanInMs = domain[1];
163+
const useMonthTicks = spanInMs > 1000 * 60 * 60 * 24 * 120;
164+
const includeYear = useMemo(() => {
165+
const start = new Date(baseTimestamp);
166+
const end = new Date(baseTimestamp + spanInMs);
167+
return start.getFullYear() !== end.getFullYear();
168+
}, [baseTimestamp, spanInMs]);
169+
170+
const tickFormatter = (value: number) => {
171+
const date = new Date(baseTimestamp + value);
172+
if (useMonthTicks) {
173+
return formatMonthYear(date);
174+
}
175+
return formatDate(date, { includeYear });
176+
};
177+
178+
const yAxisTicks = useMemo(() => chartData.map((item) => item.techniqueId), [chartData]);
179+
const labelLookup = useMemo(() => {
180+
const map = new Map<string, string>();
181+
for (const item of chartData) {
182+
map.set(item.techniqueId, item.label);
183+
}
184+
return map;
185+
}, [chartData]);
186+
187+
const formatTechniqueLabel = (value: string | number) => {
188+
if (typeof value !== "string") return "";
189+
return labelLookup.get(value) ?? value;
190+
};
191+
192+
return (
193+
<Card ref={cardRef}>
194+
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
195+
<div>
196+
<CardTitle className="text-xl font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
197+
<CalendarClock className="w-6 h-6 text-[var(--color-accent)]" />
198+
Attack Timeline
199+
</CardTitle>
200+
<CardDescription>
201+
Visualizes technique execution windows for this operation.
202+
</CardDescription>
203+
</div>
204+
<ExportToPngButton
205+
targetRef={cardRef}
206+
fileName={`operation-${operation.id}-attack-timeline`}
207+
/>
208+
</CardHeader>
209+
<CardContent>
210+
{chartData.length === 0 ? (
211+
<p className="text-sm text-[var(--color-text-muted)]">
212+
No timeline data yet. Add execution times to populate this view.
213+
</p>
214+
) : (
215+
<>
216+
<ResponsiveContainer width="100%" height={chartHeight}>
217+
<ComposedChart
218+
layout="vertical"
219+
data={chartData}
220+
margin={{ top: 24, right: 32, bottom: 24, left: 0 }}
221+
>
222+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
223+
<XAxis
224+
type="number"
225+
domain={domain}
226+
tickFormatter={tickFormatter}
227+
stroke="var(--color-text-muted)"
228+
tick={{ fontSize: 12 }}
229+
/>
230+
<YAxis
231+
dataKey="techniqueId"
232+
type="category"
233+
width={240}
234+
tickLine={false}
235+
axisLine={false}
236+
tick={{ fontSize: 12, fill: "var(--color-text-primary)" }}
237+
interval={0}
238+
ticks={yAxisTicks}
239+
tickFormatter={formatTechniqueLabel}
240+
padding={{ top: 12, bottom: 12 }}
241+
/>
242+
<Tooltip
243+
cursor={{ fill: "var(--color-surface-muted)", fillOpacity: 0.25 }}
244+
content={<AttackTimelineTooltip />}
245+
/>
246+
<Bar dataKey="offset" stackId="timeline" fill="transparent" isAnimationActive={false} />
247+
<Bar
248+
dataKey="duration"
249+
stackId="timeline"
250+
fill="var(--color-accent)"
251+
radius={[999, 999, 999, 999]}
252+
barSize={barSize}
253+
minPointSize={4}
254+
isAnimationActive={false}
255+
/>
256+
</ComposedChart>
257+
</ResponsiveContainer>
258+
</>
259+
)}
260+
</CardContent>
261+
</Card>
262+
);
263+
}

0 commit comments

Comments
 (0)