Skip to content

Commit 0b37c48

Browse files
committed
feat(logs): introduce a dedicated logs page for server and error logs
This commit introduces a new `LogsPage` component and its associated presenter hook `useLogsPresenter`. The `LogsPage` provides a comprehensive interface for viewing and managing application logs, including: - **Server Logs**: Displays real-time server logs with filtering, search, and auto-refresh capabilities. - **Error Logs**: Lists error log files, allowing users to download individual error logs. - **Log Management**: Functionality to clear all logs and download server logs. - **Filtering**: Users can filter server logs by search query and hide management-related entries. - **Display Options**: Toggle between raw log view and a parsed, structured view for server logs. - **Auto-refresh**: Automatically fetches new server logs at a set interval. - **Error Log Viewer**: A side sheet to display the content of selected error log files. The `useLogsPresenter` hook encapsulates the logic for fetching, parsing, filtering, and managing log data, interacting with the `logsApi` service. This feature significantly improves the observability and debugging experience for the application. chore(gitignore): remove 'logs' entry as it is covered by '*.log'
1 parent ab72282 commit 0b37c48

3 files changed

Lines changed: 595 additions & 1 deletion

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# Logs
2-
logs
32
*.log
43
npm-debug.log*
54
yarn-debug.log*

src/features/logs/LogsPage.tsx

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import { useEffect, useRef } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import { motion } from 'motion/react'
4+
import {
5+
Card,
6+
CardContent,
7+
} from '@/shared/components/ui/card'
8+
9+
import { Button } from '@/shared/components/ui/button'
10+
import { ScrollArea, ScrollBar } from '@/shared/components/ui/scroll-area'
11+
import { Trash2, AlertCircle, FileText, Download, RotateCcw, EyeOff, Code, Clock, AlertTriangle } from 'lucide-react'
12+
import { Switch } from '@/shared/components/ui/switch'
13+
import { Input } from '@/shared/components/ui/input'
14+
import { Label } from '@/shared/components/ui/label'
15+
import { cn } from '@/shared/lib/utils'
16+
import {
17+
Sheet,
18+
SheetContent,
19+
SheetDescription,
20+
SheetHeader,
21+
SheetTitle,
22+
} from '@/shared/components/ui/sheet'
23+
import { useConfigStore } from '@/features/settings/config.store'
24+
import {
25+
Tabs,
26+
TabsContent,
27+
TabsList,
28+
TabsTrigger,
29+
} from '@/shared/components/ui/tabs'
30+
31+
import { useLogsPresenter, LogTab } from './useLogsPresenter'
32+
33+
function formatBytes(bytes: number, decimals = 2) {
34+
if (!+bytes) return '0 Bytes'
35+
const k = 1024
36+
const dm = decimals < 0 ? 0 : decimals
37+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
38+
const i = Math.floor(Math.log(bytes) / Math.log(k))
39+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
40+
}
41+
42+
function formatDate(unixSecs: number) {
43+
return new Date(unixSecs * 1000).toLocaleString()
44+
}
45+
46+
export function LogsPage() {
47+
const { t } = useTranslation()
48+
const {
49+
activeTab,
50+
setActiveTab,
51+
errorLogs,
52+
isErrorLogsLoading,
53+
isServerLogsLoading,
54+
selectedErrorLog,
55+
isViewingErrorLog,
56+
setIsViewingErrorLog,
57+
isDeleting,
58+
fetchErrorLogs,
59+
fetchServerLogs,
60+
saveErrorLog,
61+
deleteLogs,
62+
searchQuery,
63+
setSearchQuery,
64+
hideManagementLogs,
65+
setHideManagementLogs,
66+
showRawLogs,
67+
setShowRawLogs,
68+
autoRefresh,
69+
setAutoRefresh,
70+
parsedServerLogs,
71+
} = useLogsPresenter()
72+
73+
const logsEndRef = useRef<HTMLDivElement>(null)
74+
75+
useEffect(() => {
76+
if (autoRefresh) {
77+
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
78+
}
79+
}, [parsedServerLogs.items, autoRefresh])
80+
81+
const { config: appConfig, fetchConfig: fetchAppConfig } = useConfigStore()
82+
const loggingEnabled = Boolean(appConfig?.['logging-to-file'] ?? false)
83+
84+
useEffect(() => {
85+
if (!appConfig) fetchAppConfig()
86+
}, [appConfig, fetchAppConfig])
87+
88+
return (
89+
<motion.div
90+
initial={{ opacity: 0, y: 20 }}
91+
animate={{ opacity: 1, y: 0 }}
92+
transition={{ duration: 0.4 }}
93+
className="space-y-6 max-w-7xl mx-auto"
94+
>
95+
<div className="flex justify-between items-center">
96+
<div>
97+
<h2 className="text-3xl font-bold tracking-tight">{t('logs.title')}</h2>
98+
</div>
99+
<div className="flex items-center gap-2">
100+
{activeTab === 'server' && (
101+
<Button variant="outline" size="sm" onClick={async () => {
102+
try {
103+
const { save } = await import('@tauri-apps/plugin-dialog')
104+
const { writeTextFile } = await import('@tauri-apps/plugin-fs')
105+
const content = parsedServerLogs.items.map(i => i.original).join('\n')
106+
const defaultName = `server-logs-${new Date().toISOString().replace(/:/g, '-')}.log`
107+
const filePath = await save({
108+
defaultPath: defaultName,
109+
filters: [{ name: 'Log Files', extensions: ['log', 'txt'] }],
110+
})
111+
if (filePath) {
112+
await writeTextFile(filePath, content)
113+
const { toast } = await import('sonner')
114+
toast.success(t('logs.saved'), { description: filePath })
115+
}
116+
} catch (err) {
117+
console.error('Failed to save logs:', err)
118+
const { toast } = await import('sonner')
119+
toast.error(t('logs.saveFailed'))
120+
}
121+
}}>
122+
<Download className="w-4 h-4 mr-2" />
123+
{t('logs.downloadLogs')}
124+
</Button>
125+
)}
126+
<Button
127+
variant="outline"
128+
size="sm"
129+
onClick={() => {
130+
if (activeTab === 'error') fetchErrorLogs()
131+
else fetchServerLogs()
132+
}}
133+
disabled={isErrorLogsLoading || isServerLogsLoading}
134+
>
135+
<RotateCcw className={`h-4 w-4 mr-2 ${(isErrorLogsLoading || isServerLogsLoading) ? 'animate-spin' : ''}`} />
136+
{t('common.refresh')}
137+
</Button>
138+
<Button
139+
variant="destructive"
140+
size="sm"
141+
onClick={deleteLogs}
142+
disabled={isDeleting}
143+
>
144+
<Trash2 className="h-4 w-4 mr-2" />
145+
{t('logs.clearLogs')}
146+
</Button>
147+
</div>
148+
</div>
149+
150+
{!loggingEnabled && (
151+
<div className="flex items-center gap-3 rounded-lg border border-yellow-900/50 bg-yellow-950/20 p-3 text-sm text-yellow-400">
152+
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
153+
<span>{t('logs.loggingDisabled')}</span>
154+
</div>
155+
)}
156+
157+
<Tabs
158+
value={activeTab}
159+
onValueChange={(val) => setActiveTab(val as LogTab)}
160+
className="w-full"
161+
>
162+
<TabsList className="w-fit justify-start">
163+
<TabsTrigger value="server">
164+
<FileText className="w-4 h-4 mr-2" />
165+
{t('logs.serverLogs')}
166+
</TabsTrigger>
167+
<TabsTrigger value="error">
168+
<AlertCircle className="w-4 h-4 mr-2" />
169+
{t('logs.errorLogs')}
170+
</TabsTrigger>
171+
</TabsList>
172+
173+
<TabsContent value="error" className="mt-4">
174+
<Card>
175+
<CardContent className="pt-6">
176+
<p className="text-sm text-muted-foreground mb-4">
177+
{t('logs.errorLogsDesc')}
178+
</p>
179+
{isErrorLogsLoading ? (
180+
<div className="flex justify-center p-8 text-sm text-muted-foreground animate-pulse">
181+
{t('logs.loadingErrorLogs')}
182+
</div>
183+
) : errorLogs.length === 0 ? (
184+
<div className="flex flex-col items-center justify-center py-12 text-center">
185+
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4 opacity-20" />
186+
<h3 className="text-lg font-medium">{t('logs.noErrorLogs')}</h3>
187+
<p className="text-sm text-muted-foreground mt-1">
188+
{t('logs.noErrorLogsDesc')}
189+
</p>
190+
</div>
191+
) : (
192+
<ScrollArea style={{ height: 'calc(100vh - 300px)' }}>
193+
<div className="space-y-3 pr-4">
194+
{errorLogs.map((log) => (
195+
<div
196+
key={log.name}
197+
className="flex items-center justify-between rounded-lg border bg-card p-4 hover:bg-muted/30 transition-colors"
198+
>
199+
<div className="flex flex-col gap-1 min-w-0 flex-1 mr-4">
200+
<span className="text-sm font-medium font-mono truncate">{log.name}</span>
201+
<span className="text-xs text-muted-foreground font-mono">
202+
{formatBytes(log.size)}{' '}{formatDate(log.modified)}
203+
</span>
204+
</div>
205+
<Button
206+
variant="outline"
207+
size="sm"
208+
onClick={() => saveErrorLog(log.name)}
209+
>
210+
{t('common.download')}
211+
</Button>
212+
</div>
213+
))}
214+
</div>
215+
</ScrollArea>
216+
)}
217+
</CardContent>
218+
</Card>
219+
</TabsContent>
220+
221+
<TabsContent value="server" className="mt-4 flex flex-col gap-4">
222+
<Card className="flex-1 flex flex-col overflow-hidden border-border/50">
223+
<ScrollArea className="border-b bg-muted/20">
224+
<div className="px-4 py-2.5 flex items-center gap-3">
225+
<Input
226+
placeholder={t('logs.searchPlaceholder')}
227+
className="h-8 w-48 text-xs bg-background/50 flex-shrink-0"
228+
value={searchQuery}
229+
onChange={(e) => setSearchQuery(e.target.value)}
230+
/>
231+
232+
<div className="flex items-center gap-1.5 flex-shrink-0">
233+
<Switch id="hide-mgmt-log" checked={hideManagementLogs} onCheckedChange={setHideManagementLogs} />
234+
<Label htmlFor="hide-mgmt-log" className="flex items-center gap-1 cursor-pointer text-xs whitespace-nowrap"><EyeOff className="w-3 h-3"/> {t('logs.hideMgmtLogs')}</Label>
235+
</div>
236+
237+
<div className="flex items-center gap-1.5 flex-shrink-0">
238+
<Switch id="show-raw" checked={showRawLogs} onCheckedChange={setShowRawLogs} />
239+
<Label htmlFor="show-raw" className="flex items-center gap-1 cursor-pointer text-xs whitespace-nowrap"><Code className="w-3 h-3"/> {t('logs.showRawLogs')}</Label>
240+
</div>
241+
242+
<div className="flex-1 min-w-4" />
243+
244+
<div className="flex items-center gap-1.5 flex-shrink-0">
245+
<Switch id="auto-refresh" checked={autoRefresh} onCheckedChange={setAutoRefresh} />
246+
<Label htmlFor="auto-refresh" className="flex items-center gap-1 cursor-pointer text-xs whitespace-nowrap"><Clock className="w-3 h-3"/> {t('logs.autoRefresh')}</Label>
247+
</div>
248+
</div>
249+
<ScrollBar orientation="horizontal" />
250+
</ScrollArea>
251+
252+
<CardContent className="p-0 bg-[#121212]">
253+
<div style={{ height: 'calc(100vh - 280px)' }}>
254+
<ScrollArea className="h-full w-full">
255+
<div className="flex items-center justify-between p-3 text-xs text-muted-foreground/50 border-b border-zinc-900/50 bg-[#121212] sticky top-0 z-10 w-full backdrop-blur-sm">
256+
<span className="italic">{t('logs.scrollUp')}</span>
257+
<div className="flex gap-6 font-medium tracking-wide">
258+
<span>{t('logs.loaded')}: <span className="text-foreground">{parsedServerLogs.stats.loaded}</span></span>
259+
<span>{t('logs.filtered')}: <span className="text-foreground">{parsedServerLogs.stats.filtered}</span></span>
260+
<span>{t('logs.hidden')}: <span className="text-foreground">{parsedServerLogs.stats.hidden}</span></span>
261+
</div>
262+
</div>
263+
{isServerLogsLoading && parsedServerLogs.items.length === 0 ? (
264+
<div className="flex justify-center p-8 text-sm text-muted-foreground animate-pulse">
265+
{t('logs.loadingServerLogs')}
266+
</div>
267+
) : showRawLogs ? (
268+
<div className="p-4">
269+
<pre className="text-xs text-zinc-300 font-mono whitespace-pre-wrap leading-relaxed">
270+
{parsedServerLogs.items.length > 0
271+
? parsedServerLogs.items.map(i => i.original).join('\n')
272+
: t('logs.noLogsAvailable')}
273+
</pre>
274+
</div>
275+
) : (
276+
<div className="flex flex-col pb-4">
277+
{parsedServerLogs.items.length > 0 ? parsedServerLogs.items.map((log, i) => (
278+
<div key={i} className={cn(
279+
"flex gap-4 px-4 py-2 hover:bg-[#1a1a1a] border-b border-zinc-900/50 group text-[11px] font-mono leading-relaxed items-start",
280+
(log.level === 'ERROR' || log.level === 'FATAL' || log.level === 'PANIC') && "border-l-2 border-l-red-500 bg-red-950/10"
281+
)}>
282+
<div className="w-[140px] flex-shrink-0 text-zinc-500 whitespace-nowrap">{log.timestamp}</div>
283+
<div className="w-[60px] flex-shrink-0 flex items-center">
284+
<span className={cn(
285+
"px-2 py-[2px] rounded-full text-[9px] uppercase font-bold tracking-widest border border-transparent",
286+
log.level === 'INFO' ? "border-zinc-700/50 text-zinc-300 bg-zinc-800/30" :
287+
log.level === 'DEBUG' ? "border-zinc-700/30 text-zinc-500 bg-zinc-800/10" :
288+
log.level === 'WARN' ? "border-yellow-900/50 text-yellow-500 bg-yellow-900/20" :
289+
log.level === 'ERROR' || log.level === 'FATAL' || log.level === 'PANIC' ? "border-red-900/50 text-red-500 bg-red-900/20" :
290+
"border-zinc-800 text-zinc-400"
291+
)}>{log.level}</span>
292+
</div>
293+
<div className="w-[180px] flex-shrink-0 text-zinc-500 truncate" title={log.source}>{log.source}</div>
294+
<div className={cn(
295+
"flex-1 whitespace-pre-wrap break-all",
296+
log.level === 'ERROR' || log.level === 'FATAL' || log.level === 'PANIC' ? "text-red-400" :
297+
log.level === 'WARN' ? "text-yellow-400" :
298+
"text-zinc-300"
299+
)}>{log.message}</div>
300+
</div>
301+
)) : (
302+
<div className="p-8 text-center text-zinc-500 flex flex-col items-center gap-2">
303+
<FileText className="w-8 h-8 opacity-20" />
304+
<p>{t('logs.noMatchingLogs')}</p>
305+
</div>
306+
)}
307+
<div ref={logsEndRef} />
308+
</div>
309+
)}
310+
</ScrollArea>
311+
</div>
312+
</CardContent>
313+
</Card>
314+
</TabsContent>
315+
</Tabs>
316+
317+
<Sheet open={isViewingErrorLog} onOpenChange={setIsViewingErrorLog}>
318+
<SheetContent side="right" className="w-[90vw] sm:max-w-2xl overflow-hidden flex flex-col p-0 border-l border-border/40 shadow-2xl">
319+
<div className="p-6 pb-4 border-b bg-muted/30">
320+
<SheetHeader>
321+
<SheetTitle className="flex items-center gap-2 text-xl">
322+
<AlertCircle className="h-5 w-5 text-destructive" />
323+
{t('logs.errorPayloadTitle')}
324+
</SheetTitle>
325+
<SheetDescription className="break-all font-mono text-xs">
326+
{selectedErrorLog?.name}
327+
</SheetDescription>
328+
</SheetHeader>
329+
</div>
330+
<div className="flex-1 overflow-hidden p-6">
331+
<ScrollArea className="h-full w-full rounded-md border bg-zinc-950 p-4">
332+
<pre className="text-xs font-mono text-zinc-300 whitespace-pre-wrap leading-relaxed break-words">
333+
{selectedErrorLog?.content || t('common.loading')}
334+
</pre>
335+
</ScrollArea>
336+
</div>
337+
<div className="p-4 border-t bg-muted/30 flex justify-end">
338+
<Button
339+
variant="outline"
340+
size="sm"
341+
onClick={() => {
342+
if (selectedErrorLog) {
343+
const blob = new Blob([selectedErrorLog.content], { type: 'text/plain' })
344+
const url = window.URL.createObjectURL(blob)
345+
const a = document.createElement('a')
346+
a.href = url
347+
a.download = selectedErrorLog.name
348+
document.body.appendChild(a)
349+
a.click()
350+
window.URL.revokeObjectURL(url)
351+
document.body.removeChild(a)
352+
}
353+
}}
354+
>
355+
<Download className="h-4 w-4 mr-2" />
356+
{t('logs.downloadRaw')}
357+
</Button>
358+
</div>
359+
</SheetContent>
360+
</Sheet>
361+
</motion.div>
362+
)
363+
}

0 commit comments

Comments
 (0)