feat(logs): enhance log viewer with line wrapping and improved filter options

- Added a toggle for wrapping long log lines in the LogsPage component.
- Updated the structured filters panel to open in a modal for better accessibility.
- Improved accessibility features with appropriate ARIA attributes.
- Refactored log rendering logic to support new wrapping functionality.
- Updated translations for filter and display settings in multiple languages.
- Enhanced styles for better layout and responsiveness in log viewer.
- Added tests to ensure layout integrity and functionality of new features.
This commit is contained in:
Supra4E8C
2026-09-29 13:46:16 +08:00
parent deab4d6055
commit d82d95465b
10 changed files with 961 additions and 980 deletions
+9
View File
@@ -19,6 +19,15 @@ const baseSvgProps: SVGProps<SVGSVGElement> = {
focusable: 'false',
};
export function IconCopy({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
}
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
File diff suppressed because it is too large Load Diff
+386 -352
View File
@@ -9,8 +9,7 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { lockScroll, unlockScroll } from '@/components/ui/scrollLock';
import {
IconChevronDown,
IconChevronUp,
IconCopy,
IconCode,
IconDownload,
IconEye,
@@ -69,10 +68,8 @@ export function LogsPage() {
true
);
const [showRawLogs, setShowRawLogs] = useLocalStorage('logsPage.showRawLogs', false);
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useLocalStorage(
'logsPage.structuredFiltersExpanded',
true
);
const [wrapLogs, setWrapLogs] = useLocalStorage('logsPage.wrapLogs', false);
const [structuredFiltersExpanded, setStructuredFiltersExpanded] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogFile[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
@@ -344,6 +341,7 @@ export function LogsPage() {
filteredLineCount: filteredLines.length,
hasStructuredFilters: filters.hasStructuredFilters,
showRawLogs,
wrapLogs,
});
const copyLogLine = async (raw: string) => {
@@ -401,27 +399,30 @@ export function LogsPage() {
return (
<div className={styles.container}>
<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={() => {
setFullscreenLogs(false);
setActiveTab('errors');
}}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<header className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar} role="group" aria-label={t('logs.title')}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
aria-pressed={activeTab === 'logs'}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
aria-pressed={activeTab === 'errors'}
onClick={() => {
setFullscreenLogs(false);
setActiveTab('errors');
}}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
</header>
<div className={styles.content}>
{activeTab === 'logs' && (
@@ -444,120 +445,132 @@ export function LogsPage() {
{error}
</div>
)}
<div className="hint" role="status">
{t(
catchingUp && autoRefresh
? 'logs.read_status_catching_up'
: autoRefresh
? 'logs.read_status_live'
: 'logs.read_status_paused'
<footer className={styles.statusBar}>
<div
className={styles.readStatus}
role="status"
data-live={autoRefresh && !disableControls}
>
<span className={styles.statusDot} aria-hidden="true" />
{t(
catchingUp && autoRefresh
? 'logs.read_status_catching_up'
: autoRefresh
? 'logs.read_status_live'
: 'logs.read_status_paused'
)}
{lastUpdated && (
<>
{' '}
· {t('logs.last_updated', { time: new Date(lastUpdated).toLocaleTimeString() })}
</>
)}
</div>
<div className={styles.bufferStatus}>
{t('logs.buffer_scope', {
count: logBuffer.buffer.length,
matched: filteredLines.length,
})}
</div>
</footer>
<div className={styles.notices}>
{logBuffer.evicted > 0 && <div className="hint">{t('logs.buffer_evicted')}</div>}
{wasReset && (
<div className="hint" role="status">
{t('logs.cursor_reset_notice')}
</div>
)}
{lastUpdated && (
<>
{' '}
· {t('logs.last_updated', { time: new Date(lastUpdated).toLocaleTimeString() })}
</>
{historyEvicted && (
<div className="hint" role="status">
{t('logs.history_evicted')}
</div>
)}
</div>
<div className="hint">
{t('logs.buffer_scope', {
count: logBuffer.buffer.length,
matched: filteredLines.length,
})}
</div>
{logBuffer.evicted > 0 && <div className="hint">{t('logs.buffer_evicted')}</div>}
{wasReset && (
<div className="hint" role="status">
{t('logs.cursor_reset_notice')}
</div>
)}
{historyEvicted && (
<div className="hint" role="status">
{t('logs.history_evicted')}
</div>
)}
{!isFollowing && (
<Button variant="secondary" size="sm" onClick={resumeFollowing}>
<Button
className={styles.followButton}
variant="secondary"
size="sm"
onClick={resumeFollowing}
>
{t('logs.resume_following', { count: pendingLines })}
</Button>
)}
<div className={styles.filters}>
{!fullscreenLogs && (
<>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title={t('logs.clear_search')}
aria-label={t('logs.clear_search')}
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
aria-label={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title={t('logs.clear_search')}
aria-label={t('logs.clear_search')}
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
<Select
fullWidth={false}
size="sm"
value={levelFilter}
onChange={setLevelFilter}
ariaLabel={t('logs.level_filter')}
options={[
{ value: '', label: t('logs.all_levels') },
...['trace', 'debug', 'info', 'warn', 'error', 'fatal'].map((level) => ({
value: level,
label: level.toUpperCase(),
})),
]}
/>
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-expanded={structuredFiltersExpanded}
aria-controls={structuredFiltersPanelId}
title={
structuredFiltersExpanded
? t('logs.filter_panel_collapse')
: t('logs.filter_panel_expand')
}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
)}
{structuredFiltersExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)}
<Select
fullWidth={false}
size="sm"
value={levelFilter}
onChange={setLevelFilter}
ariaLabel={t('logs.level_filter')}
options={[
{ value: '', label: t('logs.all_levels') },
...['trace', 'debug', 'info', 'warn', 'error', 'fatal'].map((level) => ({
value: level,
label: level.toUpperCase(),
})),
]}
/>
<div className={styles.filterPanelHeader}>
<Button
type="button"
variant="secondary"
size="sm"
className={styles.filterPanelToggle}
onClick={() => setStructuredFiltersExpanded((prev) => !prev)}
aria-haspopup="dialog"
aria-label={t('logs.filter_panel_title')}
title={t('logs.filter_panel_title')}
>
<span className={styles.filterPanelButtonContent}>
<IconSlidersHorizontal size={16} />
<span className={styles.filterPanelLabel}>{t('logs.filter_panel_title')}</span>
{structuredFilterCount > 0 && (
<span className={styles.filterPanelCount}>
{t('logs.filter_panel_active_count', { count: structuredFilterCount })}
</span>
</Button>
</div>
</>
)}
)}
</span>
</Button>
</div>
{!fullscreenLogs && structuredFiltersExpanded && (
<Modal
open={structuredFiltersExpanded}
onClose={() => setStructuredFiltersExpanded(false)}
title={t('logs.filter_panel_title')}
width={640}
footer={
<Button variant="secondary" onClick={() => setStructuredFiltersExpanded(false)}>
{t('common.close')}
</Button>
}
>
<div id={structuredFiltersPanelId} className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
@@ -637,34 +650,41 @@ export function LogsPage() {
{t('logs.clear_filters')}
</Button>
</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.displayOptions}>
<ToggleSwitch
checked={wrapLogs}
onChange={setWrapLogs}
label={t('logs.wrap_lines')}
/>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<ToggleSwitch
checked={showRawLogs}
onChange={setShowRawLogs}
label={
<span
className={styles.switchLabel}
title={t('logs.show_raw_logs_hint', {
defaultValue: 'Show original log text for easier multi-line copy',
})}
>
<IconCode size={16} />
{t('logs.show_raw_logs', { defaultValue: 'Show raw logs' })}
</span>
}
/>
<ToggleSwitch
checked={showRawLogs}
onChange={setShowRawLogs}
label={
<span
className={styles.switchLabel}
title={t('logs.show_raw_logs_hint', {
defaultValue: 'Show original log text for easier multi-line copy',
})}
>
<IconCode size={16} />
{t('logs.show_raw_logs', { defaultValue: 'Show raw logs' })}
</span>
}
/>
</div>
</Modal>
<div className={styles.toolbar}>
<Button
@@ -673,46 +693,44 @@ export function LogsPage() {
onClick={() => loadLogs(false)}
disabled={refreshDisabled}
className={styles.actionButton}
title={t('logs.refresh_button')}
aria-label={t('logs.refresh_button')}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
<IconRefreshCw size={16} />
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
<Button
variant="secondary"
size="sm"
className={styles.actionButton}
aria-pressed={autoRefresh}
aria-label={t('logs.reading_enabled')}
title={t('logs.reading_enabled')}
onClick={() => setAutoRefresh(!autoRefresh)}
disabled={autoRefreshDisabled}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.reading_enabled')}
</span>
}
/>
>
<IconTimer size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logBuffer.buffer.length === 0}
className={styles.actionButton}
title={t('logs.download_cached')}
aria-label={t('logs.download_cached')}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_cached')}
</span>
<IconDownload size={16} />
</Button>
<Button
variant="danger"
variant="ghost"
size="sm"
onClick={clearLogs}
disabled={clearDisabled}
className={styles.actionButton}
title={t('logs.clear_button')}
aria-label={t('logs.clear_button')}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
<IconTrash2 size={16} />
</Button>
<Button
variant="secondary"
@@ -720,200 +738,216 @@ export function LogsPage() {
onClick={() => setFullscreenLogs((prev) => !prev)}
className={styles.actionButton}
aria-pressed={fullscreenLogs}
aria-label={
fullscreenLogs ? t('logs.exit_fullscreen_button') : t('logs.fullscreen_button')
}
title={
fullscreenLogs ? t('logs.exit_fullscreen_button') : t('logs.fullscreen_button')
}
>
<span className={styles.buttonContent}>
{fullscreenLogs ? <IconMinimize2 size={16} /> : <IconMaximize2 size={16} />}
{fullscreenLogs
? t('logs.exit_fullscreen_button')
: t('logs.fullscreen_button')}
</span>
</Button>
</div>
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logBuffer.buffer.length > 0 && filteredLines.length > 0 ? (
<div
ref={logViewerRef}
className={[styles.logPanel, fullscreenLogs ? styles.logPanelFullscreen : '']
.filter(Boolean)
.join(' ')}
onScroll={handleLogScroll}
>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<Button
variant="secondary"
size="sm"
onClick={() =>
setVisibleCount((count) => Math.min(filteredLines.length, count + 200))
}
>
{t('logs.filter_load_more')}
</Button>
<div className={styles.loadMoreStats}>
<span>{t('logs.loaded_lines', { count: parsedVisibleLines.length })}</span>
{removedCount > 0 && (
<div className={styles.viewerArea}>
{loading && logBuffer.buffer.length === 0 ? (
<div className="hint">{t('logs.loading')}</div>
) : logBuffer.buffer.length > 0 && filteredLines.length > 0 ? (
<div
ref={logViewerRef}
className={[
styles.logPanel,
wrapLogs ? styles.wrapped : '',
fullscreenLogs ? styles.logPanelFullscreen : '',
]
.filter(Boolean)
.join(' ')}
onScroll={handleLogScroll}
tabIndex={0}
role="region"
aria-label={t('logs.log_content')}
aria-busy={loading}
>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<Button
variant="secondary"
size="sm"
onClick={() =>
setVisibleCount((count) => Math.min(filteredLines.length, count + 200))
}
>
{t('logs.filter_load_more')}
</Button>
<div className={styles.loadMoreStats}>
<span>{t('logs.loaded_lines', { count: parsedVisibleLines.length })}</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
</span>
)}
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
)}
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
</div>
</div>
</div>
)}
{showRawLogs ? (
<pre className={styles.rawLog} spellCheck={false}>
{parsedVisibleLines.map((line) => (
<span key={line.id} data-log-id={line.id} style={{ display: 'block' }}>
{line.raw || '\u00a0'}
</span>
))}
</pre>
) : (
<div className={styles.logList}>
{parsedVisibleLines.map((line) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={line.id}
data-log-id={line.id}
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(' ')}
)}
{showRawLogs ? (
<pre className={styles.rawLog} spellCheck={false}>
{parsedVisibleLines.map((line) => (
<span key={line.id} data-log-id={line.id} style={{ display: 'block' }}>
{line.raw || '\u00a0'}
</span>
))}
</pre>
) : (
<div className={styles.logList}>
{parsedVisibleLines.map((line) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={line.id}
data-log-id={line.id}
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>
)}
{line.requestId && (
<button
type="button"
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={t('logs.view_request', { id: line.requestId })}
aria-label={t('logs.view_request', { id: line.requestId })}
onClick={() =>
void openErrorLog(
{ name: `request-${line.requestId}.log` },
line.requestId
)
}
>
{line.requestId}
</button>
)}
{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.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && (
<span className={styles.message}>{line.message}</span>
)}
<Button
variant="ghost"
size="sm"
className={styles.copyButton}
title={t('logs.copy_line')}
onClick={() => void copyLogLine(line.raw)}
aria-label={t('logs.copy_line')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.requestId && (
<button
type="button"
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={t('logs.view_request', { id: line.requestId })}
aria-label={t('logs.view_request', { id: line.requestId })}
onClick={() =>
void openErrorLog(
{ name: `request-${line.requestId}.log` },
line.requestId
)
}
>
{line.requestId}
</button>
)}
{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.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
<Button
variant="secondary"
size="sm"
onClick={() => void copyLogLine(line.raw)}
aria-label={t('logs.copy_line')}
>
{t('common.copy')}
</Button>
<IconCopy size={14} />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
) : logBuffer.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : showFileLoggingRequired ? (
<EmptyState
title={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_title'
: 'logs.file_logging_required_title'
)}
description={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_desc'
: 'logs.file_logging_required_desc'
)}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
);
})}
</div>
)}
</div>
) : logBuffer.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : showFileLoggingRequired ? (
<EmptyState
title={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_title'
: 'logs.file_logging_required_title'
)}
description={t(
cpaNeedsFileLogging
? 'logs.cpa_file_logging_required_desc'
: 'logs.file_logging_required_desc'
)}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</div>
</Card>
)}
{activeTab === 'errors' && (
<Card
className={styles.errorCard}
title={t('logs.error_logs_modal_title')}
extra={
<Button
variant="secondary"
@@ -926,7 +960,7 @@ export function LogsPage() {
</Button>
}
>
<div className="stack">
<div className={styles.errorBody}>
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
+11 -1
View File
@@ -52,6 +52,7 @@ interface UseLogScrollerOptions {
filteredLineCount: number;
hasStructuredFilters: boolean;
showRawLogs: boolean;
wrapLogs?: boolean;
}
interface UseLogScrollerReturn {
@@ -76,6 +77,7 @@ export function useLogScroller(options: UseLogScrollerOptions): UseLogScrollerRe
filteredLineCount,
hasStructuredFilters,
showRawLogs,
wrapLogs = false,
} = options;
const logViewerRef = useRef<HTMLDivElement | null>(null);
@@ -204,7 +206,15 @@ export function useLogScroller(options: UseLogScrollerOptions): UseLogScrollerRe
pendingPrependScrollRef.current = null;
lastScrollTopRef.current = node.scrollTop;
anchorRef.current = findVisibleLogAnchor(readLogRows(node));
}, [bufferStart, loading, logState.buffer, logState.visibleFrom, showRawLogs, isFollowing]);
}, [
bufferStart,
loading,
logState.buffer,
logState.visibleFrom,
showRawLogs,
wrapLogs,
isFollowing,
]);
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
const node = logViewerRef.current;
+2 -1
View File
@@ -965,7 +965,8 @@
"show_raw_logs": "Show Raw Logs",
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
"search_placeholder": "Search logs by content or keyword",
"filter_panel_title": "Structured Filters",
"filter_panel_title": "Filters & display",
"wrap_lines": "Wrap long lines",
"filter_panel_expand": "Expand structured filters",
"filter_panel_collapse": "Collapse structured filters",
"filter_panel_active_count": "{{count}} active",
+2 -1
View File
@@ -968,7 +968,8 @@
"show_raw_logs": "Показать исходные журналы",
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
"search_placeholder": "Искать по содержимому или ключевым словам",
"filter_panel_title": "Структурные фильтры",
"filter_panel_title": "Фильтры и вид",
"wrap_lines": "Перенос длинных строк",
"filter_panel_expand": "Развернуть структурные фильтры",
"filter_panel_collapse": "Свернуть структурные фильтры",
"filter_panel_active_count": "Активно: {{count}}",
+2 -1
View File
@@ -963,7 +963,8 @@
"show_raw_logs": "显示原始日志",
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
"search_placeholder": "搜索日志内容或关键字",
"filter_panel_title": "结构化筛选",
"filter_panel_title": "筛选与显示",
"wrap_lines": "长日志自动换行",
"filter_panel_expand": "展开结构化筛选",
"filter_panel_collapse": "收起结构化筛选",
"filter_panel_active_count": "已选 {{count}} 项",
+2 -1
View File
@@ -989,7 +989,8 @@
"show_raw_logs": "顯示原始記錄",
"show_raw_logs_hint": "直接顯示原始記錄文字,方便多行複製",
"search_placeholder": "搜尋記錄內容或關鍵字",
"filter_panel_title": "結構化篩選",
"filter_panel_title": "篩選與顯示",
"wrap_lines": "長日誌自動換行",
"filter_panel_expand": "展開結構化篩選",
"filter_panel_collapse": "收起結構化篩選",
"filter_panel_active_count": "已選 {{count}} 項",
+9 -6
View File
@@ -795,6 +795,7 @@
&.content-logs {
overflow: hidden;
scrollbar-gutter: auto;
}
&.content-plugin-resource {
@@ -815,6 +816,7 @@
&.main-content-logs {
flex: 1 1 auto;
min-height: 0;
padding: 64px clamp(12px, 2vw, 28px) 16px;
overflow: hidden;
}
@@ -980,9 +982,9 @@
overflow-y: auto;
&.content-logs {
overflow: visible;
overflow-y: auto;
height: auto;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
&.content-plugin-resource {
@@ -1007,9 +1009,10 @@
}
&.main-content-logs {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
flex: 1 1 auto;
min-height: 0;
padding: calc(var(--header-height) + 8px) 8px max(8px, env(safe-area-inset-bottom));
overflow: hidden;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const readSource = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const page = readSource('src/features/logs/LogsPage.tsx');
const styles = readSource('src/features/logs/LogsPage.module.scss');
const layout = readSource('src/styles/layout.scss');
// Structural guards complement the browser viewport/interaction checks. They do not
// assert browser layout: the important contract is a single flexible scrolling viewer.
describe('log workspace layout contract', () => {
test('viewer uses remaining space instead of resolution-specific height budgets', () => {
const panel = styles.match(/\.logPanel \{([^}]+)\}/)?.[1] ?? '';
expect(panel).toContain('flex: 1');
expect(panel).toContain('min-height: 0');
expect(panel).toContain('overflow: auto');
expect(panel).not.toMatch(/(?:max-height|height):\s*(?:calc|\d+px)/);
expect(styles).not.toContain('resize: vertical');
expect(styles).not.toMatch(/calc\(100vh\s*-/);
expect(styles).not.toMatch(/\n\s*(?:height|min-height|max-height):\s*(?:360|420|480)px/);
expect(layout).toMatch(
/&\.content-logs\s*\{\s*height: 100dvh;\s*min-height: 0;\s*overflow: hidden;/
);
});
test('error archive and fullscreen share the flexible viewport contract', () => {
const archive = styles.match(/\.errorPanel \{([^}]+)\}/)?.[1] ?? '';
const fullscreen = styles.match(/\.logCardFullscreen \{([^}]+)\}/)?.[1] ?? '';
expect(archive).toContain('flex: 1');
expect(archive).toContain('min-height: 0');
expect(archive).not.toMatch(/\bheight:\s*\d+px/);
expect(fullscreen).toContain('height: 100dvh');
expect(styles).not.toMatch(/\.pageHeader\s*\{\s*display: none/);
expect(page).toContain('className={styles.errorCard}');
});
test('advanced controls are closed initially and opened in a focus-managed modal', () => {
expect(page).toContain(
'[structuredFiltersExpanded, setStructuredFiltersExpanded] = useState(false)'
);
expect(page).not.toContain('logsPage.structuredFiltersExpanded');
expect(page).toContain('aria-haspopup="dialog"');
expect(page).toMatch(/<Modal\s+open=\{structuredFiltersExpanded\}/);
expect(page).toContain("'logsPage.showRawLogs'");
expect(page).toContain("'logsPage.hideManagementLogs'");
expect(page).not.toContain('!fullscreenLogs &&');
});
test('row actions do not wrap into an empty line or remove keyboard access', () => {
const copy = styles.match(/^\.copyButton:global\(\.btn\) \{([^}]+)\}/m)?.[1] ?? '';
expect(copy).toContain('position: sticky');
expect(page).toContain("useLocalStorage('logsPage.wrapLogs', false)");
expect(styles).toContain('width: max-content');
expect(styles).toContain('flex-wrap: nowrap');
expect(styles).toContain('.logRow:focus-within .copyButton');
expect(page).toContain("aria-label={t('logs.copy_line')}");
expect(page).toContain('tabIndex={0}');
expect(page).toContain("aria-label={t('logs.log_content')}");
expect(page).toContain('aria-pressed={autoRefresh}');
});
test('background refresh retains loaded content and compact status stays outside viewer', () => {
expect(page).toContain('loading && logBuffer.buffer.length === 0');
expect(page).toContain('<footer className={styles.statusBar}>');
expect(page).toContain('<div className={styles.viewerArea}>');
expect(page).toContain('onScroll={handleLogScroll}');
expect(page).toContain('onClick={resumeFollowing}');
expect(page).toContain('data-log-id={line.id}');
});
test('all supported locales describe both filtering and display settings', () => {
for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru']) {
const messages = JSON.parse(readSource(`src/i18n/locales/${locale}.json`));
expect(messages.logs.filter_panel_title).toBeTruthy();
expect(messages.logs.show_raw_logs).toBeTruthy();
expect(messages.logs.wrap_lines).toBeTruthy();
expect(messages.logs.hide_management_logs).toBeTruthy();
}
});
});