feat: add copyable diagnostic logs
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user