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