mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
26
apps/sim/components/emcn/icons/link.tsx
Normal file
26
apps/sim/components/emcn/icons/link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user