diff --git a/src/apollo/operations.ts b/src/apollo/operations.ts index 6f30ed7..3e65cd1 100644 --- a/src/apollo/operations.ts +++ b/src/apollo/operations.ts @@ -876,6 +876,28 @@ export const SUB_MYTHIC_TREE = gql` } ` +// All tasks for the operation — used by TimelinePanel swimlane view. +// Lean shape: no tags, no response_count. Limit 2000. +export const GET_TIMELINE_TASKS = gql` + query GetTimelineTasks($operation_id: Int!, $limit: Int = 2000) { + task( + where: { callback: { operation_id: { _eq: $operation_id } } } + order_by: { timestamp: asc } + limit: $limit + ) { + id + display_id + command_name + display_params + status + completed + timestamp + operator { username } + callback { id display_id host } + } + } +` + // Historical commands for a callback — seeds CommandBar history on first visit. // Uses display_params (always human-readable) rather than raw params. export const GET_CALLBACK_TASK_HISTORY = gql` diff --git a/src/components/Rail/Rail.tsx b/src/components/Rail/Rail.tsx index 1e5d131..f40d37b 100644 --- a/src/components/Rail/Rail.tsx +++ b/src/components/Rail/Rail.tsx @@ -105,6 +105,19 @@ const ITEMS: { id: RailView; title: string; icon: React.ReactNode }[] = [ ), }, + { + id: 'timeline', + title: 'Attack Timeline', + icon: ( + + + + + + + + ), + }, { id: 'attack', title: 'ATT&CK Matrix', diff --git a/src/components/TimelinePanel/TimelinePanel.module.css b/src/components/TimelinePanel/TimelinePanel.module.css new file mode 100644 index 0000000..4b1c466 --- /dev/null +++ b/src/components/TimelinePanel/TimelinePanel.module.css @@ -0,0 +1,313 @@ +/* ═══════════════════════════════════════════════════ + src/components/TimelinePanel/TimelinePanel.module.css + ═══════════════════════════════════════════════════ */ + +.panel { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + overflow: hidden; + background: var(--bg-base); +} + +/* ── Header ──────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--beige-border); + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: baseline; + gap: 10px; +} + +.title { + font-family: var(--font-serif); + font-size: 13px; + color: var(--beige); + letter-spacing: 0.04em; +} + +.meta { + font-size: 11px; + color: var(--bone-700); +} + +.headerRight { + display: flex; + align-items: center; + gap: 10px; +} + +.filterInput { + background: var(--bg-raised); + border: 1px solid var(--beige-border); + border-radius: 3px; + color: var(--bone-400); + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 8px; + width: 200px; + outline: none; +} +.filterInput::placeholder { color: var(--bone-800); } +.filterInput:focus { border-color: var(--crimson-600); } + +.zoomBtns { + display: flex; + gap: 2px; +} + +.zoomBtn { + background: var(--bg-raised); + border: 1px solid var(--beige-border); + border-radius: 3px; + color: var(--bone-600); + font-family: var(--font-mono); + font-size: 11px; + padding: 3px 7px; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} +.zoomBtn:hover { background: var(--bg-elevated); color: var(--bone-400); } +.zoomActive { + background: var(--crimson-900); + border-color: var(--crimson-600); + color: var(--crimson-300); +} + +/* ── Scroll area ─────────────────────────────────── */ + +.scrollArea { + flex: 1; + overflow: auto; + position: relative; +} + +.inner { + display: flex; + flex-direction: column; +} + +.empty { + padding: 48px 24px; + color: var(--bone-700); + font-size: 12px; + font-family: var(--font-mono); +} + +/* ── Time axis ───────────────────────────────────── */ + +.axisRow { + display: flex; + align-items: flex-end; + border-bottom: 1px solid var(--beige-border); + position: sticky; + top: 0; + z-index: 3; + background: var(--bg-base); +} + +.axisLabel { + font-size: 9px; + color: var(--bone-800); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.axisTrack { + position: relative; + flex: 1; + height: 100%; + min-width: 0; +} + +.tick { + position: absolute; + bottom: 0; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + pointer-events: none; +} + +.tickNub { + width: 1px; + height: 6px; + background: var(--bone-700); +} + +.tickLabel { + font-family: var(--font-mono); + font-size: 9px; + color: var(--bone-600); + white-space: nowrap; + padding-bottom: 3px; +} + +/* ── Label cell (sticky left) ────────────────────── */ + +.labelCell { + position: sticky; + left: 0; + z-index: 2; + background: var(--bg-base); + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + border-right: 1px solid var(--beige-border); +} + +/* ── Callback tracks ─────────────────────────────── */ + +.trackRow { + display: flex; + align-items: center; + border-bottom: 1px solid color-mix(in srgb, var(--beige-border) 50%, transparent); +} +.trackRow:hover .labelCell { background: var(--bg-raised); } +.trackRow:hover .trackArea { background: color-mix(in srgb, var(--bg-raised) 40%, transparent); } + +.trackDot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.trackHost { + font-family: var(--font-mono); + font-size: 11px; + color: var(--bone-500); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 110px; +} + +.trackId { + font-family: var(--font-mono); + font-size: 10px; + color: var(--bone-700); + flex-shrink: 0; +} + +.trackArea { + position: relative; + flex: 1; + height: 100%; + min-width: 0; +} + +.trackLine { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + opacity: 0.2; + pointer-events: none; +} + +/* ── Events ──────────────────────────────────────── */ + +.event { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + transition: transform 0.1s, border-color 0.1s, box-shadow 0.1s; +} +.event:hover { + transform: translate(-50%, -50%) scale(1.5); + z-index: 1; +} + +.ev_done { background: var(--crimson-400); } +.ev_error { background: #7a1515; opacity: 0.85; } +.ev_running { background: var(--bone-700); } + +.evSelected { + border-color: var(--beige) !important; + transform: translate(-50%, -50%) scale(1.4) !important; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--beige) 25%, transparent); + z-index: 2; +} + +/* ── Detail bar ──────────────────────────────────── */ + +.detail { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0; + padding: 0 12px 0 8px; + height: 52px; + border-top: 1px solid var(--beige-border); + background: var(--bg-raised); + overflow: hidden; +} + +.detailClose { + background: none; + border: none; + color: var(--bone-700); + font-size: 11px; + cursor: pointer; + padding: 4px 8px 4px 4px; + flex-shrink: 0; + line-height: 1; +} +.detailClose:hover { color: var(--bone-400); } + +.detailItem { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 14px; + border-right: 1px solid var(--beige-border); + flex-shrink: 0; + overflow: hidden; +} +.detailItem:last-child { border-right: none; flex-shrink: 1; overflow: hidden; } + +.diLabel { + font-size: 9px; + color: var(--bone-800); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.diValue { + font-size: 12px; + color: var(--bone-400); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.diMono { + font-family: var(--font-mono); + font-size: 11px; +} + +.diSt_done { color: var(--crimson-400); } +.diSt_error { color: #c04040; } +.diSt_running { color: var(--bone-600); } diff --git a/src/components/TimelinePanel/TimelinePanel.tsx b/src/components/TimelinePanel/TimelinePanel.tsx new file mode 100644 index 0000000..0ba863d --- /dev/null +++ b/src/components/TimelinePanel/TimelinePanel.tsx @@ -0,0 +1,285 @@ +/* ═══════════════════════════════════════════════════ + src/components/TimelinePanel/TimelinePanel.tsx + ═══════════════════════════════════════════════════ */ + +import { useState, useMemo } from 'react' +import { useQuery } from '@apollo/client' +import { GET_TIMELINE_TASKS } from '@/apollo/operations' +import { useStore } from '@/store' +import { parseTs } from '@/components/Sidebar/utils' +import styles from './TimelinePanel.module.css' + +// ── Types ───────────────────────────────────────────── + +interface TaskRow { + id: number + display_id: number + command_name: string + display_params: string + status: string + completed: boolean + timestamp: string + operator: { username: string } + callback: { id: number; display_id: number; host: string } +} + +interface EventData extends TaskRow { + leftPct: number +} + +interface CallbackTrack { + cbId: number + displayId: number + host: string + color: string + events: EventData[] +} + +// ── Constants ────────────────────────────────────────── + +const LABEL_W = 188 +const TRACK_H = 44 +const AXIS_H = 34 + +const TRACK_PALETTE = [ + '#d4916b', '#6b8fd4', '#6aaa64', '#b06bd4', + '#4ec9b0', '#d4c46b', '#d46b8f', '#8fd46b', +] + +const NICE_INTERVALS_MS = [ + 1_000, 5_000, 10_000, 30_000, + 60_000, 300_000, 900_000, 1_800_000, + 3_600_000, 14_400_000, 43_200_000, 86_400_000, +] + +const ZOOM_LEVELS = [1, 2, 4, 8] as const +type ZoomLevel = typeof ZOOM_LEVELS[number] + +// ── Helpers ─────────────────────────────────────────── + +function pickTickInterval(spanMs: number): number { + const raw = spanMs / 8 + return NICE_INTERVALS_MS.find(i => i >= raw) ?? NICE_INTERVALS_MS[NICE_INTERVALS_MS.length - 1] +} + +function fmtTickLabel(ms: number, spanMs: number): string { + const d = new Date(ms) + if (spanMs < 3_600_000) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) + } + if (spanMs < 86_400_000) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + } + return ( + d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + ) +} + +function eventStatus(t: TaskRow): 'done' | 'error' | 'running' { + if (t.status === 'error') return 'error' + if (t.completed) return 'done' + return 'running' +} + +// ── Component ───────────────────────────────────────── + +export function TimelinePanel() { + const [filter, setFilter] = useState('') + const [zoom, setZoom] = useState(1) + const [selected, setSelected] = useState(null) + + const activeOperation = useStore(s => s.activeOperation) + const annotations = useStore(s => s.callbackAnnotations) + + const { data, loading } = useQuery(GET_TIMELINE_TASKS, { + variables: { operation_id: activeOperation?.id ?? 0 }, + skip: !activeOperation, + fetchPolicy: 'cache-and-network', + }) + + const allTasks: TaskRow[] = data?.task ?? [] + + const filterLc = filter.toLowerCase() + const filtered = useMemo(() => { + if (!filterLc) return allTasks + return allTasks.filter(t => + t.callback.host.toLowerCase().includes(filterLc) || + t.command_name.toLowerCase().includes(filterLc) || + (t.display_params ?? '').toLowerCase().includes(filterLc) + ) + }, [allTasks, filterLc]) + + const { minMs, maxMs, spanMs } = useMemo(() => { + if (!filtered.length) return { minMs: 0, maxMs: 0, spanMs: 1 } + let mn = Infinity, mx = -Infinity + for (const t of filtered) { + const ms = parseTs(t.timestamp).getTime() + if (ms < mn) mn = ms + if (ms > mx) mx = ms + } + return { minMs: mn, maxMs: mx, spanMs: Math.max(mx - mn, 1) } + }, [filtered]) + + const tracks: CallbackTrack[] = useMemo(() => { + const map = new Map() + for (const t of filtered) { + const cbId = t.callback.id + if (!map.has(cbId)) { + const idx = map.size + const annotColor = annotations[t.callback.display_id] + map.set(cbId, { + cbId, + displayId: t.callback.display_id, + host: t.callback.host, + color: annotColor || TRACK_PALETTE[idx % TRACK_PALETTE.length], + events: [], + }) + } + const ms = parseTs(t.timestamp).getTime() + const leftPct = (ms - minMs) / spanMs * 100 + map.get(cbId)!.events.push({ ...t, leftPct }) + } + return Array.from(map.values()).sort((a, b) => a.displayId - b.displayId) + }, [filtered, minMs, spanMs, annotations]) + + const ticks = useMemo(() => { + if (spanMs <= 1) return [] + const interval = pickTickInterval(spanMs) + const firstTick = Math.ceil(minMs / interval) * interval + const result = [] + for (let ms = firstTick; ms <= maxMs; ms += interval) { + result.push({ ms, leftPct: (ms - minMs) / spanMs * 100, label: fmtTickLabel(ms, spanMs) }) + } + return result + }, [minMs, maxMs, spanMs]) + + const innerH = AXIS_H + tracks.length * TRACK_H + + return ( +
+ + {/* ── Header ── */} +
+
+ Attack Timeline + + {loading ? 'loading…' : `${filtered.length} tasks · ${tracks.length} callbacks`} + +
+
+ { setFilter(e.target.value); setSelected(null) }} + /> +
+ {ZOOM_LEVELS.map(z => ( + + ))} +
+
+
+ + {/* ── Scroll area ── */} +
+ {tracks.length === 0 && !loading && ( +
+ {filterLc ? `no tasks matching "${filter}"` : 'no tasks in this operation'} +
+ )} + + {tracks.length > 0 && ( +
+ + {/* Time axis */} +
+
+ time +
+
+ {ticks.map(tick => ( +
+
+ {tick.label} +
+ ))} +
+
+ + {/* Callback swimlanes */} + {tracks.map(track => ( +
+
+ + {track.host} + #{track.displayId} +
+
+
+ {track.events.map(ev => ( +
+
+ ))} +
+ )} +
+ + {/* ── Selected event detail bar ── */} + {selected && ( +
+ +
+ host + {selected.callback.host} +
+
+ callback + #{selected.callback.display_id} +
+
+ task + #{selected.display_id} +
+
+ command + + {selected.command_name}{selected.display_params ? ` ${selected.display_params}` : ''} + +
+
+ status + + {selected.status} + +
+
+ time + {parseTs(selected.timestamp).toLocaleString()} +
+
+ operator + {selected.operator.username} +
+
+ )} +
+ ) +} diff --git a/src/store/index.ts b/src/store/index.ts index 14f2cfd..abe5d46 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -108,7 +108,7 @@ export interface HecateStore { setCallbacks: (cbs: Callback[]) => void currentTasks: Task[] setCurrentTasks: (tasks: Task[]) => void - activeRailView: 'overview' | 'callbacks' | 'payloads' | 'services' | 'proxies' | 'credentials' | 'files' | 'attack' | 'logs' | 'report' | 'operations' + activeRailView: 'overview' | 'callbacks' | 'payloads' | 'services' | 'proxies' | 'credentials' | 'files' | 'attack' | 'logs' | 'report' | 'operations' | 'timeline' setActiveRailView: (v: HecateStore['activeRailView']) => void theme: 'dark' | 'light' setTheme: (t: 'dark' | 'light') => void diff --git a/src/views/Dashboard.tsx b/src/views/Dashboard.tsx index 5dc112b..bb0040e 100644 --- a/src/views/Dashboard.tsx +++ b/src/views/Dashboard.tsx @@ -19,6 +19,7 @@ import { OperationsPanel } from '@/components/OperationsPanel/OperationsPane import { CredentialsPanel } from '@/components/CredentialsPanel/CredentialsPanel' import { EventLogPanel } from '@/components/EventLogPanel/EventLogPanel' import { ProxiesPanel } from '@/components/ProxiesPanel/ProxiesPanel' +import { TimelinePanel } from '@/components/TimelinePanel/TimelinePanel' import { CallbackToastContainer } from '@/components/Toast/CallbackToast' import { ProxyToastContainer } from '@/components/Toast/ProxyToast' import { SettingsPanel } from '@/components/SettingsPanel/SettingsPanel' @@ -160,7 +161,7 @@ export function Dashboard() { useWarningBadge() const activeRailView = useStore((s) => s.activeRailView) - const isFullPanel = activeRailView === 'overview' || activeRailView === 'payloads' || activeRailView === 'services' || activeRailView === 'proxies' || activeRailView === 'report' || activeRailView === 'files' || activeRailView === 'operations' || activeRailView === 'credentials' || activeRailView === 'logs' + const isFullPanel = activeRailView === 'overview' || activeRailView === 'payloads' || activeRailView === 'services' || activeRailView === 'proxies' || activeRailView === 'report' || activeRailView === 'files' || activeRailView === 'operations' || activeRailView === 'credentials' || activeRailView === 'logs' || activeRailView === 'timeline' return (
@@ -184,6 +185,7 @@ export function Dashboard() { {activeRailView === 'operations' && } {activeRailView === 'credentials' && } {activeRailView === 'logs' && } + {activeRailView === 'timeline' && }
) : ( <>