feat(logs): add copy link and deep link support for log entries (#3855)

* feat(logs): add copy link and deep link support for log entries

* fix(logs): fetch next page when deep linked log is beyond initial page

* fix(logs): move Link icon to emcn and handle clipboard rejections

* fix(logs): track isFetching reactively and drop empty-list early-return

- Remove  guard that prevented clearing the
  pending ref when filters return no results
- Use  directly in the condition and add it to
  the effect deps so the effect re-triggers after a background refetch

* fix(logs): guard deep-link ref clear until query has succeeded

Only clear pendingExecutionIdRef when the query status is 'success',
preventing premature clearing before the initial fetch completes.
On mount, the query is disabled (isInitialized.current starts false),
so hasNextPage is false but no data has loaded yet — the ref was being
cleared in the same effect pass that set it.

* fix(logs): guard fetchNextPage call until query has succeeded

Add logsQuery.status === 'success' to the fetchNextPage branch so it
mirrors the clear branch. On mount the query is disabled (isFetching is
false, status is pending), causing the effect to call fetchNextPage()
before the query is initialized — now both branches require success.
This commit is contained in:
Waleed
2026-03-30 18:42:27 -07:00
committed by GitHub
parent 72eea64bf6
commit e9c94fa462
4 changed files with 71 additions and 28 deletions

View File

@@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy, Eye, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import { Copy, Eye, Link, ListFilter, SquareArrowUpRight, X } from '@/components/emcn/icons'
import type { WorkflowLog } from '@/stores/logs/filters/types'
interface LogRowContextMenuProps {
@@ -17,6 +17,7 @@ interface LogRowContextMenuProps {
onClose: () => void
log: WorkflowLog | null
onCopyExecutionId: () => void
onCopyLink: () => void
onOpenWorkflow: () => void
onOpenPreview: () => void
onToggleWorkflowFilter: () => void
@@ -35,6 +36,7 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
onClose,
log,
onCopyExecutionId,
onCopyLink,
onOpenWorkflow,
onOpenPreview,
onToggleWorkflowFilter,
@@ -71,6 +73,10 @@ export const LogRowContextMenu = memo(function LogRowContextMenu({
<Copy />
Copy Execution ID
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasExecutionId} onSelect={onCopyLink}>
<Link />
Copy Link
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!hasWorkflow} onSelect={onOpenWorkflow}>

View File

@@ -266,16 +266,17 @@ export default function Logs() {
isSidebarOpen: false,
})
const isInitialized = useRef<boolean>(false)
const pendingExecutionIdRef = useRef<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearchQuery = useDebounce(searchQuery, 300)
useEffect(() => {
const urlSearch = new URLSearchParams(window.location.search).get('search') || ''
if (urlSearch && urlSearch !== searchQuery) {
setSearchQuery(urlSearch)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const params = new URLSearchParams(window.location.search)
const urlSearch = params.get('search')
if (urlSearch) setSearchQuery(urlSearch)
const urlExecutionId = params.get('executionId')
if (urlExecutionId) pendingExecutionIdRef.current = urlExecutionId
}, [])
const isLive = true
@@ -298,7 +299,6 @@ export default function Logs() {
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [contextMenuLog, setContextMenuLog] = useState<WorkflowLog | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
@@ -417,28 +417,30 @@ export default function Logs() {
useFolders(workspaceId)
logsRef.current = sortedLogs
selectedLogIndexRef.current = selectedLogIndex
selectedLogIdRef.current = selectedLogId
logsRefetchRef.current = logsQuery.refetch
activeLogRefetchRef.current = activeLogQuery.refetch
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
}
useEffect(() => {
logsRef.current = sortedLogs
}, [sortedLogs])
useEffect(() => {
selectedLogIndexRef.current = selectedLogIndex
}, [selectedLogIndex])
useEffect(() => {
selectedLogIdRef.current = selectedLogId
}, [selectedLogId])
useEffect(() => {
logsRefetchRef.current = logsQuery.refetch
}, [logsQuery.refetch])
useEffect(() => {
activeLogRefetchRef.current = activeLogQuery.refetch
}, [activeLogQuery.refetch])
useEffect(() => {
logsQueryRef.current = {
isFetching: logsQuery.isFetching,
hasNextPage: logsQuery.hasNextPage ?? false,
fetchNextPage: logsQuery.fetchNextPage,
if (!pendingExecutionIdRef.current) return
const targetExecutionId = pendingExecutionIdRef.current
const found = sortedLogs.find((l) => l.executionId === targetExecutionId)
if (found) {
pendingExecutionIdRef.current = null
dispatch({ type: 'TOGGLE_LOG', logId: found.id })
} else if (!logsQuery.hasNextPage && logsQuery.status === 'success') {
pendingExecutionIdRef.current = null
} else if (!logsQuery.isFetching && logsQuery.status === 'success') {
logsQueryRef.current.fetchNextPage()
}
}, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage])
}, [sortedLogs, logsQuery.hasNextPage, logsQuery.isFetching, logsQuery.status])
useEffect(() => {
const timers = refreshTimersRef.current
@@ -490,10 +492,17 @@ export default function Logs() {
const handleCopyExecutionId = useCallback(() => {
if (contextMenuLog?.executionId) {
navigator.clipboard.writeText(contextMenuLog.executionId)
navigator.clipboard.writeText(contextMenuLog.executionId).catch(() => {})
}
}, [contextMenuLog])
const handleCopyLink = useCallback(() => {
if (contextMenuLog?.executionId) {
const url = `${window.location.origin}/workspace/${workspaceId}/logs?executionId=${contextMenuLog.executionId}`
navigator.clipboard.writeText(url).catch(() => {})
}
}, [contextMenuLog, workspaceId])
const handleOpenWorkflow = useCallback(() => {
const wfId = contextMenuLog?.workflow?.id || contextMenuLog?.workflowId
if (wfId) {
@@ -1165,6 +1174,7 @@ export default function Logs() {
onClose={handleCloseContextMenu}
log={contextMenuLog}
onCopyExecutionId={handleCopyExecutionId}
onCopyLink={handleCopyLink}
onOpenWorkflow={handleOpenWorkflow}
onOpenPreview={handleOpenPreview}
onToggleWorkflowFilter={handleToggleWorkflowFilter}

View File

@@ -42,6 +42,7 @@ export { Key } from './key'
export { KeySquare } from './key-square'
export { Layout } from './layout'
export { Library } from './library'
export { Link } from './link'
export { ListFilter } from './list-filter'
export { Loader } from './loader'
export { Lock } from './lock'

View File

@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* Link icon component
* @param props - SVG properties including className, size, etc.
*/
export function Link(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' />
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' />
</svg>
)
}