Further taskview improvements

This commit is contained in:
hunterino-sec
2026-04-05 13:12:34 +02:00
parent 598cd45d73
commit ff625fa91c
22 changed files with 906 additions and 191 deletions
+28 -3
View File
@@ -88,7 +88,7 @@ export const GET_CALLBACKS = gql`
// SUBSCRIPTIONS
// ─────────────────────────────────────────────────────
// Live callback list
// Live callback list — sidebar shows active only, topology shows all
export const SUB_CALLBACKS = gql`
${CALLBACK_FIELDS}
subscription SubCallbacks($operation_id: Int!) {
@@ -99,6 +99,17 @@ export const SUB_CALLBACKS = gql`
}
`
// All callbacks (including inactive) for topology
export const SUB_ALL_CALLBACKS = gql`
${CALLBACK_FIELDS}
subscription SubAllCallbacks($operation_id: Int!) {
callback(
where: { operation_id: { _eq: $operation_id } }
order_by: { last_checkin: desc }
) { ...CallbackFields }
}
`
// Task list for a callback — note: NO responses embedded here (too expensive)
// response_count tells us if there's output to fetch
export const SUB_TASKS = gql`
@@ -131,9 +142,23 @@ export const SUB_TASK_RESPONSES = gql`
`
// Mutations
// callback_id must be the callback's display_id (not the internal id).
// tasking_location "command_line" tells Mythic to treat params as raw CLI input.
export const CREATE_TASK = gql`
mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
createTask(callback_id: $callback_id, command: $command, params: $params) {
mutation CreateTask(
$callback_id: Int!
$command: String!
$params: String!
$tasking_location: String
$original_params: String
) {
createTask(
callback_id: $callback_id
command: $command
params: $params
tasking_location: $tasking_location
original_params: $original_params
) {
status
error
id
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+46 -20
View File
@@ -1,13 +1,52 @@
/* hecate/src/components/CommandBar/CommandBar.module.css */
/* src/components/CommandBar/CommandBar.module.css */
.wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
}
/* ── Error banner ── */
.errorBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 18px;
background: rgba(120,20,20,0.3);
border-top: 1px solid rgba(180,50,50,0.4);
}
.errorText {
font-size: 9px;
color: var(--status-err-text);
font-family: var(--font-mono);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.errorDismiss {
background: transparent;
border: none;
cursor: pointer;
font-size: 9px;
color: var(--status-err-text);
opacity: 0.6;
padding: 0 2px;
flex-shrink: 0;
}
.errorDismiss:hover { opacity: 1; }
/* ── Command input row ── */
.cmdBar {
padding: 10px 18px;
border-top: 1px solid var(--border-strong);
border-top: 1px solid var(--beige-border);
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-raised);
flex-shrink: 0;
}
.prefix {
@@ -26,29 +65,16 @@
font-family: var(--font-mono);
font-size: 11px;
color: var(--bone-100);
caret-color: transparent; /* we show our own cursor */
caret-color: var(--crimson-400);
}
.input::placeholder {
color: var(--bone-800);
}
.input:disabled {
opacity: 0.4;
}
.cursor {
display: inline-block;
width: 7px;
height: 12px;
background: var(--crimson-400);
animation: blink 1s step-end infinite;
flex-shrink: 0;
}
.input::placeholder { color: var(--bone-800); }
.input:disabled { opacity: 0.4; }
.sending {
font-size: 9px;
color: var(--bone-800);
text-transform: uppercase;
letter-spacing: 0.1em;
flex-shrink: 0;
}
+61 -38
View File
@@ -1,5 +1,5 @@
/* ═══════════════════════════════════════════════════
hecate/src/components/CommandBar/CommandBar.tsx
src/components/CommandBar/CommandBar.tsx
═══════════════════════════════════════════════════ */
import { useState, useRef, useCallback } from 'react'
@@ -9,35 +9,52 @@ import { useStore } from '@/store'
import styles from './CommandBar.module.css'
export function CommandBar() {
const [input, setInput] = useState('')
const [input, setInput] = useState('')
const [histIdx, setHistIdx] = useState(-1)
const [history, setHistory] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { selectedCallbackId } = useStore()
const { selectedCallbackId, callbacks } = useStore()
const [createTask, { loading }] = useMutation(CREATE_TASK)
// Use display_id — Mythic's createTask takes display_id, not internal id
const cb = callbacks.find(c => c.id === selectedCallbackId)
const displayId = cb?.display_id ?? null
const prompt = cb ? `${cb.host}` : 'hecate'
const [createTask, { loading }] = useMutation(CREATE_TASK, {
onError: (err) => setError(err.message),
})
const handleSubmit = useCallback(async () => {
const raw = input.trim()
if (!raw || !selectedCallbackId) return
if (!raw || !displayId) return
// Split "command [params]"
const [command, ...rest] = raw.split(' ')
const params = rest.join(' ')
setError(null)
setHistory((h) => [raw, ...h.slice(0, 99)])
// Split into command + raw params — Mythic handles further parsing
const spaceIdx = raw.indexOf(' ')
const command = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx)
const params = spaceIdx === -1 ? '' : raw.slice(spaceIdx + 1)
setHistory(h => [raw, ...h.slice(0, 99)])
setHistIdx(-1)
setInput('')
try {
await createTask({
variables: { callback_id: selectedCallbackId, command, params },
})
} catch (err) {
console.error('Task creation failed:', err)
const result = await createTask({
variables: {
callback_id: displayId,
command,
params,
tasking_location: 'command_line',
original_params: params,
},
}).catch(() => null)
if (result?.data?.createTask?.status === 'error') {
setError(result.data.createTask.error ?? 'Task creation failed')
}
}, [input, selectedCallbackId, createTask])
}, [input, displayId, createTask])
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
@@ -45,7 +62,10 @@ export function CommandBar() {
handleSubmit()
return
}
// History navigation
if (e.key === 'Escape') {
setError(null)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
const next = Math.min(histIdx + 1, history.length - 1)
@@ -61,28 +81,31 @@ export function CommandBar() {
}
}
const { callbacks, selectedCallbackId: cbId } = useStore()
const cb = callbacks.find((c) => c.id === cbId)
const prompt = cb ? `hecate:${cb.host}` : 'hecate'
return (
<div className={styles.cmdBar}>
<span className={styles.prefix}>{prompt} </span>
<input
ref={inputRef}
className={styles.input}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={selectedCallbackId ? 'issue task…' : 'select a callback first…'}
disabled={!selectedCallbackId || loading}
autoFocus
autoComplete="off"
spellCheck={false}
/>
{!loading && <span className={styles.cursor} />}
{loading && <span className={styles.sending}>sending</span>}
<div className={styles.wrap}>
{error && (
<div className={styles.errorBar}>
<span className={styles.errorText}>{error}</span>
<button className={styles.errorDismiss} onClick={() => setError(null)}></button>
</div>
)}
<div className={styles.cmdBar}>
<span className={styles.prefix}>{prompt} </span>
<input
ref={inputRef}
className={styles.input}
type="text"
value={input}
onChange={e => { setInput(e.target.value); setError(null) }}
onKeyDown={handleKeyDown}
placeholder={selectedCallbackId ? 'command [args]…' : 'select a callback first…'}
disabled={!selectedCallbackId || loading}
autoFocus
autoComplete="off"
spellCheck={false}
/>
{loading && <span className={styles.sending}>sending</span>}
</div>
</div>
)
}
+1 -1
View File
@@ -2,7 +2,7 @@
.rail {
background: var(--bg-raised);
border-right: 1px solid var(--border-subtle);
border-right: 1px solid var(--beige-border);
display: flex;
flex-direction: column;
align-items: center;
@@ -2,14 +2,14 @@
.panel {
background: var(--bg-raised);
border-left: 1px solid var(--border-default);
border-left: 1px solid var(--beige-border-md);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.section {
padding: 12px 14px;
border-bottom: 1px solid var(--border-default);
border-bottom: 1px solid var(--beige-border);
}
/* ── Stats ── */
+209 -72
View File
@@ -1,29 +1,219 @@
/* ═══════════════════════════════════════════════════
hecate/src/components/RightPanel/RightPanel.tsx
src/components/RightPanel/RightPanel.tsx
═══════════════════════════════════════════════════ */
import { useQuery } from '@apollo/client'
import { GET_OPERATIONS } from '@/apollo/operations'
import { useQuery, useSubscription } from '@apollo/client'
import { GET_OPERATIONS, SUB_ALL_CALLBACKS } from '@/apollo/operations'
import { useStore, useSelectedCallback, useAliveCallbacks } from '@/store'
import styles from './RightPanel.module.css'
import type { Callback } from '@/store'
import { parseTs } from '@/components/Sidebar/utils'
import styles from './RightPanel.module.css'
// ── Protocol helpers ──────────────────────────────────
type ProtoInfo = { color: string; dash?: string; short: string }
function protocolInfo(c2name: string): ProtoInfo {
const n = c2name.toLowerCase()
if (n.includes('http')) return { color: '#90d880', short: 'HTTP' }
if (n.includes('smb')) return { color: '#EFEFDA', short: 'SMB', dash: '4 2' }
if (n.includes('tcp')) return { color: '#d0a848', short: 'TCP', dash: '2 2' }
if (n.includes('ws') || n.includes('socket'))
return { color: '#c090e0', short: 'WS', dash: '6 2' }
if (n.includes('dns')) return { color: '#80c8d0', short: 'DNS', dash: '1 3' }
return { color: 'rgba(208,56,56,0.7)', short: c2name.toUpperCase().slice(0,4) }
}
// ── Late-checkin detection ────────────────────────────
// Mythic sleep_info formats: "60", "10s", "2m", "1h", "60 10" (interval jitter%)
function parseSleepSeconds(raw: string): number {
if (!raw || raw === '—') return 0
const first = raw.trim().toLowerCase().split(/\s+/)[0]
if (first.endsWith('h')) return parseFloat(first) * 3600
if (first.endsWith('m')) return parseFloat(first) * 60
if (first.endsWith('s')) return parseFloat(first)
const n = parseFloat(first)
return isNaN(n) ? 0 : n
}
function isLateCheckin(cb: Callback): boolean {
const sleepSecs = parseSleepSeconds(cb.sleep_info)
const elapsed = (Date.now() - parseTs(cb.last_checkin).getTime()) / 1000
// sleep=0 means continuous check-in — threshold is just the 5-min grace window
// sleep>0 — threshold is the interval plus 5-min grace
const threshold = sleepSecs > 0 ? sleepSecs + 300 : 300
return elapsed > threshold
}
// ── Topology SVG ──────────────────────────────────────
const C2_X = 122
const C2_Y = 28
const C2_R = 11
function NetworkTopology({ callbacks, selectedId }: { callbacks: Callback[]; selectedId: number | null }) {
const visible = callbacks.slice(0, 6)
const total = visible.length
// Collect unique protocols for legend
const legendProtos = Array.from(
new Map(
callbacks.map(cb => {
const name = cb.callbackc2profiles[0]?.c2profile.name ?? 'unknown'
const info = protocolInfo(name)
return [info.short, info]
})
).values()
)
return (
<svg className={styles.netSvg} viewBox="0 0 244 170">
{/* ── C2 hub ── */}
<circle
cx={C2_X} cy={C2_Y} r={C2_R}
fill="#2a0808"
stroke="var(--crimson-500)"
strokeWidth="1.5"
/>
<text x={C2_X} y={C2_Y + 3} textAnchor="middle" fontFamily="monospace" fontSize="6.5" fill="var(--crimson-300)">
C2
</text>
{/* ── Agent nodes ── */}
{visible.map((cb, i) => {
const angle = (i / Math.max(total, 1)) * Math.PI * 1.35 + Math.PI * (-0.175)
const r = 66
const nx = C2_X + Math.cos(angle) * r
const ny = 100 + Math.sin(angle) * 36
const c2name = cb.callbackc2profiles[0]?.c2profile.name ?? 'unknown'
const proto = protocolInfo(c2name)
const alive = cb.active
const isSel = cb.id === selectedId
const late = alive && isLateCheckin(cb)
// Dead connection: very sparse dash — visually "broken"
const lineColor = !alive ? 'rgba(80,30,30,0.35)' : proto.color
const lineDash = !alive ? '3 3' : late ? '2 8' : proto.dash
const lineWidth = isSel ? 1.8 : 1
const lineOpacity = alive ? (late ? 0.35 : 0.75) : 0.4
// Midpoint for protocol label
const mx = (C2_X + nx) / 2
const my = (C2_Y + ny) / 2 - 4
return (
<g key={cb.id}>
{/* Connection line */}
<line
x1={C2_X} y1={C2_Y + C2_R}
x2={nx} y2={ny - 9}
stroke={lineColor}
strokeWidth={lineWidth}
strokeDasharray={lineDash}
opacity={lineOpacity}
/>
{/* Protocol label on the line */}
{alive && (
<g>
<rect
x={mx - 9} y={my - 6}
width={18} height={9}
rx="1"
fill="#0d0806"
opacity="0.85"
/>
<text
x={mx} y={my + 1}
textAnchor="middle"
fontFamily="monospace"
fontSize="5.5"
fontWeight="bold"
fill={lineColor}
>
{proto.short}
</text>
</g>
)}
{/* Agent node */}
<circle
cx={nx} cy={ny} r="9"
fill={isSel ? '#3a0c0c' : '#160808'}
stroke={isSel ? lineColor : (alive ? `${lineColor}99` : '#3a1818')}
strokeWidth={isSel ? 1.8 : 1}
/>
{/* Live pulse dot — hidden when late (no active connection) */}
{alive && !late && (
<circle
cx={nx + 6.5} cy={ny - 6.5} r="2.5"
fill="var(--status-alive)"
opacity="0.9"
/>
)}
{/* Hostname */}
<text
x={nx} y={ny + 19}
textAnchor="middle"
fontFamily="monospace"
fontSize="5.5"
fill={isSel ? 'var(--beige)' : 'var(--bone-600)'}
>
{cb.host.slice(0, 10)}
</text>
</g>
)
})}
{callbacks.length === 0 && (
<text x={C2_X} y="95" textAnchor="middle" fontFamily="monospace" fontSize="8" fill="var(--bone-800)">
no agents
</text>
)}
{/* ── Protocol legend ── */}
{legendProtos.length > 0 && (
<g transform="translate(6, 150)">
{legendProtos.map((p, i) => (
<g key={p.short} transform={`translate(${i * 52}, 0)`}>
<line x1="0" y1="5" x2="12" y2="5"
stroke={p.color}
strokeWidth="1.5"
strokeDasharray={p.dash}
/>
<text x="15" y="8" fontFamily="monospace" fontSize="6" fill={p.color}>
{p.short}
</text>
</g>
))}
</g>
)}
</svg>
)
}
// ── Main panel ────────────────────────────────────────
export function RightPanel() {
const allCallbacks = useStore((s) => s.callbacks)
const currentTasks = useStore((s) => s.currentTasks)
const aliveCallbacks = useAliveCallbacks() // active:true only — for stats
const currentTasks = useStore((s) => s.currentTasks)
const selectedCallbackId = useStore((s) => s.selectedCallbackId)
const aliveCallbacks = useAliveCallbacks()
const selected = useSelectedCallback()
const activeOp = useStore((s) => s.activeOperation)
const selected = useSelectedCallback()
const activeOp = useStore((s) => s.activeOperation)
const { data: opData } = useQuery(GET_OPERATIONS, {
// All callbacks (including inactive) for topology — shows dead nodes too
const { data: allCbData } = useSubscription(SUB_ALL_CALLBACKS, {
variables: { operation_id: activeOp?.id ?? 0 },
skip: !activeOp,
fetchPolicy: 'cache-first',
})
const allCallbacks: Callback[] = allCbData?.callback ?? aliveCallbacks
// Count operators from the operation
const operatorCount = opData?.operation?.[0]
? 1 // Will expand when we add operator list to operations query
: 1
useQuery(GET_OPERATIONS, { skip: !activeOp, fetchPolicy: 'cache-first' })
const errorCount = currentTasks.filter(t => t.status === 'error').length
@@ -58,60 +248,7 @@ export function RightPanel() {
{/* ── Network topology ── */}
<div className={styles.section}>
<div className="sec-label">Network topology</div>
<svg className={styles.netSvg} viewBox="0 0 244 148">
{/* C2 hub */}
<circle cx="122" cy="30" r="12" fill="#2a0808" stroke="var(--crimson-500)" strokeWidth="1.5" />
<text x="122" y="34" textAnchor="middle" fontFamily="monospace" fontSize="7" fill="var(--crimson-300)">C2</text>
{allCallbacks.slice(0, 6).map((cb, i) => {
const total = Math.min(allCallbacks.length, 6)
const angle = (i / total) * Math.PI * 1.4 + Math.PI * (-0.2)
const r = 72
const x = 122 + Math.cos(angle) * r
const y = 85 + Math.sin(angle) * 38
const alive = cb.active
const isSelected = cb.id === selectedCallbackId
return (
<g key={cb.id}>
<line
x1="122" y1="42"
x2={x} y2={y - 9}
stroke={alive ? 'rgba(208,56,56,0.5)' : 'rgba(80,20,20,0.3)'}
strokeWidth={isSelected ? 1.5 : 0.8}
strokeDasharray={alive ? undefined : '3 3'}
/>
<circle
cx={x} cy={y} r="9"
fill={isSelected ? '#3a0808' : '#1a0606'}
stroke={alive ? 'var(--crimson-500)' : '#3a1818'}
strokeWidth={isSelected ? 1.5 : 1}
/>
{alive && (
<circle cx={x + 7} cy={y - 7} r="2.5"
fill="var(--status-alive)"
opacity="0.9"
/>
)}
<text
x={x} y={y + 20}
textAnchor="middle"
fontFamily="monospace"
fontSize="6"
fill="var(--bone-600)"
>
{cb.host.slice(0, 9)}
</text>
</g>
)
})}
{allCallbacks.length === 0 && (
<text x="122" y="90" textAnchor="middle" fontFamily="monospace" fontSize="8" fill="var(--bone-800)">
no agents
</text>
)}
</svg>
<NetworkTopology callbacks={allCallbacks} selectedId={selectedCallbackId} />
</div>
{/* ── Selected agent detail ── */}
@@ -144,9 +281,9 @@ export function RightPanel() {
<div className="sec-label">Task status</div>
<div className={styles.taskBreakdown}>
{[
{ label: 'Completed', count: currentTasks.filter(t => t.completed).length, color: 'var(--status-ok-text)' },
{ label: 'Running', count: currentTasks.filter(t => !t.completed && t.status !== 'error').length, color: 'var(--crimson-300)' },
{ label: 'Errors', count: errorCount, color: 'var(--status-err-text)' },
{ label: 'Completed', count: currentTasks.filter(t => t.completed).length, color: 'var(--status-ok-text)' },
{ label: 'Running', count: currentTasks.filter(t => !t.completed && t.status !== 'error').length, color: 'var(--crimson-300)' },
{ label: 'Errors', count: errorCount, color: 'var(--status-err-text)' },
].map(({ label, count, color }) => (
<div key={label} className={styles.taskBreakdownRow}>
<span className={styles.tbLabel}>{label}</span>
+2 -2
View File
@@ -2,7 +2,7 @@
.sidebar {
background: var(--bg-raised);
border-right: 1px solid var(--border-default);
border-right: 1px solid var(--beige-border-md);
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -12,7 +12,7 @@
.detail {
flex: 1;
border-top: 1px solid var(--border-default);
border-top: 1px solid var(--beige-border);
}
.empty {
+4 -3
View File
@@ -5,7 +5,7 @@
import { useSubscription } from '@apollo/client'
import { SUB_CALLBACKS } from '@/apollo/operations'
import { useStore, useSelectedCallback } from '@/store'
import { integrityLabel, timeSince } from './utils'
import { integrityLabel, timeSince, parseTs } from './utils'
import styles from './Sidebar.module.css'
export function Sidebar() {
@@ -36,8 +36,9 @@ export function Sidebar() {
)}
{callbacks.map((cb) => {
const alive = Date.now() - new Date(cb.last_checkin).getTime() < 60_000
const idle = !alive && Date.now() - new Date(cb.last_checkin).getTime() < 600_000
const elapsed = Date.now() - parseTs(cb.last_checkin).getTime()
const alive = elapsed < 60_000
const idle = !alive && elapsed < 600_000
const statusClass = alive ? styles.alive : idle ? styles.idle : styles.dead
return (
+14 -4
View File
@@ -1,10 +1,20 @@
/* hecate/src/components/Sidebar/utils.ts */
// Mythic stores timestamps as UTC but Hasura returns them without a 'Z' suffix.
// new Date("2024-01-15T14:23:01") is parsed as LOCAL time by JS engines,
// making every timestamp appear offset by your UTC offset.
// Force UTC by appending 'Z' only when no timezone designator is present.
export function parseTs(iso: string): Date {
if (!iso) return new Date(0)
return new Date(/[Z+]/.test(iso) ? iso : iso + 'Z')
}
export function timeSince(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`
return `${Math.floor(diff / 3600_000)}h ago`
const diff = Date.now() - parseTs(iso).getTime()
if (diff < 0) return 'just now'
if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
return `${Math.floor(diff / 3_600_000)}h ago`
}
export function integrityLabel(level: number): string {
@@ -121,6 +121,63 @@
.waitDot:nth-child(2) { animation-delay: 0.15s; }
.waitDot:nth-child(3) { animation-delay: 0.30s; }
/* ── Inline ls / file-browser ── */
.lsWrap {
display: flex;
flex-direction: column;
margin-top: 4px;
border: 1px solid var(--beige-border);
border-radius: 3px;
overflow: hidden;
}
.lsToggle {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 10px;
background: var(--bg-raised);
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: background var(--t-fast);
}
.lsToggle:hover { background: var(--beige-dim); }
.lsIcon { font-size: 11px; }
.lsPath {
font-family: var(--font-mono);
font-size: 10px;
color: var(--beige);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lsMeta {
font-family: var(--font-mono);
font-size: 8px;
color: var(--bone-800);
letter-spacing: 0.04em;
flex-shrink: 0;
}
.lsChevron {
font-size: 7px;
color: var(--bone-800);
flex-shrink: 0;
}
/* Capped height so an ls doesn't take over the whole console */
.lsBrowserWrap {
max-height: 260px;
overflow-y: auto;
border-top: 1px solid var(--beige-border);
}
/* ── Block cursor after streaming output ── */
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
+32 -5
View File
@@ -9,6 +9,7 @@ import { useRef, useEffect, useState } from 'react'
import { useSubscription } from '@apollo/client'
import { SUB_TASK_RESPONSES } from '@/apollo/operations'
import type { Task } from '@/store'
import { FileBrowser, parseLsOutput } from './FileBrowser'
import styles from './ConsoleView.module.css'
// ── Helpers ───────────────────────────────────────────
@@ -33,7 +34,8 @@ function formatTimestamp(iso: string): string {
// ── Single task entry inside the console ─────────────
function ConsoleEntry({ task, isLast }: { task: Task; isLast: boolean }) {
const [lines, setLines] = useState<Array<{ id: number; response: string }>>([])
const [lines, setLines] = useState<Array<{ id: number; response: string }>>([])
const [expanded, setExpanded] = useState(false)
const hasOutput = task.response_count > 0
const isRunning = !task.completed && !task.status.toLowerCase().includes('error')
@@ -53,6 +55,7 @@ function ConsoleEntry({ task, isLast }: { task: Task; isLast: boolean }) {
})
const fullOutput = lines.map(r => decodeResponse(r.response)).join('')
const lsResult = fullOutput ? parseLsOutput(fullOutput) : null
const displayArgs = (task.display_params && task.display_params !== '{}' && task.display_params !== '')
? task.display_params
@@ -74,9 +77,33 @@ function ConsoleEntry({ task, isLast }: { task: Task; isLast: boolean }) {
{/* Output */}
{fullOutput && (
<pre className={`${styles.output} ${isError ? styles.outputErr : ''}`}>
{fullOutput}
</pre>
lsResult ? (
// File-explorer output — collapsed by default to avoid giant scroll
<div className={styles.lsWrap}>
<button className={styles.lsToggle} onClick={() => setExpanded(x => !x)}>
<span className={styles.lsIcon}>📂</span>
<span className={styles.lsPath}>
{[lsResult.parent_path, lsResult.name].filter(Boolean).join('').replace(/\\$/, '')}
</span>
<span className={styles.lsMeta}>
{lsResult.files.filter(f => !f.is_file).length}d {lsResult.files.filter(f => f.is_file).length}f
</span>
<span className={styles.lsChevron}>{expanded ? '▲' : '▼'}</span>
</button>
{expanded && (
<div className={styles.lsBrowserWrap}>
<FileBrowser
result={lsResult}
callbackDisplayId={task.callback.display_id}
/>
</div>
)}
</div>
) : (
<pre className={`${styles.output} ${isError ? styles.outputErr : ''}`}>
{fullOutput}
</pre>
)
)}
{/* Running: waiting indicator (only on last/active task) */}
@@ -89,7 +116,7 @@ function ConsoleEntry({ task, isLast }: { task: Task; isLast: boolean }) {
)}
{/* Running: inline pulse after output */}
{isRunning && fullOutput && isLast && (
{isRunning && fullOutput && !lsResult && isLast && (
<div className={styles.cursor} />
)}
</div>
@@ -0,0 +1,175 @@
/* src/components/TaskFeed/FileBrowser.module.css */
.browser {
display: flex;
flex-direction: column;
padding: 8px 0;
min-height: 0;
}
/* ── Path bar ── */
.pathBar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px 8px;
border-bottom: 1px solid var(--beige-border);
}
.pathIcon { font-size: 11px; }
.pathText {
font-family: var(--font-mono);
font-size: 10px;
color: var(--beige);
letter-spacing: 0.03em;
flex: 1;
}
.pathHost {
font-size: 9px;
color: var(--bone-800);
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: 2px;
padding: 1px 5px;
letter-spacing: 0.04em;
}
/* ── Table ── */
.table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 10px;
}
.table thead tr {
border-bottom: 1px solid var(--beige-border);
}
.table th {
text-align: left;
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--bone-800);
padding: 4px 10px;
font-weight: 400;
}
.thIcon { width: 24px; padding-left: 14px; }
.thName { /* flex */ }
.thSize { width: 72px; text-align: right; }
.thOwner { width: 90px; }
.thDate { width: 110px; }
.thAct { width: 48px; }
/* ── Rows ── */
.row {
border-bottom: 1px solid rgba(239,239,218,0.05);
transition: background var(--t-fast);
}
.row:hover { background: var(--beige-dim); }
.row.hidden { opacity: 0.5; }
.tdIcon {
padding: 5px 4px 5px 14px;
font-size: 11px;
line-height: 1;
}
.tdName {
padding: 5px 10px;
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileName { color: var(--bone-300); }
.dirName { color: var(--beige); }
.hiddenBadge {
margin-left: 5px;
font-size: 7px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--bone-800);
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: 2px;
padding: 0 3px;
vertical-align: middle;
}
.tdSize {
padding: 5px 10px;
color: var(--bone-600);
text-align: right;
white-space: nowrap;
}
.tdOwner {
padding: 5px 10px;
color: var(--bone-800);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90px;
}
.tdDate {
padding: 5px 10px;
color: var(--bone-800);
white-space: nowrap;
}
.tdAct {
padding: 4px 8px 4px 4px;
}
/* ── Action buttons ── */
.actBtn {
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: 2px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.05em;
color: var(--bone-800);
padding: 2px 5px;
white-space: nowrap;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.actBtn:hover {
color: var(--bone-100);
background: rgba(180,50,50,0.2);
border-color: var(--border-default);
}
.actNav:hover {
color: var(--beige);
background: rgba(239,239,218,0.08);
border-color: var(--beige-border-md);
}
/* ── Footer ── */
.empty {
padding: 12px 14px;
font-size: 9px;
color: var(--bone-900);
font-style: italic;
}
.footer {
padding: 6px 14px;
font-size: 8px;
color: var(--bone-900);
border-top: 1px solid var(--beige-border);
letter-spacing: 0.04em;
margin-top: 2px;
}
+204
View File
@@ -0,0 +1,204 @@
/* ═══════════════════════════════════════════════════
src/components/TaskFeed/FileBrowser.tsx
Renders Mythic `ls` JSON output as a file explorer.
Parses concatenated JSON chunks, deduplicates by full_name.
═══════════════════════════════════════════════════ */
import { useCallback } from 'react'
import { useMutation } from '@apollo/client'
import { CREATE_TASK } from '@/apollo/operations'
import styles from './FileBrowser.module.css'
// ── Types ─────────────────────────────────────────────
interface LsFile {
name: string
full_name: string
is_file: boolean
size?: number
hidden?: boolean
owner?: string
modify_time?: number // epoch ms
extended_attributes?: string
directory?: string
}
interface LsResult {
success: boolean
host?: string
name?: string
parent_path?: string
is_file?: boolean
files: LsFile[]
}
// ── Parsing ───────────────────────────────────────────
function parseJsonObjects(text: string): LsResult[] {
const results: LsResult[] = []
// Try single parse first
try {
const obj = JSON.parse(text)
if (obj && Array.isArray(obj.files)) return [obj as LsResult]
} catch { /* fall through to multi-object parse */ }
// Multiple concatenated JSON objects — scan for object boundaries
let depth = 0
let start = -1
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (ch === '{') {
if (depth === 0) start = i
depth++
} else if (ch === '}') {
depth--
if (depth === 0 && start !== -1) {
try {
const obj = JSON.parse(text.slice(start, i + 1))
if (obj && Array.isArray(obj.files)) results.push(obj as LsResult)
} catch { /* skip malformed */ }
start = -1
}
}
}
return results
}
export function parseLsOutput(raw: string): LsResult | null {
if (!raw.trim().startsWith('{')) return null
const chunks = parseJsonObjects(raw)
if (!chunks.length) return null
// Merge: use first chunk's metadata, combine all files, deduplicate
const base = chunks[0]
const fileMap = new Map<string, LsFile>()
chunks.forEach(chunk => chunk.files.forEach(f => fileMap.set(f.full_name ?? f.name, f)))
return { ...base, files: Array.from(fileMap.values()) }
}
// ── Formatters ────────────────────────────────────────
function fmtSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`
return `${(bytes / 1073741824).toFixed(2)} GB`
}
function fmtDate(epochMs?: number): string {
if (!epochMs) return '—'
return new Date(epochMs).toLocaleString([], {
month: '2-digit', day: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
})
}
// ── Component ─────────────────────────────────────────
interface Props {
result: LsResult
callbackDisplayId: number
}
export function FileBrowser({ result, callbackDisplayId }: Props) {
const [createTask] = useMutation(CREATE_TASK)
const issueCmd = useCallback((command: string, params: string) => {
createTask({
variables: {
callback_id: callbackDisplayId,
command,
params,
tasking_location: 'command_line',
original_params: params,
},
})
}, [callbackDisplayId, createTask])
// Sort: directories first, then files, each alphabetically
const sorted = [...result.files].sort((a, b) => {
if (a.is_file !== b.is_file) return a.is_file ? 1 : -1
return a.name.localeCompare(b.name)
})
const dirPath = result.parent_path
? `${result.parent_path}${result.name ?? ''}`.replace(/\\$/, '')
: result.name ?? ''
return (
<div className={styles.browser}>
{/* ── Path bar ── */}
<div className={styles.pathBar}>
<span className={styles.pathIcon}>📂</span>
<span className={styles.pathText}>{dirPath}</span>
{result.host && <span className={styles.pathHost}>{result.host}</span>}
</div>
{/* ── Table ── */}
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thIcon} />
<th className={styles.thName}>Name</th>
<th className={styles.thSize}>Size</th>
<th className={styles.thOwner}>Owner</th>
<th className={styles.thDate}>Modified</th>
<th className={styles.thAct} />
</tr>
</thead>
<tbody>
{sorted.map(f => (
<tr
key={f.full_name ?? f.name}
className={`${styles.row} ${f.hidden ? styles.hidden : ''}`}
>
<td className={styles.tdIcon}>
{f.is_file ? '📄' : '📁'}
</td>
<td className={styles.tdName}>
<span className={f.is_file ? styles.fileName : styles.dirName}>
{f.name}
</span>
{f.hidden && <span className={styles.hiddenBadge}>hidden</span>}
</td>
<td className={styles.tdSize}>{f.is_file ? fmtSize(f.size) : '—'}</td>
<td className={styles.tdOwner} title={f.owner}>{f.owner?.split('\\').pop() ?? '—'}</td>
<td className={styles.tdDate}>{fmtDate(f.modify_time)}</td>
<td className={styles.tdAct}>
{f.is_file ? (
<button
className={styles.actBtn}
title={`download ${f.full_name}`}
onClick={() => issueCmd('download', f.full_name ?? f.name)}
>
dl
</button>
) : (
<button
className={`${styles.actBtn} ${styles.actNav}`}
title={`ls ${f.full_name}`}
onClick={() => issueCmd('ls', f.full_name ?? f.name)}
>
ls
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && (
<div className={styles.empty}>(empty directory)</div>
)}
<div className={styles.footer}>
{sorted.filter(f => !f.is_file).length} dirs · {sorted.filter(f => f.is_file).length} files
</div>
</div>
)
}
+2 -2
View File
@@ -14,7 +14,7 @@
align-items: center;
gap: 8px;
padding: 5px 14px;
border-bottom: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--beige-border);
background: var(--bg-raised);
flex-shrink: 0;
}
@@ -61,7 +61,7 @@
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-subtle);
border-right: 1px solid var(--beige-border);
min-height: 0;
background: var(--bg-base);
}
@@ -7,7 +7,7 @@
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border-subtle);
border-left: 1px solid var(--beige-border);
background: var(--bg-void);
}
@@ -19,7 +19,7 @@
align-items: center;
justify-content: center;
gap: 10px;
border-left: 1px solid var(--border-subtle);
border-left: 1px solid var(--beige-border);
background: var(--bg-void);
}
@@ -43,7 +43,7 @@
gap: 10px;
padding: 8px 12px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border-default);
border-bottom: 1px solid var(--beige-border-md);
flex-shrink: 0;
}
@@ -156,7 +156,7 @@
gap: 6px;
padding: 4px 12px;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--beige-border);
font-size: 8px;
color: var(--bone-800);
letter-spacing: 0.04em;
+13 -1
View File
@@ -8,6 +8,7 @@ import { useRef, useEffect, useState, useCallback } from 'react'
import { useSubscription } from '@apollo/client'
import { SUB_TASK_RESPONSES } from '@/apollo/operations'
import type { Task } from '@/store'
import { FileBrowser, parseLsOutput } from './FileBrowser'
import styles from './TaskOutputPanel.module.css'
function decodeResponse(raw: string): string {
@@ -170,7 +171,18 @@ export function TaskOutputPanel({ task }: Props) {
{/* ── Output ── */}
<div className={styles.outputArea}>
{fullOutput ? (
<pre ref={outputRef} className={styles.outputPre}>{fullOutput}</pre>
(() => {
const lsResult = parseLsOutput(fullOutput)
if (lsResult) {
return (
<FileBrowser
result={lsResult}
callbackDisplayId={task.callback.display_id}
/>
)
}
return <pre ref={outputRef} className={styles.outputPre}>{fullOutput}</pre>
})()
) : isRunning ? (
<div className={styles.waiting}>
<span className={styles.waitDot} />
+33 -23
View File
@@ -1,4 +1,4 @@
/* hecate/src/components/Topbar/Topbar.module.css */
/* src/components/Topbar/Topbar.module.css */
.topbar {
display: flex;
@@ -7,23 +7,34 @@
padding: 0 20px;
height: var(--topbar-h);
background: var(--bg-raised);
border-bottom: 1px solid var(--border-strong);
/* Beige bottom border — structural frame, not action */
border-bottom: 1px solid var(--beige-border-md);
flex-shrink: 0;
}
/* ── Logo mark + wordmark ── */
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logoImg {
width: 28px;
height: 28px;
object-fit: contain;
flex-shrink: 0;
}
.logoText {
font-family: var(--font-serif);
font-size: 17px;
color: var(--bone-200);
letter-spacing: 0.14em;
/* Beige accent — matches the logo's hex border */
color: var(--beige);
letter-spacing: 0.16em;
}
/* ── Centre tagline ── */
.center {
font-size: 9px;
text-transform: uppercase;
@@ -31,6 +42,7 @@
color: var(--bone-800);
}
/* ── Right cluster ── */
.right {
display: flex;
align-items: center;
@@ -39,27 +51,29 @@
.opBadge {
font-size: 9px;
color: var(--bone-800);
border: 1px solid var(--border-default);
color: var(--bone-600);
border: 1px solid var(--beige-border);
padding: 2px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.opBadge:hover { border-color: var(--beige-border-md); }
.opName { color: var(--crimson-400); }
.opName { color: var(--beige); }
.mythicVersion {
font-size: 9px;
color: var(--bone-800);
}
/* ── User avatar button ── */
.userDot {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--glow-crimson);
border: 1px solid var(--border-default);
background: var(--beige-dim);
border: 1px solid var(--beige-border);
display: flex;
align-items: center;
justify-content: center;
@@ -69,24 +83,21 @@
cursor: pointer;
transition: all var(--t-fast);
}
.userDot:hover {
background: var(--glow-crimson-md);
border-color: var(--border-strong);
color: var(--bone-200);
background: var(--beige-dim);
border-color: var(--beige-border-md);
color: var(--beige);
}
/* ── User menu dropdown ── */
.userMenu {
position: relative;
}
/* ── Dropdown ── */
.userMenu { position: relative; }
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border: 1px solid var(--beige-border-md);
border-radius: var(--radius-sm);
min-width: 160px;
z-index: 100;
@@ -107,17 +118,16 @@
letter-spacing: 0.08em;
transition: background var(--t-fast), color var(--t-fast);
}
.dropItem:hover {
background: var(--glow-crimson);
color: var(--bone-100);
background: var(--beige-dim);
color: var(--beige);
}
.dropDanger { color: var(--crimson-400); }
.dropDanger:hover { color: var(--bone-100); background: rgba(139,26,26,0.25); }
.dropDanger:hover { color: var(--crimson-300); background: var(--glow-crimson); }
.dropSep {
height: 1px;
background: var(--border-subtle);
background: var(--beige-border);
margin: 2px 0;
}
+6 -6
View File
@@ -2,11 +2,11 @@
hecate/src/components/Topbar/Topbar.tsx
═══════════════════════════════════════════════════ */
import { useState } from 'react'
import { WgSigil } from '../shared/WgSigil'
import { useStore } from '@/store'
import { mythicLogout } from '@/apollo/client'
import styles from './Topbar.module.css'
import { useState } from 'react'
import logoImg from '@/assets/logo.png'
import { useStore } from '@/store'
import { mythicLogout } from '@/apollo/client'
import styles from './Topbar.module.css'
export function Topbar() {
const { activeOperation, setActiveOperation, token, setToken } = useStore()
@@ -27,7 +27,7 @@ export function Topbar() {
return (
<header className={styles.topbar}>
<div className={styles.logo}>
<WgSigil size={26} />
<img src={logoImg} className={styles.logoImg} alt="WhisperGate" />
<span className={styles.logoText}>HECATE</span>
</div>
+1 -1
View File
@@ -72,7 +72,7 @@ body::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-default);
background: var(--beige-border);
}
/* ── Status badge ── */
+7
View File
@@ -30,6 +30,13 @@
--bone-800: #887860; /* dimmed — was near-invisible */
--bone-900: #584838; /* placeholders */
/* ── Beige / parchment accent (matches logo hex border) ── */
--beige: #EFEFDA; /* pure accent — logo border colour */
--beige-dim: rgba(239,239,218,0.06);/* subtle panel tint */
--beige-border: rgba(239,239,218,0.18);/* structural frame borders */
--beige-border-md: rgba(239,239,218,0.30);/* topbar / panel-divider borders */
--beige-border-strong:rgba(239,239,218,0.50);/* emphasis lines, active frames */
/* ── Semantic ── */
--status-alive: #e06020;
--status-alive-glow: rgba(224,96,32,0.7);
+5 -4
View File
@@ -1,4 +1,4 @@
/* hecate/src/views/Dashboard.module.css */
/* src/views/Dashboard.module.css */
.root {
display: flex;
@@ -7,9 +7,10 @@
overflow: hidden;
}
/* Beige top stripe — matches logo border colour, frames the UI */
.stripe {
height: 2px;
background: var(--crimson-600);
background: linear-gradient(90deg, var(--beige-border-strong), var(--beige), var(--beige-border-strong));
flex-shrink: 0;
}
@@ -30,8 +31,8 @@
}
.mainHeader {
padding: 10px 18px;
border-bottom: 1px solid var(--border-subtle);
padding: 9px 18px;
border-bottom: 1px solid var(--beige-border);
background: var(--bg-raised);
display: flex;
align-items: center;