diff --git a/src/apollo/operations.ts b/src/apollo/operations.ts index cf1ab20..636be55 100644 --- a/src/apollo/operations.ts +++ b/src/apollo/operations.ts @@ -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 diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..857cc6d Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/components/CommandBar/CommandBar.module.css b/src/components/CommandBar/CommandBar.module.css index ab97bd6..67fbf2d 100644 --- a/src/components/CommandBar/CommandBar.module.css +++ b/src/components/CommandBar/CommandBar.module.css @@ -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; } diff --git a/src/components/CommandBar/CommandBar.tsx b/src/components/CommandBar/CommandBar.tsx index 4830ca5..c9c103a 100644 --- a/src/components/CommandBar/CommandBar.tsx +++ b/src/components/CommandBar/CommandBar.tsx @@ -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([]) + const [error, setError] = useState(null) const inputRef = useRef(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) { 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 ( -
- {prompt} › - setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={selectedCallbackId ? 'issue task…' : 'select a callback first…'} - disabled={!selectedCallbackId || loading} - autoFocus - autoComplete="off" - spellCheck={false} - /> - {!loading && } - {loading && sending…} +
+ {error && ( +
+ {error} + +
+ )} +
+ {prompt} › + { 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 && sending…} +
) } diff --git a/src/components/Rail/Rail.module.css b/src/components/Rail/Rail.module.css index 3603534..510c6ad 100644 --- a/src/components/Rail/Rail.module.css +++ b/src/components/Rail/Rail.module.css @@ -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; diff --git a/src/components/RightPanel/RightPanel.module.css b/src/components/RightPanel/RightPanel.module.css index 9c104c7..a9d4447 100644 --- a/src/components/RightPanel/RightPanel.module.css +++ b/src/components/RightPanel/RightPanel.module.css @@ -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 ── */ diff --git a/src/components/RightPanel/RightPanel.tsx b/src/components/RightPanel/RightPanel.tsx index 8683e47..d5db186 100644 --- a/src/components/RightPanel/RightPanel.tsx +++ b/src/components/RightPanel/RightPanel.tsx @@ -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 ( + + + {/* ── C2 hub ── */} + + + C2 + + + {/* ── 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 ( + + {/* Connection line */} + + + {/* Protocol label on the line */} + {alive && ( + + + + {proto.short} + + + )} + + {/* Agent node */} + + + {/* Live pulse dot — hidden when late (no active connection) */} + {alive && !late && ( + + )} + + {/* Hostname */} + + {cb.host.slice(0, 10)} + + + ) + })} + + {callbacks.length === 0 && ( + + no agents + + )} + + {/* ── Protocol legend ── */} + {legendProtos.length > 0 && ( + + {legendProtos.map((p, i) => ( + + + + {p.short} + + + ))} + + )} + + ) +} + +// ── 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 ── */}
Network topology
- - {/* C2 hub */} - - C2 - - {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 ( - - - - {alive && ( - - )} - - {cb.host.slice(0, 9)} - - - ) - })} - - {allCallbacks.length === 0 && ( - - no agents - - )} - +
{/* ── Selected agent detail ── */} @@ -144,9 +281,9 @@ export function RightPanel() {
Task status
{[ - { 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 }) => (
{label} diff --git a/src/components/Sidebar/Sidebar.module.css b/src/components/Sidebar/Sidebar.module.css index f7ec412..6626ad2 100644 --- a/src/components/Sidebar/Sidebar.module.css +++ b/src/components/Sidebar/Sidebar.module.css @@ -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 { diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index a3b7998..b7658bd 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -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 ( diff --git a/src/components/Sidebar/utils.ts b/src/components/Sidebar/utils.ts index b44e399..297f6ac 100644 --- a/src/components/Sidebar/utils.ts +++ b/src/components/Sidebar/utils.ts @@ -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 { diff --git a/src/components/TaskFeed/ConsoleView.module.css b/src/components/TaskFeed/ConsoleView.module.css index be46eed..8318a4e 100644 --- a/src/components/TaskFeed/ConsoleView.module.css +++ b/src/components/TaskFeed/ConsoleView.module.css @@ -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; } diff --git a/src/components/TaskFeed/ConsoleView.tsx b/src/components/TaskFeed/ConsoleView.tsx index 9aeacf9..496fbf0 100644 --- a/src/components/TaskFeed/ConsoleView.tsx +++ b/src/components/TaskFeed/ConsoleView.tsx @@ -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>([]) + const [lines, setLines] = useState>([]) + 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 && ( -
-          {fullOutput}
-        
+ lsResult ? ( + // File-explorer output — collapsed by default to avoid giant scroll +
+ + {expanded && ( +
+ +
+ )} +
+ ) : ( +
+            {fullOutput}
+          
+ ) )} {/* 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 && (
)}
diff --git a/src/components/TaskFeed/FileBrowser.module.css b/src/components/TaskFeed/FileBrowser.module.css new file mode 100644 index 0000000..27cd6a7 --- /dev/null +++ b/src/components/TaskFeed/FileBrowser.module.css @@ -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; +} diff --git a/src/components/TaskFeed/FileBrowser.tsx b/src/components/TaskFeed/FileBrowser.tsx new file mode 100644 index 0000000..af46457 --- /dev/null +++ b/src/components/TaskFeed/FileBrowser.tsx @@ -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() + 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 ( +
+ + {/* ── Path bar ── */} +
+ 📂 + {dirPath} + {result.host && {result.host}} +
+ + {/* ── Table ── */} + + + + + + + + + + + {sorted.map(f => ( + + + + + + + + + ))} + +
+ NameSizeOwnerModified +
+ {f.is_file ? '📄' : '📁'} + + + {f.name} + + {f.hidden && hidden} + {f.is_file ? fmtSize(f.size) : '—'}{f.owner?.split('\\').pop() ?? '—'}{fmtDate(f.modify_time)} + {f.is_file ? ( + + ) : ( + + )} +
+ + {sorted.length === 0 && ( +
(empty directory)
+ )} + +
+ {sorted.filter(f => !f.is_file).length} dirs · {sorted.filter(f => f.is_file).length} files +
+
+ ) +} diff --git a/src/components/TaskFeed/TaskFeed.module.css b/src/components/TaskFeed/TaskFeed.module.css index e4590ee..b5cab6d 100644 --- a/src/components/TaskFeed/TaskFeed.module.css +++ b/src/components/TaskFeed/TaskFeed.module.css @@ -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); } diff --git a/src/components/TaskFeed/TaskOutputPanel.module.css b/src/components/TaskFeed/TaskOutputPanel.module.css index 54b1735..bd33329 100644 --- a/src/components/TaskFeed/TaskOutputPanel.module.css +++ b/src/components/TaskFeed/TaskOutputPanel.module.css @@ -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; diff --git a/src/components/TaskFeed/TaskOutputPanel.tsx b/src/components/TaskFeed/TaskOutputPanel.tsx index 74ab518..3b2f4a8 100644 --- a/src/components/TaskFeed/TaskOutputPanel.tsx +++ b/src/components/TaskFeed/TaskOutputPanel.tsx @@ -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 ── */}
{fullOutput ? ( -
{fullOutput}
+ (() => { + const lsResult = parseLsOutput(fullOutput) + if (lsResult) { + return ( + + ) + } + return
{fullOutput}
+ })() ) : isRunning ? (
diff --git a/src/components/Topbar/Topbar.module.css b/src/components/Topbar/Topbar.module.css index 5955426..f089a7f 100644 --- a/src/components/Topbar/Topbar.module.css +++ b/src/components/Topbar/Topbar.module.css @@ -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; } diff --git a/src/components/Topbar/Topbar.tsx b/src/components/Topbar/Topbar.tsx index 3ca923d..59abce7 100644 --- a/src/components/Topbar/Topbar.tsx +++ b/src/components/Topbar/Topbar.tsx @@ -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 (
- + WhisperGate HECATE
diff --git a/src/styles/global.css b/src/styles/global.css index 7851c7b..fa2e2be 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -72,7 +72,7 @@ body::after { content: ''; flex: 1; height: 1px; - background: var(--border-default); + background: var(--beige-border); } /* ── Status badge ── */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 592129d..d7c9535 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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); diff --git a/src/views/Dashboard.module.css b/src/views/Dashboard.module.css index bb93478..cd1fbe5 100644 --- a/src/views/Dashboard.module.css +++ b/src/views/Dashboard.module.css @@ -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;