Added timeline view

This commit is contained in:
hunterino-sec
2026-05-13 21:02:10 +02:00
parent 40bd340d9a
commit 9b73709bca
6 changed files with 637 additions and 2 deletions
+22
View File
@@ -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`
+13
View File
@@ -105,6 +105,19 @@ const ITEMS: { id: RailView; title: string; icon: React.ReactNode }[] = [
</svg>
),
},
{
id: 'timeline',
title: 'Attack Timeline',
icon: (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<path d="M2 8h12" />
<circle cx="4" cy="8" r="1.5" fill="currentColor" stroke="none" />
<circle cx="8" cy="8" r="1.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="8" r="1.5" fill="currentColor" stroke="none" />
<path d="M4 5v1.2M8 4v2.2M12 6v0.2" />
</svg>
),
},
{
id: 'attack',
title: 'ATT&CK Matrix',
@@ -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); }
@@ -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<ZoomLevel>(1)
const [selected, setSelected] = useState<EventData | null>(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<number, CallbackTrack>()
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 (
<div className={styles.panel}>
{/* ── Header ── */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.title}>Attack Timeline</span>
<span className={styles.meta}>
{loading ? 'loading…' : `${filtered.length} tasks · ${tracks.length} callbacks`}
</span>
</div>
<div className={styles.headerRight}>
<input
className={styles.filterInput}
placeholder="filter host / command…"
value={filter}
onChange={e => { setFilter(e.target.value); setSelected(null) }}
/>
<div className={styles.zoomBtns}>
{ZOOM_LEVELS.map(z => (
<button
key={z}
className={`${styles.zoomBtn} ${zoom === z ? styles.zoomActive : ''}`}
onClick={() => setZoom(z)}
>{z}×</button>
))}
</div>
</div>
</div>
{/* ── Scroll area ── */}
<div className={styles.scrollArea}>
{tracks.length === 0 && !loading && (
<div className={styles.empty}>
{filterLc ? `no tasks matching "${filter}"` : 'no tasks in this operation'}
</div>
)}
{tracks.length > 0 && (
<div className={styles.inner} style={{ width: `${zoom * 100}%`, minHeight: `${innerH}px` }}>
{/* Time axis */}
<div className={styles.axisRow} style={{ height: `${AXIS_H}px` }}>
<div className={styles.labelCell} style={{ width: `${LABEL_W}px` }}>
<span className={styles.axisLabel}>time</span>
</div>
<div className={styles.axisTrack}>
{ticks.map(tick => (
<div key={tick.ms} className={styles.tick} style={{ left: `${tick.leftPct}%` }}>
<div className={styles.tickNub} />
<span className={styles.tickLabel}>{tick.label}</span>
</div>
))}
</div>
</div>
{/* Callback swimlanes */}
{tracks.map(track => (
<div key={track.cbId} className={styles.trackRow} style={{ height: `${TRACK_H}px` }}>
<div className={styles.labelCell} style={{ width: `${LABEL_W}px` }}>
<span className={styles.trackDot} style={{ background: track.color }} />
<span className={styles.trackHost} title={track.host}>{track.host}</span>
<span className={styles.trackId}>#{track.displayId}</span>
</div>
<div className={styles.trackArea}>
<div className={styles.trackLine} style={{ background: track.color }} />
{track.events.map(ev => (
<button
key={ev.id}
className={[
styles.event,
styles[`ev_${eventStatus(ev)}`],
selected?.id === ev.id ? styles.evSelected : '',
].join(' ')}
style={{ left: `${ev.leftPct}%` }}
onClick={() => setSelected(s => s?.id === ev.id ? null : ev)}
title={`${ev.command_name}${ev.display_params ? ' ' + ev.display_params : ''}\n${ev.status} · ${ev.operator.username}`}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* ── Selected event detail bar ── */}
{selected && (
<div className={styles.detail}>
<button className={styles.detailClose} onClick={() => setSelected(null)}></button>
<div className={styles.detailItem}>
<span className={styles.diLabel}>host</span>
<span className={styles.diValue}>{selected.callback.host}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>callback</span>
<span className={styles.diValue}>#{selected.callback.display_id}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>task</span>
<span className={styles.diValue}>#{selected.display_id}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>command</span>
<span className={`${styles.diValue} ${styles.diMono}`}>
{selected.command_name}{selected.display_params ? ` ${selected.display_params}` : ''}
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>status</span>
<span className={`${styles.diValue} ${styles[`diSt_${eventStatus(selected)}`]}`}>
{selected.status}
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>time</span>
<span className={styles.diValue}>{parseTs(selected.timestamp).toLocaleString()}</span>
</div>
<div className={styles.detailItem}>
<span className={styles.diLabel}>operator</span>
<span className={styles.diValue}>{selected.operator.username}</span>
</div>
</div>
)}
</div>
)
}
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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 (
<div className={styles.root}>
@@ -184,6 +185,7 @@ export function Dashboard() {
{activeRailView === 'operations' && <OperationsPanel />}
{activeRailView === 'credentials' && <CredentialsPanel />}
{activeRailView === 'logs' && <EventLogPanel />}
{activeRailView === 'timeline' && <TimelinePanel />}
</div>
) : (
<>