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
+32 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { X } from 'lucide-react' import { Check, Copy, X } from 'lucide-react'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLogStream } from '../../hooks/useLogStream' import { useLogStream } from '../../hooks/useLogStream'
@@ -14,6 +14,7 @@ function LogPane({
}): React.JSX.Element { }): React.JSX.Element {
const { lines, isStreaming } = useLogStream(name, service) const { lines, isStreaming } = useLogStream(name, service)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const filteredLines = useMemo(() => { const filteredLines = useMemo(() => {
if (!filter.trim()) return lines if (!filter.trim()) return lines
@@ -25,22 +26,41 @@ function LogPane({
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [filteredLines.length]) }, [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 ( return (
<> <>
<span <div className="flex items-center justify-between px-4 py-2">
className={clsx(
'flex items-center gap-1 text-xs',
isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
)}
>
<span <span
className={clsx( className={clsx(
'h-1.5 w-1.5 rounded-full', 'flex items-center gap-1 text-xs',
isStreaming ? 'bg-emerald-500' : 'bg-neutral-400' isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
)} )}
/> >
{isStreaming ? 'streaming' : 'stopped'} <span className={clsx('h-1.5 w-1.5 rounded-full', isStreaming ? 'bg-emerald-500' : 'bg-neutral-400')} />
</span> {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 <div
ref={scrollRef} ref={scrollRef}
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200" 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 { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react' import { Check, Copy, X } from 'lucide-react'
import { useTerminalStore } from '../../stores/terminalStore' import { useTerminalStore } from '../../stores/terminalStore'
export function TerminalPanel(): React.JSX.Element | null { 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 s.activeOperationId ? s.operations[s.activeOperationId] : null
) )
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen) 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 scrollRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
@@ -17,17 +21,59 @@ export function TerminalPanel(): React.JSX.Element | null {
if (!isPanelOpen || !activeOperationId || !operation) return 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 ( 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 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"> <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">
<button <select
type="button" aria-label="Diagnostic operation"
onClick={() => setPanelOpen(false)} value={activeOperationId}
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200" 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"
<X size={14} /> >
</button> {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>
<div <div
ref={scrollRef} ref={scrollRef}
+12 -2
View File
@@ -8,6 +8,8 @@ export interface TerminalOperation {
lines: string[] lines: string[]
status: OperationStatus status: OperationStatus
exitCode: number | null exitCode: number | null
startedAt: string
finishedAt: string | null
} }
interface TerminalState { interface TerminalState {
@@ -33,7 +35,15 @@ export const useTerminalStore = create<TerminalState>((set) => ({
set((state) => ({ set((state) => ({
operations: { operations: {
...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, activeOperationId: id,
isPanelOpen: true isPanelOpen: true
@@ -56,7 +66,7 @@ export const useTerminalStore = create<TerminalState>((set) => ({
return { return {
operations: { operations: {
...state.operations, ...state.operations,
[id]: { ...op, status, exitCode } [id]: { ...op, status, exitCode, finishedAt: new Date().toISOString() }
} }
} }
}), }),