* Don't fetch logs as anon * Only fetch logs if we need them on the current page * Wait for poll to finish with setTimeout This makes sure that we wait for the pending poll to finish before we poll again. This prevents running multiple polls at the same time on slow connections. I noticed we don't need to queue a new poll ourselves since a poll updates effect dependencies so we will cleanup and run the effect again anyway. * Fix polling via useEffect abuse
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
import LogMessage from './log-message'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import styles from '@/styles/log.module.css'
|
|
import { Button } from 'react-bootstrap'
|
|
import { useToast } from './toast'
|
|
import { useShowModal } from './modal'
|
|
import { WALLET_LOGS } from '@/fragments/wallet'
|
|
import { getWalletByType } from '@/wallets/common'
|
|
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
|
import { useMe } from './me'
|
|
import useIndexedDB, { getDbName } from './use-indexeddb'
|
|
import { SSR } from '@/lib/constants'
|
|
import { decode as bolt11Decode } from 'bolt11'
|
|
import { formatMsats } from '@/lib/format'
|
|
import { useRouter } from 'next/router'
|
|
|
|
export function WalletLogs ({ wallet, embedded }) {
|
|
const { logs, setLogs, hasMore, loadMore, loading } = useWalletLogs(wallet)
|
|
|
|
const showModal = useShowModal()
|
|
|
|
return (
|
|
<>
|
|
<div className='d-flex w-100 align-items-center mb-3'>
|
|
<span
|
|
style={{ cursor: 'pointer' }}
|
|
className='text-muted fw-bold nav-link ms-auto' onClick={() => {
|
|
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} setLogs={setLogs} onClose={onClose} />)
|
|
}}
|
|
>clear logs
|
|
</span>
|
|
</div>
|
|
<div className={`${styles.tableContainer} ${embedded ? styles.embedded : ''}`}>
|
|
<table>
|
|
<colgroup>
|
|
<col span='1' style={{ width: '1rem' }} />
|
|
<col span='1' style={{ width: '1rem' }} />
|
|
<col span='1' style={{ width: '1rem' }} />
|
|
<col span='1' style={{ width: '100%' }} />
|
|
<col span='1' style={{ width: '1rem' }} />
|
|
</colgroup>
|
|
<tbody>
|
|
{logs.map((log, i) => (
|
|
<LogMessage
|
|
key={i}
|
|
showWallet={!wallet}
|
|
{...log}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{loading
|
|
? <div className='w-100 text-center'>loading...</div>
|
|
: logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
|
{hasMore
|
|
? <div className='w-100 text-center'><Button onClick={loadMore} size='sm' className='mt-3'>more</Button></div>
|
|
: <div className='w-100 text-center'>------ start of logs ------</div>}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
|
const { deleteLogs } = useWalletLogger(wallet, setLogs)
|
|
const toaster = useToast()
|
|
|
|
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
|
return (
|
|
<div className='text-center'>
|
|
{prompt}
|
|
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
|
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
|
<Button
|
|
className='d-flex me-auto mx-3' variant='danger'
|
|
onClick={
|
|
async () => {
|
|
try {
|
|
await deleteLogs(wallet)
|
|
onClose()
|
|
toaster.success('deleted wallet logs')
|
|
} catch (err) {
|
|
console.error(err)
|
|
toaster.danger('failed to delete wallet logs')
|
|
}
|
|
}
|
|
}
|
|
>delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const INDICES = [
|
|
{ name: 'ts', keyPath: 'ts' },
|
|
{ name: 'wallet_ts', keyPath: ['wallet', 'ts'] }
|
|
]
|
|
|
|
function getWalletLogDbName (userId) {
|
|
return getDbName(userId)
|
|
}
|
|
|
|
function useWalletLogDB () {
|
|
const { me } = useMe()
|
|
// memoize the idb config to avoid re-creating it on every render
|
|
const idbConfig = useMemo(() =>
|
|
({ dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }), [me?.id])
|
|
const { add, getPage, clear, error, notSupported } = useIndexedDB(idbConfig)
|
|
|
|
return { add, getPage, clear, error, notSupported }
|
|
}
|
|
|
|
export function useWalletLogger (wallet, setLogs) {
|
|
const { add, clear, notSupported } = useWalletLogDB()
|
|
|
|
const appendLog = useCallback(async (wallet, level, message, context) => {
|
|
const log = { wallet: tag(wallet), level, message, ts: +new Date(), context }
|
|
try {
|
|
if (notSupported) {
|
|
console.log('cannot persist wallet log: indexeddb not supported')
|
|
} else {
|
|
await add(log)
|
|
}
|
|
setLogs?.(prevLogs => [log, ...prevLogs])
|
|
} catch (error) {
|
|
console.error('Failed to append wallet log:', error)
|
|
}
|
|
}, [add, notSupported])
|
|
|
|
const [deleteServerWalletLogs] = useMutation(
|
|
gql`
|
|
mutation deleteWalletLogs($wallet: String) {
|
|
deleteWalletLogs(wallet: $wallet)
|
|
}
|
|
`,
|
|
{
|
|
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
|
setLogs?.(logs => logs.filter(l => walletType ? l.wallet !== getWalletByType(walletType).name : false))
|
|
}
|
|
}
|
|
)
|
|
|
|
const deleteLogs = useCallback(async (wallet, options) => {
|
|
if ((!wallet || wallet.def.walletType) && !options?.clientOnly) {
|
|
await deleteServerWalletLogs({ variables: { wallet: wallet?.def.walletType } })
|
|
}
|
|
if (!wallet || wallet.sendPayment) {
|
|
try {
|
|
const walletTag = wallet ? tag(wallet) : null
|
|
if (notSupported) {
|
|
console.log('cannot clear wallet logs: indexeddb not supported')
|
|
} else {
|
|
await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null)
|
|
}
|
|
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
|
|
} catch (e) {
|
|
console.error('failed to delete logs', e)
|
|
}
|
|
}
|
|
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
|
|
|
|
const log = useCallback(level => (message, context = {}) => {
|
|
if (!wallet) {
|
|
// console.error('cannot log: no wallet set')
|
|
return
|
|
}
|
|
|
|
if (context?.bolt11) {
|
|
// automatically populate context from bolt11 to avoid duplicating this code
|
|
const decoded = bolt11Decode(context.bolt11)
|
|
context = {
|
|
...context,
|
|
amount: formatMsats(Number(decoded.millisatoshis)),
|
|
payment_hash: decoded.tagsObject.payment_hash,
|
|
description: decoded.tagsObject.description,
|
|
created_at: new Date(decoded.timestamp * 1000).toISOString(),
|
|
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
|
|
// payments should affect wallet status
|
|
status: true
|
|
}
|
|
}
|
|
context.send = true
|
|
|
|
appendLog(wallet, level, message, context)
|
|
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
|
|
}, [appendLog, wallet])
|
|
|
|
const logger = useMemo(() => ({
|
|
ok: (message, context) => log('ok')(message, context),
|
|
info: (message, context) => log('info')(message, context),
|
|
error: (message, context) => log('error')(message, context)
|
|
}), [log])
|
|
|
|
return { logger, deleteLogs }
|
|
}
|
|
|
|
function tag (walletDef) {
|
|
return walletDef.shortName || walletDef.name
|
|
}
|
|
|
|
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
|
const [logs, _setLogs] = useState([])
|
|
const [page, setPage] = useState(initialPage)
|
|
const [hasMore, setHasMore] = useState(true)
|
|
const [cursor, setCursor] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const latestTimestamp = useRef()
|
|
const { me } = useMe()
|
|
const router = useRouter()
|
|
|
|
const { getPage, error, notSupported } = useWalletLogDB()
|
|
const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' })
|
|
|
|
const setLogs = useCallback((action) => {
|
|
_setLogs(action)
|
|
// action can be a React state dispatch function
|
|
const newLogs = typeof action === 'function' ? action(logs) : action
|
|
// make sure 'more' button is removed if logs were deleted
|
|
if (newLogs.length === 0) setHasMore(false)
|
|
latestTimestamp.current = newLogs[0]?.ts
|
|
}, [logs, _setLogs, setHasMore])
|
|
|
|
const loadLogsPage = useCallback(async (page, pageSize, walletDef, variables = {}) => {
|
|
try {
|
|
let result = { data: [], hasMore: false }
|
|
if (notSupported) {
|
|
console.log('cannot get client wallet logs: indexeddb not supported')
|
|
} else {
|
|
const indexName = walletDef ? 'wallet_ts' : 'ts'
|
|
const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null
|
|
|
|
result = await getPage(page, pageSize, indexName, query, 'prev')
|
|
// if given wallet has no walletType it means logs are only stored in local IDB
|
|
if (walletDef && !walletDef.walletType) {
|
|
return result
|
|
}
|
|
}
|
|
|
|
const oldestTs = result?.data[result.data.length - 1]?.ts // start of local logs
|
|
const newestTs = result?.data[0]?.ts // end of local logs
|
|
|
|
let from
|
|
if (variables?.from !== undefined) {
|
|
from = variables.from
|
|
} else if (oldestTs && result.hasMore) {
|
|
// fetch all missing, intertwined server logs since start of local logs
|
|
from = String(oldestTs)
|
|
} else {
|
|
from = null
|
|
}
|
|
|
|
let to
|
|
if (variables?.to !== undefined) {
|
|
to = variables.to
|
|
} else if (newestTs && cursor) {
|
|
// fetch next old page of server logs
|
|
// ( if cursor is available, we will use decoded time of cursor )
|
|
to = String(newestTs)
|
|
} else {
|
|
to = null
|
|
}
|
|
|
|
const { data } = await getWalletLogs({
|
|
variables: {
|
|
type: walletDef?.walletType,
|
|
from,
|
|
to,
|
|
cursor,
|
|
...variables
|
|
}
|
|
})
|
|
|
|
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
|
|
ts: +new Date(createdAt),
|
|
wallet: tag(getWalletByType(walletType)),
|
|
...log
|
|
}))
|
|
const combinedLogs = uniqueSort([...result.data, ...newLogs])
|
|
|
|
setCursor(data.walletLogs.cursor)
|
|
return {
|
|
...result,
|
|
data: combinedLogs,
|
|
hasMore: result.hasMore || !!data.walletLogs.cursor
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading logs from IndexedDB:', error)
|
|
return { data: [], hasMore: false }
|
|
}
|
|
}, [getPage, setCursor, cursor, notSupported])
|
|
|
|
if (error) {
|
|
console.error('IndexedDB error:', error)
|
|
}
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (hasMore) {
|
|
setLoading(true)
|
|
const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def)
|
|
_setLogs(prevLogs => uniqueSort([...prevLogs, ...result.data]))
|
|
setHasMore(result.hasMore)
|
|
setPage(prevPage => prevPage + 1)
|
|
setLoading(false)
|
|
}
|
|
}, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore])
|
|
|
|
const loadNew = useCallback(async () => {
|
|
const latestTs = latestTimestamp.current
|
|
const variables = { from: latestTs?.toString(), to: null }
|
|
const result = await loadLogsPage(1, logsPerPage, wallet?.def, variables)
|
|
setLoading(false)
|
|
_setLogs(prevLogs => uniqueSort([...result.data, ...prevLogs]))
|
|
if (!latestTs) {
|
|
// we only want to update the more button if we didn't fetch new logs since it is about old logs.
|
|
// we didn't fetch new logs if this is our first fetch (no newest timestamp available)
|
|
setHasMore(result.hasMore)
|
|
}
|
|
}, [wallet?.def, loadLogsPage])
|
|
|
|
useEffect(() => {
|
|
// only fetch new logs if we are on a page that uses logs
|
|
const needLogs = router.asPath.startsWith('/settings/wallets') || router.asPath.startsWith('/wallet/logs')
|
|
if (!me || !needLogs) return
|
|
|
|
let timeout
|
|
let stop = false
|
|
|
|
const poll = async () => {
|
|
await loadNew().catch(console.error)
|
|
if (!stop) timeout = setTimeout(poll, 1_000)
|
|
}
|
|
|
|
timeout = setTimeout(poll, 1_000)
|
|
|
|
return () => {
|
|
stop = true
|
|
clearTimeout(timeout)
|
|
}
|
|
}, [me?.id, router.pathname, loadNew])
|
|
|
|
return { logs, hasMore: !loading && hasMore, loadMore, setLogs, loading }
|
|
}
|
|
|
|
function uniqueSort (logs) {
|
|
return Array.from(new Set(logs.map(JSON.stringify))).map(JSON.parse).sort((a, b) => b.ts - a.ts)
|
|
}
|