feat: add copyable diagnostic logs

This commit is contained in:
reaper
2026-08-14 17:00:12 -05:00
parent 33976583ae
commit 0e27b8f829
3 changed files with 100 additions and 24 deletions
+28 -8
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Check, Copy, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useLogStream } from '../../hooks/useLogStream'
@@ -14,6 +14,7 @@ function LogPane({
}): React.JSX.Element {
const { lines, isStreaming } = useLogStream(name, service)
const scrollRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const filteredLines = useMemo(() => {
if (!filter.trim()) return lines
@@ -25,22 +26,41 @@ function LogPane({
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [filteredLines.length])
const copyLogs = async (): Promise<void> => {
const report = [
'Aurora Dockside service log',
`Project: ${name}`,
`Service: ${service}`,
`Captured: ${new Date().toISOString()}`,
'',
...filteredLines.map((line) => line.text)
].join('\n')
await navigator.clipboard.writeText(report)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<>
<div className="flex items-center justify-between px-4 py-2">
<span
className={clsx(
'flex items-center gap-1 text-xs',
isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
)}
>
<span
className={clsx(
'h-1.5 w-1.5 rounded-full',
isStreaming ? 'bg-emerald-500' : 'bg-neutral-400'
)}
/>
{isStreaming ? 'streaming' : 'stopped'}
<span className={clsx('h-1.5 w-1.5 rounded-full', isStreaming ? 'bg-emerald-500' : 'bg-neutral-400')} />
{isStreaming ? 'streaming' : 'stopped'} · {filteredLines.length} lines
</span>
<button
type="button"
onClick={() => void copyLogs()}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied' : 'Copy logs'}
</button>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200"
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Check, Copy, X } from 'lucide-react'
import { useTerminalStore } from '../../stores/terminalStore'
export function TerminalPanel(): React.JSX.Element | null {
@@ -9,7 +9,11 @@ export function TerminalPanel(): React.JSX.Element | null {
s.activeOperationId ? s.operations[s.activeOperationId] : null
)
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
const setActiveOperation = useTerminalStore((s) => s.setActiveOperation)
const operationMap = useTerminalStore((s) => s.operations)
const operations = Object.values(operationMap)
const scrollRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
@@ -17,18 +21,60 @@ export function TerminalPanel(): React.JSX.Element | null {
if (!isPanelOpen || !activeOperationId || !operation) return null
const copyDiagnosticLog = async (): Promise<void> => {
const report = [
'Aurora Dockside diagnostic log',
`Operation: ${operation.label}`,
`Status: ${operation.status}`,
`Exit code: ${operation.exitCode ?? 'not finished'}`,
`Started: ${operation.startedAt}`,
`Finished: ${operation.finishedAt ?? 'not finished'}`,
'',
operation.lines.join('').trimEnd()
].join('\n')
await navigator.clipboard.writeText(report)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
}
return (
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
<div className="flex min-w-0 items-center gap-2">
<select
aria-label="Diagnostic operation"
value={activeOperationId}
onChange={(event) => setActiveOperation(event.target.value)}
className="max-w-sm truncate rounded border border-white/10 bg-neutral-900 px-2 py-1 text-xs text-neutral-200"
>
{operations.slice().reverse().map((item) => (
<option key={item.id} value={item.id}>{item.label} {item.status}</option>
))}
</select>
<span className={operation.status === 'error' ? 'text-xs text-red-400' : 'text-xs text-neutral-400'}>
{operation.status}{operation.exitCode !== null ? ` · exit ${operation.exitCode}` : ''}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => void copyDiagnosticLog()}
className="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-neutral-300 transition hover:bg-white/10 hover:text-white"
title="Copy complete diagnostic log"
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied' : 'Copy logs'}
</button>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
title="Close logs"
>
<X size={14} />
</button>
</div>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs text-neutral-200"
+12 -2
View File
@@ -8,6 +8,8 @@ export interface TerminalOperation {
lines: string[]
status: OperationStatus
exitCode: number | null
startedAt: string
finishedAt: string | null
}
interface TerminalState {
@@ -33,7 +35,15 @@ export const useTerminalStore = create<TerminalState>((set) => ({
set((state) => ({
operations: {
...state.operations,
[id]: { id, label, lines: [], status: 'running', exitCode: null }
[id]: {
id,
label,
lines: [],
status: 'running',
exitCode: null,
startedAt: new Date().toISOString(),
finishedAt: null
}
},
activeOperationId: id,
isPanelOpen: true
@@ -56,7 +66,7 @@ export const useTerminalStore = create<TerminalState>((set) => ({
return {
operations: {
...state.operations,
[id]: { ...op, status, exitCode }
[id]: { ...op, status, exitCode, finishedAt: new Date().toISOString() }
}
}
}),