Add streaming log viewer with service switching and filtering (step 6)
ddev logs -f runs indefinitely rather than completing, so it doesn't
fit runStreamed's resolve/reject-on-exit model or the terminal panel's
operation semantics — added a parallel startLogStream/logs:data path
in commandRunner.ts that shares the same process-tracking map (so
stopping a log stream reuses the existing terminal:cancel IPC) but
pushes chunks over a dedicated channel decoupled from the status
bar/toast system. Kill all tracked processes on app quit so a
forgotten open log viewer doesn't leave an orphaned `ddev logs -f`.
Two real bugs found and fixed via live testing:
- react-hooks/set-state-in-effect flagged synchronous setState calls
used only to reset state on service change. Fixed by keying the
streaming component by service (LogPane key={service}) so switching
services remounts it and state resets via useState initializers
instead — the React-recommended pattern for this.
- Filtering operated on raw stream chunks, not lines: a single data
chunk can bundle many log lines or split one across chunk
boundaries, so filtering by chunk let unrelated lines through
whenever a match happened to share a chunk. Fixed by buffering
partial lines per stream and only filtering/rendering once complete
lines are assembled.
Verified end-to-end against the real scratch project via CDP-driven
clicks: live streaming from real containers (db and web service logs
both confirmed with distinct real content), service switching,
filtering (confirmed both the false-positive case is fixed and real
matches still work), and confirmed closing the viewer actually kills
the underlying `ddev logs -f` process rather than leaving it orphaned.
This commit is contained in:
@@ -97,3 +97,39 @@ export function cancelCommand(operationId: string): boolean {
|
|||||||
child.kill()
|
child.kill()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log tailing (`ddev logs -f`) runs indefinitely rather than completing, so
|
||||||
|
// it doesn't fit runStreamed's resolve/reject-on-exit model or the terminal
|
||||||
|
// panel's operation semantics (no "success"/"failure" toast makes sense for
|
||||||
|
// an open-ended stream). It shares the same running/cancel bookkeeping —
|
||||||
|
// stopping a log stream reuses cancelCommand — but pushes chunks over a
|
||||||
|
// dedicated logs:data channel instead of terminal:data.
|
||||||
|
export function startLogStream(operationId: string, args: string[], sender: WebContents): void {
|
||||||
|
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH })
|
||||||
|
running.set(operationId, child)
|
||||||
|
|
||||||
|
const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => {
|
||||||
|
if (!sender.isDestroyed()) {
|
||||||
|
sender.send('logs:data', { operationId, stream, chunk: data.toString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.stdout.on('data', forward('stdout'))
|
||||||
|
child.stderr.on('data', forward('stderr'))
|
||||||
|
|
||||||
|
const finish = (): void => {
|
||||||
|
running.delete(operationId)
|
||||||
|
cancelledIds.delete(operationId)
|
||||||
|
if (!sender.isDestroyed()) {
|
||||||
|
sender.send('logs:exit', { operationId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.on('close', finish)
|
||||||
|
child.on('error', finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killAllRunningCommands(): void {
|
||||||
|
for (const child of running.values()) {
|
||||||
|
child.kill()
|
||||||
|
}
|
||||||
|
running.clear()
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { registerProjectsIpc } from './ipc/projects'
|
|||||||
import { registerTerminalIpc } from './ipc/terminal'
|
import { registerTerminalIpc } from './ipc/terminal'
|
||||||
import { registerDatabaseIpc } from './ipc/database'
|
import { registerDatabaseIpc } from './ipc/database'
|
||||||
import { registerAddonsIpc } from './ipc/addons'
|
import { registerAddonsIpc } from './ipc/addons'
|
||||||
|
import { registerLogsIpc } from './ipc/logs'
|
||||||
|
import { killAllRunningCommands } from './commandRunner'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -58,6 +60,7 @@ app.whenReady().then(() => {
|
|||||||
registerTerminalIpc()
|
registerTerminalIpc()
|
||||||
registerDatabaseIpc()
|
registerDatabaseIpc()
|
||||||
registerAddonsIpc()
|
registerAddonsIpc()
|
||||||
|
registerLogsIpc()
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
@@ -77,5 +80,11 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream)
|
||||||
|
// so they don't linger as orphans after the app exits.
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
killAllRunningCommands()
|
||||||
|
})
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
// code. You can also put them in separate files and require them here.
|
// code. You can also put them in separate files and require them here.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { startLogStream } from '../commandRunner'
|
||||||
|
|
||||||
|
export function registerLogsIpc(): void {
|
||||||
|
ipcMain.handle('logs:start', (event, operationId: string, name: string, service: string) =>
|
||||||
|
startLogStream(operationId, ['logs', name, '-f', '-s', service, '--tail', '200'], event.sender)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
DdevProjectDetail,
|
DdevProjectDetail,
|
||||||
DdevProjectSummary,
|
DdevProjectSummary,
|
||||||
DdevSnapshot,
|
DdevSnapshot,
|
||||||
|
LogDataEvent,
|
||||||
|
LogExitEvent,
|
||||||
TerminalDataEvent,
|
TerminalDataEvent,
|
||||||
TerminalExitEvent
|
TerminalExitEvent
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
@@ -63,6 +65,20 @@ const api = {
|
|||||||
ipcRenderer.invoke('addons:install', operationId, name, addonRepo),
|
ipcRenderer.invoke('addons:install', operationId, name, addonRepo),
|
||||||
remove: (operationId: string, name: string, addonName: string): Promise<void> =>
|
remove: (operationId: string, name: string, addonName: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('addons:remove', operationId, name, addonName)
|
ipcRenderer.invoke('addons:remove', operationId, name, addonName)
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
start: (operationId: string, name: string, service: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('logs:start', operationId, name, service),
|
||||||
|
onData: (callback: (event: LogDataEvent) => void): (() => void) => {
|
||||||
|
const listener = (_event: IpcRendererEvent, data: LogDataEvent): void => callback(data)
|
||||||
|
ipcRenderer.on('logs:data', listener)
|
||||||
|
return () => ipcRenderer.removeListener('logs:data', listener)
|
||||||
|
},
|
||||||
|
onExit: (callback: (event: LogExitEvent) => void): (() => void) => {
|
||||||
|
const listener = (_event: IpcRendererEvent, data: LogExitEvent): void => callback(data)
|
||||||
|
ipcRenderer.on('logs:exit', listener)
|
||||||
|
return () => ipcRenderer.removeListener('logs:exit', listener)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { useLogStream } from '../../hooks/useLogStream'
|
||||||
|
|
||||||
|
function LogPane({
|
||||||
|
name,
|
||||||
|
service,
|
||||||
|
filter
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
service: string
|
||||||
|
filter: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { lines, isStreaming } = useLogStream(name, service)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const filteredLines = useMemo(() => {
|
||||||
|
if (!filter.trim()) return lines
|
||||||
|
const q = filter.toLowerCase()
|
||||||
|
return lines.filter((line) => line.text.toLowerCase().includes(q))
|
||||||
|
}, [lines, filter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||||
|
}, [filteredLines.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200"
|
||||||
|
>
|
||||||
|
{filteredLines.length === 0 ? (
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{lines.length === 0 ? 'Waiting for log output…' : 'No lines match the filter.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{filteredLines.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx('whitespace-pre-wrap', line.stream === 'stderr' && 'text-red-400')}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogViewer({
|
||||||
|
name,
|
||||||
|
services,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
services: string[]
|
||||||
|
onClose: () => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const [service, setService] = useState(services[0] ?? 'web')
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||||
|
<div className="flex h-full w-full max-w-3xl flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-sm font-semibold">Logs — {name}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-b border-neutral-200 p-3 dark:border-neutral-800">
|
||||||
|
<select
|
||||||
|
value={service}
|
||||||
|
onChange={(e) => setService(e.target.value)}
|
||||||
|
className="rounded-md border border-neutral-300 px-2 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Filter logs…"
|
||||||
|
className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LogPane key={service} name={name} service={service} filter={filter} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Play, RotateCw, Square } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { FileText, Play, RotateCw, Square } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useProjectDetail,
|
useProjectDetail,
|
||||||
useRestartProject,
|
useRestartProject,
|
||||||
@@ -8,12 +9,14 @@ import {
|
|||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
import { DatabaseSection } from './DatabaseSection'
|
import { DatabaseSection } from './DatabaseSection'
|
||||||
import { AddonsSection } from './AddonsSection'
|
import { AddonsSection } from './AddonsSection'
|
||||||
|
import { LogViewer } from '../logs/LogViewer'
|
||||||
|
|
||||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||||
const startProject = useStartProject()
|
const startProject = useStartProject()
|
||||||
const stopProject = useStopProject()
|
const stopProject = useStopProject()
|
||||||
const restartProject = useRestartProject()
|
const restartProject = useRestartProject()
|
||||||
|
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||||
|
|
||||||
const isBusy = startProject.isPending || stopProject.isPending || restartProject.isPending
|
const isBusy = startProject.isPending || stopProject.isPending || restartProject.isPending
|
||||||
|
|
||||||
@@ -66,6 +69,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<RotateCw size={14} /> Restart
|
<RotateCw size={14} /> Restart
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!isRunning}
|
||||||
|
onClick={() => setIsLogsOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<FileText size={14} /> Logs
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -143,6 +154,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
<DatabaseSection name={project.name} approot={project.approot} />
|
<DatabaseSection name={project.name} approot={project.approot} />
|
||||||
|
|
||||||
<AddonsSection name={project.name} />
|
<AddonsSection name={project.name} />
|
||||||
|
|
||||||
|
{isLogsOpen && (
|
||||||
|
<LogViewer
|
||||||
|
name={project.name}
|
||||||
|
services={Object.keys(project.services)}
|
||||||
|
onClose={() => setIsLogsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
stream: 'stdout' | 'stderr'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLogStreamResult {
|
||||||
|
lines: LogLine[]
|
||||||
|
isStreaming: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owns the lifecycle of a single `ddev logs -f` subprocess for one
|
||||||
|
// (name, service) pair. Callers must remount this (e.g. `key={service}`)
|
||||||
|
// when the service changes — state resets via fresh useState initializers
|
||||||
|
// on mount rather than manual resets inside the effect, since React's
|
||||||
|
// hooks lint flags synchronous setState-to-reset calls in an effect body.
|
||||||
|
// Always stops the subprocess on unmount so a closed log viewer doesn't
|
||||||
|
// leave an orphaned `ddev logs -f` process running.
|
||||||
|
export function useLogStream(name: string, service: string): UseLogStreamResult {
|
||||||
|
const [lines, setLines] = useState<LogLine[]>([])
|
||||||
|
const [isStreaming, setIsStreaming] = useState(true)
|
||||||
|
// Data arrives as arbitrary byte chunks, not newline-delimited — a chunk
|
||||||
|
// can span partial lines or bundle many lines together. Buffer per stream
|
||||||
|
// so filtering/display operates on real lines instead of raw chunks.
|
||||||
|
const buffers = useRef({ stdout: '', stderr: '' })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const operationId = crypto.randomUUID()
|
||||||
|
buffers.current = { stdout: '', stderr: '' }
|
||||||
|
|
||||||
|
const unsubData = window.api.logs.onData((event) => {
|
||||||
|
if (event.operationId !== operationId) return
|
||||||
|
const combined = buffers.current[event.stream] + event.chunk
|
||||||
|
const parts = combined.split('\n')
|
||||||
|
buffers.current[event.stream] = parts.pop() ?? ''
|
||||||
|
if (parts.length === 0) return
|
||||||
|
setLines((prev) => [...prev, ...parts.map((text) => ({ stream: event.stream, text }))])
|
||||||
|
})
|
||||||
|
const unsubExit = window.api.logs.onExit((event) => {
|
||||||
|
if (event.operationId !== operationId) return
|
||||||
|
setIsStreaming(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.api.logs.start(operationId, name, service)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubData()
|
||||||
|
unsubExit()
|
||||||
|
window.api.terminal.cancel(operationId)
|
||||||
|
}
|
||||||
|
}, [name, service])
|
||||||
|
|
||||||
|
return { lines, isStreaming }
|
||||||
|
}
|
||||||
@@ -112,3 +112,13 @@ export interface TerminalExitEvent {
|
|||||||
exitCode: number | null
|
exitCode: number | null
|
||||||
cancelled: boolean
|
cancelled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogDataEvent {
|
||||||
|
operationId: string
|
||||||
|
stream: 'stdout' | 'stderr'
|
||||||
|
chunk: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogExitEvent {
|
||||||
|
operationId: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user