mirror of
https://github.com/Whispergate/Hecate
synced 2026-06-06 16:57:58 +00:00
Further taskview improvements
This commit is contained in:
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ body::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
background: var(--beige-border);
|
||||
}
|
||||
|
||||
/* ── Status badge ── */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user