Skip to content

Commit 3ccbe72

Browse files
committed
Add log viewer overlay
1 parent 68cdfd7 commit 3ccbe72

File tree

5 files changed

+252
-2
lines changed

5 files changed

+252
-2
lines changed

src/components/LogsContent.tsx

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2+
import { AlertTriangleIcon, Loader2Icon, RefreshCwIcon, DownloadIcon, ArrowDownIcon } from 'lucide-react'
3+
import { useTranslation } from 'react-i18next'
4+
import { useStore } from 'zustand'
5+
import { Alert, AlertDescription } from '@/components/ui/alert'
6+
import { Button } from '@/components/ui/button'
7+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
8+
import { isDevMode } from '@/constants/debugFeatures'
9+
import { fetchLog } from '@/lib/api/logs'
10+
import { cn, delayedPromise } from '@/lib/utils'
11+
import { authStore } from '@/store/authStore'
12+
13+
const JMWALLETD_LOG_FILE_NAME = 'jmwalletd_stdout.log'
14+
15+
interface SimpleAlert {
16+
variant: React.ComponentProps<typeof Alert>['variant']
17+
message: string
18+
}
19+
20+
interface LogViewerProps {
21+
value: string
22+
refresh: (signal: AbortSignal) => Promise<void>
23+
}
24+
25+
function LogViewer({ value, refresh }: LogViewerProps) {
26+
const { t } = useTranslation()
27+
const logContentRef = useRef<HTMLPreElement>(null)
28+
const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
29+
const [logScrollProgress, setLogScrollProgress] = useState(0)
30+
const isScrolledToLogBottom = useMemo(() => logScrollProgress >= 0.995, [logScrollProgress])
31+
32+
const scrollToLogBottom = () => {
33+
logContentRef.current?.scrollTo({
34+
top: logContentRef.current.scrollHeight,
35+
behavior: 'smooth',
36+
})
37+
}
38+
39+
const logScrollHandler = (event: React.UIEvent<HTMLPreElement>) => {
40+
const containerHeight = event.currentTarget.clientHeight
41+
const scrollHeight = event.currentTarget.scrollHeight
42+
43+
const scrollTop = event.currentTarget.scrollTop
44+
setLogScrollProgress((scrollTop + containerHeight) / scrollHeight)
45+
}
46+
47+
useEffect(() => {
48+
if (!value) return
49+
scrollToLogBottom()
50+
}, [value])
51+
52+
const handleRefresh = useCallback(async () => {
53+
if (isLoadingRefresh) return
54+
55+
setIsLoadingRefresh(true)
56+
const abortCtrl = new AbortController()
57+
58+
try {
59+
await refresh(abortCtrl.signal).then(() => delayedPromise(210))
60+
} finally {
61+
setIsLoadingRefresh(false)
62+
}
63+
}, [isLoadingRefresh, refresh])
64+
65+
const handleDownload = useCallback(() => {
66+
const blob = new Blob([value], { type: 'text/plain' })
67+
const url = URL.createObjectURL(blob)
68+
const a = document.createElement('a')
69+
a.href = url
70+
a.download = JMWALLETD_LOG_FILE_NAME
71+
document.body.appendChild(a)
72+
a.click()
73+
document.body.removeChild(a)
74+
setTimeout(() => {
75+
URL.revokeObjectURL(url)
76+
}, 0)
77+
}, [value])
78+
79+
return (
80+
<Card className="flex flex-1 flex-col overflow-hidden pb-0">
81+
<CardHeader className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
82+
<CardTitle className="font-mono break-all">{JMWALLETD_LOG_FILE_NAME}</CardTitle>
83+
<div className="flex items-center justify-end gap-2">
84+
<Button
85+
className="hover:[&>svg]:motion-safe:animate-bounce"
86+
variant="outline"
87+
onClick={handleDownload}
88+
disabled={!value || isLoadingRefresh}
89+
title={t('global.download')}
90+
>
91+
<DownloadIcon className="group/download" />
92+
{t('global.download')}
93+
</Button>
94+
<Button variant="outline" onClick={handleRefresh} disabled={isLoadingRefresh} title={t('global.refresh')}>
95+
<RefreshCwIcon
96+
className={cn({
97+
'animate-spin': isLoadingRefresh,
98+
})}
99+
/>
100+
{t('global.refresh')}
101+
</Button>
102+
</div>
103+
</CardHeader>
104+
<CardContent className="relative flex-1 overflow-hidden rounded-b-xl p-0">
105+
<pre
106+
onScrollEnd={logScrollHandler}
107+
ref={logContentRef}
108+
className="bg-muted/90 absolute inset-0 overflow-auto rounded-b-xl px-2 py-2 font-mono text-sm break-words whitespace-pre-wrap"
109+
>
110+
{value}
111+
</pre>
112+
<Button
113+
className={cn('absolute top-2 right-2 size-12', {
114+
'opacity-25 hover:opacity-50': !isScrolledToLogBottom,
115+
hidden: isScrolledToLogBottom,
116+
})}
117+
variant={isScrolledToLogBottom ? 'ghost' : 'default'}
118+
disabled={isScrolledToLogBottom}
119+
size="icon"
120+
onClick={scrollToLogBottom}
121+
title={/* TODO: i18n */ 'Scroll to bottom'}
122+
>
123+
<ArrowDownIcon />
124+
</Button>
125+
</CardContent>
126+
</Card>
127+
)
128+
}
129+
130+
interface LogsContentProps {
131+
className?: string
132+
enabled: boolean
133+
}
134+
135+
export const LogsContent = ({ enabled, className }: LogsContentProps) => {
136+
const authState = useStore(authStore, (state) => state.state)
137+
const { t } = useTranslation()
138+
const [alert, setAlert] = useState<SimpleAlert>()
139+
const [isInitialized, setIsInitialized] = useState(false)
140+
const [logFileContent, setLogFileContent] = useState<string>()
141+
142+
const refresh = useCallback(
143+
async (signal: AbortSignal) => {
144+
if (!authState?.auth?.token) {
145+
setAlert({
146+
variant: 'destructive',
147+
message: 'No authentication token available. Please login again.',
148+
})
149+
return Promise.reject(new Error('No authentication token'))
150+
}
151+
152+
return fetchLog({
153+
token: authState.auth.token,
154+
fileName: JMWALLETD_LOG_FILE_NAME,
155+
signal,
156+
})
157+
.then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
158+
.then((data) => {
159+
if (signal.aborted) return
160+
setAlert(undefined)
161+
setLogFileContent(data)
162+
})
163+
.catch((e) => {
164+
if (signal.aborted) return
165+
166+
const errorMessage = t('logs.error_loading_logs_failed', {
167+
reason: e.message || t('global.errors.reason_unknown'),
168+
})
169+
170+
if (isDevMode()) {
171+
setLogFileContent(`${errorMessage}\n`.repeat(1_000))
172+
}
173+
174+
setAlert({
175+
variant: 'warning',
176+
message: errorMessage,
177+
})
178+
})
179+
},
180+
[t, authState],
181+
)
182+
183+
if (enabled && !isInitialized) {
184+
const abortCtrl = new AbortController()
185+
refresh(abortCtrl.signal).finally(() => {
186+
if (abortCtrl.signal.aborted) return
187+
setIsInitialized(true)
188+
})
189+
}
190+
191+
if (!isInitialized) {
192+
return (
193+
<div className={cn('flex items-center justify-center gap-2', className)}>
194+
<Loader2Icon className="h-4 w-4 animate-spin motion-reduce:hidden" />
195+
{t('global.loading')}
196+
</div>
197+
)
198+
}
199+
200+
return (
201+
<div className={cn('flex flex-col gap-3', className)}>
202+
{alert && (
203+
<Alert variant={alert.variant}>
204+
<AlertTriangleIcon />
205+
<AlertDescription>{alert.message}</AlertDescription>
206+
</Alert>
207+
)}
208+
209+
{logFileContent && <LogViewer value={logFileContent} refresh={refresh} />}
210+
</div>
211+
)
212+
}

src/components/LogsOverlay.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ComponentProps } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
4+
import PageTitle from '@/components/ui/jam/PageTitle'
5+
import type { WithRequiredProperty } from '@/types/global'
6+
import { LogsContent } from './LogsContent'
7+
8+
type LogsOverlayProps = WithRequiredProperty<Omit<ComponentProps<typeof Dialog>, 'children'>, 'open' | 'onOpenChange'>
9+
10+
export function LogsOverlay({ open, onOpenChange }: LogsOverlayProps) {
11+
const { t } = useTranslation()
12+
return (
13+
<Dialog open={open} onOpenChange={() => onOpenChange(false)}>
14+
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
15+
<DialogHeader>
16+
<DialogTitle className="flex items-center gap-2">
17+
<PageTitle title={t('logs.title')} />
18+
</DialogTitle>
19+
</DialogHeader>
20+
21+
<div className="overflow-hidden">
22+
<LogsContent enabled={open} className="flex h-full flex-col" />
23+
</div>
24+
</DialogContent>
25+
</Dialog>
26+
)
27+
}

src/components/layout/AppFooter.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, type ComponentProps } from 'react'
2-
import { AlertTriangleIcon, BlocksIcon, BookOpenIcon, FileQuestionMarkIcon } from 'lucide-react'
2+
import { AlertTriangleIcon, BlocksIcon, BookOpenIcon, FileQuestionMarkIcon, ScrollTextIcon } from 'lucide-react'
33
import { useTranslation, Trans } from 'react-i18next'
44
import { useStore } from 'zustand'
55
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -50,6 +50,7 @@ type AppFooterProps = Pick<BetaWarningModalProps, 'jamVersion' | 'joinmarketVers
5050
websocketInfo?: JmWebsocketInfo
5151
onClickCheatsheet: () => void
5252
onClickOrderbook: () => void
53+
onClickLogs: () => void
5354
}
5455

5556
export function AppFooter({
@@ -58,6 +59,7 @@ export function AppFooter({
5859
joinmarketVersion,
5960
onClickCheatsheet,
6061
onClickOrderbook,
62+
onClickLogs,
6163
}: AppFooterProps) {
6264
const { t } = useTranslation()
6365

@@ -90,6 +92,10 @@ export function AppFooter({
9092
<BookOpenIcon />
9193
<span className="hidden sm:inline-block">{t('footer.orderbook')}</span>
9294
</Button>
95+
<Button variant="outline" size="sm" onClick={onClickLogs} title={t('footer.logs')}>
96+
<ScrollTextIcon />
97+
<span className="hidden sm:inline-block">{t('footer.logs')}</span>
98+
</Button>
9399
</div>
94100

95101
<div className="flex flex-1 items-center justify-end gap-4 text-xs">

src/components/layout/Layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useJmWebsocket } from '@/hooks/useJmWebsocket'
1717
import { useQueryJmInfo } from '@/hooks/useQueryJmInfo'
1818
import type { WalletFileName } from '@/lib/utils'
1919
import { jmSessionStore } from '@/store/jmSessionStore'
20+
import { LogsOverlay } from '../LogsOverlay'
2021
import { OrderbookOverlay } from '../orderbook/OrderbookOverlay'
2122
import { Cheatsheet } from '../ui/jam/Cheatsheet'
2223
import { AppSidebar } from './AppSidebar'
@@ -49,6 +50,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
4950

5051
const cheatsheet = useCheatsheet()
5152
const [isOrderbookOverlayOpen, setIsOrderbookOverlayOpen] = useState(false)
53+
const [isLogsOverlayOpen, setIsLogsOverlayOpen] = useState(false)
5254

5355
return (
5456
<div className="light:bg-white light:text-black flex min-h-screen flex-1 flex-col bg-[#181b20] text-white transition-colors duration-300">
@@ -74,10 +76,12 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
7476
joinmarketVersion={joinmarketVersion}
7577
onClickCheatsheet={() => cheatsheet.onOpenChange(true)}
7678
onClickOrderbook={() => setIsOrderbookOverlayOpen(true)}
79+
onClickLogs={() => setIsLogsOverlayOpen(true)}
7780
/>
7881

7982
<Cheatsheet open={cheatsheet.open} onOpenChange={cheatsheet.onOpenChange} />
8083
<OrderbookOverlay open={isOrderbookOverlayOpen} onOpenChange={setIsOrderbookOverlayOpen} />
84+
<LogsOverlay open={isLogsOverlayOpen} onOpenChange={setIsLogsOverlayOpen} />
8185
</div>
8286
)
8387
}

src/i18n/locales/en/translation.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
"cheatsheet": "Cheatsheet",
6363
"websocket_connected": "Websocket connected",
6464
"websocket_disconnected": "Websocket disconnected",
65-
"orderbook": "Orderbook"
65+
"orderbook": "Orderbook",
66+
"logs": "Logs"
6667
},
6768
"onboarding": {
6869
"splashscreen_title": "Jam",

0 commit comments

Comments
 (0)