mirror of
https://github.com/Whispergate/Hecate
synced 2026-06-06 16:57:58 +00:00
Added timeline view
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user