feat(logs): implement tabbed view for logs and error files

This commit is contained in:
hkfires
2025-12-24 11:45:14 +08:00
parent 02a01e5afc
commit 0758cfe08a
4 changed files with 299 additions and 242 deletions

View File

@@ -608,6 +608,7 @@
"auto_refresh_disabled": "Auto refresh disabled", "auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more", "load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines", "hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs", "hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword", "search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found", "search_empty_title": "No matching logs found",

View File

@@ -608,6 +608,7 @@
"auto_refresh_disabled": "自动刷新已关闭", "auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多", "load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行", "hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志", "hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字", "search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志", "search_empty_title": "未找到匹配的日志",

View File

@@ -8,7 +8,38 @@
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 $spacing-xl 0; margin: 0 0 $spacing-lg 0;
}
.tabBar {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.tabItem {
@include button-reset;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.tabActive {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
} }
.content { .content {
@@ -22,9 +53,12 @@
align-items: center; align-items: center;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: auto;
@include mobile { @include mobile {
align-items: flex-start; align-items: flex-start;
margin-left: 0;
width: 100%;
} }
} }
@@ -137,6 +171,12 @@
white-space: nowrap; white-space: nowrap;
} }
.loadMoreStats {
display: flex;
align-items: center;
gap: $spacing-md;
}
.logList { .logList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -346,11 +346,14 @@ const copyToClipboard = async (text: string) => {
} }
}; };
type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 }); const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -547,7 +550,7 @@ export function LogsPage() {
const isSearching = trimmedSearchQuery.length > 0; const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines; const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => { const { filteredLines } = useMemo(() => {
let working = baseLines; let working = baseLines;
let removed = 0; let removed = 0;
@@ -626,261 +629,273 @@ export function LogsPage() {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1> <h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}> <div className={styles.content}>
<Card {activeTab === 'logs' && (
title={t('logs.log_content')} <Card>
extra={ {error && <div className="error-box">{error}</div>}
<div className={styles.toolbar}>
<Button <div className={styles.filters}>
variant="secondary" <div className={styles.searchWrapper}>
size="sm" <Input
onClick={() => loadLogs(false)} value={searchQuery}
disabled={disableControls || loading} onChange={(e) => setSearchQuery(e.target.value)}
className={styles.actionButton} placeholder={t('logs.search_placeholder')}
> className={styles.searchInput}
<span className={styles.buttonContent}> rightElement={
<IconRefreshCw size={16} /> searchQuery ? (
{t('logs.refresh_button')} <button
</span> type="button"
</Button> className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch <ToggleSwitch
checked={autoRefresh} checked={hideManagementLogs}
onChange={(value) => setAutoRefresh(value)} onChange={setHideManagementLogs}
disabled={disableControls}
label={ label={
<span className={styles.switchLabel}> <span className={styles.switchLabel}>
<IconTimer size={16} /> <IconEyeOff size={16} />
{t('logs.auto_refresh')} {t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span> </span>
} }
/> />
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}> <div className={styles.toolbar}>
<div className={styles.searchWrapper}> <Button
<Input variant="secondary"
value={searchQuery} size="sm"
onChange={(e) => setSearchQuery(e.target.value)} onClick={() => loadLogs(false)}
placeholder={t('logs.search_placeholder')} disabled={disableControls || loading}
className={styles.searchInput} className={styles.actionButton}
rightElement={ >
searchQuery ? ( <span className={styles.buttonContent}>
<button <IconRefreshCw size={16} />
type="button" {t('logs.refresh_button')}
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.filterStats}>
<span>
{parsedVisibleLines.length} {t('logs.lines')}
</span>
{removedCount > 0 && (
<span className={styles.removedCount}>
{t('logs.removed')} {removedCount}
</span>
)}
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span> </span>
</div> </Button>
)} <ToggleSwitch
<div className={styles.logList}> checked={autoRefresh}
{parsedVisibleLines.map((line, index) => { onChange={(value) => setAutoRefresh(value)}
const rowClassNames = [styles.logRow]; disabled={disableControls}
if (line.level === 'warn') rowClassNames.push(styles.rowWarn); label={
if (line.level === 'error' || line.level === 'fatal') <span className={styles.switchLabel}>
rowClassNames.push(styles.rowError); <IconTimer size={16} />
return ( {t('logs.auto_refresh')}
<div </span>
key={`${logState.visibleFrom + index}-${line.raw}`} }
className={rowClassNames.join(' ')} />
onDoubleClick={() => { <Button
void copyLogLine(line.raw); variant="secondary"
}} size="sm"
title={t('logs.double_click_copy_hint', { onClick={downloadLogs}
defaultValue: 'Double-click to copy', disabled={logState.buffer.length === 0}
})} className={styles.actionButton}
> >
<div className={styles.timestamp}>{line.timestamp || ''}</div> <span className={styles.buttonContent}>
<div className={styles.rowMain}> <IconDownload size={16} />
{line.level && ( {t('logs.download_button')}
<span </span>
className={[ </Button>
styles.badge, <Button
line.level === 'info' ? styles.levelInfo : '', variant="danger"
line.level === 'warn' ? styles.levelWarn : '', size="sm"
line.level === 'error' || line.level === 'fatal' onClick={clearLogs}
? styles.levelError disabled={disableControls}
: '', className={styles.actionButton}
line.level === 'debug' ? styles.levelDebug : '', >
line.level === 'trace' ? styles.levelTrace : '', <span className={styles.buttonContent}>
] <IconTrash2 size={16} />
.filter(Boolean) {t('logs.clear_button')}
.join(' ')} </span>
> </Button>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</div> </div>
</div> </div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card {loading ? (
title={t('logs.error_logs_modal_title')} <div className="hint">{t('logs.loading')}</div>
extra={ ) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}> <div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{t('common.refresh')} {canLoadMore && (
</Button> <div className={styles.loadMoreBanner}>
} <span>{t('logs.load_more_hint')}</span>
> <div className={styles.loadMoreStats}>
{errorLogs.length === 0 ? ( <span>
<div className="hint">{t('logs.error_logs_empty')}</div> {t('logs.loaded_lines', { count: parsedVisibleLines.length })}
) : ( </span>
<div className="item-list"> <span className={styles.loadMoreCount}>
{errorLogs.map((item) => ( {t('logs.hidden_lines', { count: logState.visibleFrom })}
<div key={item.name} className="item-row"> </span>
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div> </div>
</div> </div>
<div className="item-actions"> )}
<Button <div className={styles.logList}>
variant="secondary" {parsedVisibleLines.map((line, index) => {
size="sm" const rowClassNames = [styles.logRow];
onClick={() => downloadErrorLog(item.name)} if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
> if (line.level === 'error' || line.level === 'fatal')
{t('logs.error_logs_download')} rowClassNames.push(styles.rowError);
</Button> return (
</div> <div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</div> </div>
))} </div>
</div> ) : logState.buffer.length > 0 ? (
)} <EmptyState
</Card> title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
)}
{activeTab === 'errors' && (
<Card
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
)}
</div> </div>
</div> </div>
); );