mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 19:30:51 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aee78c072 | ||
|
|
8780ea7ec5 | ||
|
|
40fe33aeae | ||
|
|
2a94be08fa | ||
|
|
0758cfe08a |
@@ -3,11 +3,12 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
|
||||
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className={className ? `card ${className}` : 'card'}>
|
||||
{(title || extra) && (
|
||||
<div className="card-header">
|
||||
<div className="title">{title}</div>
|
||||
|
||||
@@ -608,6 +608,8 @@
|
||||
"auto_refresh_disabled": "Auto refresh disabled",
|
||||
"load_more_hint": "Scroll up to load more",
|
||||
"hidden_lines": "Hidden: {{count}} lines",
|
||||
"loaded_lines": "Loaded: {{count}} lines",
|
||||
"filtered_lines": "Filtered: {{count}} lines",
|
||||
"hide_management_logs": "Hide {{prefix}} logs",
|
||||
"search_placeholder": "Search logs by content or keyword",
|
||||
"search_empty_title": "No matching logs found",
|
||||
|
||||
@@ -608,6 +608,8 @@
|
||||
"auto_refresh_disabled": "自动刷新已关闭",
|
||||
"load_more_hint": "向上滚动加载更多",
|
||||
"hidden_lines": "已隐藏 {{count}} 行",
|
||||
"loaded_lines": "已载入 {{count}} 行",
|
||||
"filtered_lines": "已过滤 {{count}} 行",
|
||||
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
||||
"search_placeholder": "搜索日志内容或关键字",
|
||||
"search_empty_title": "未找到匹配的日志",
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -21,6 +25,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.searchInputWrapper {
|
||||
@@ -127,7 +133,8 @@
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
@@ -206,6 +213,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.configCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@@ -224,7 +224,7 @@ export function ConfigPage() {
|
||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||
<p className={styles.description}>{t('config_management.description')}</p>
|
||||
|
||||
<Card>
|
||||
<Card className={styles.configCard}>
|
||||
<div className={styles.content}>
|
||||
{/* Editor */}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
@@ -2,19 +2,64 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -22,9 +67,12 @@
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
|
||||
@include mobile {
|
||||
align-items: flex-start;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +160,8 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
max-height: 620px;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
@@ -137,6 +186,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loadMoreStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.logList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -310,5 +365,5 @@
|
||||
|
||||
.message {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------|---------)$/i;
|
||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
||||
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
||||
@@ -156,6 +156,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
remaining = remaining.slice(tsMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let requestId: string | undefined;
|
||||
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
|
||||
if (requestIdMatch) {
|
||||
const id = requestIdMatch[1];
|
||||
if (!/^-+$/.test(id)) {
|
||||
requestId = id;
|
||||
}
|
||||
remaining = remaining.slice(requestIdMatch[0].length).trim();
|
||||
}
|
||||
|
||||
let level: LogLevel | undefined;
|
||||
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
||||
if (lvlMatch) {
|
||||
@@ -175,7 +185,6 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
let ip: string | undefined;
|
||||
let method: HttpMethod | undefined;
|
||||
let path: string | undefined;
|
||||
let requestId: string | undefined;
|
||||
let message = remaining;
|
||||
|
||||
if (remaining.includes('|')) {
|
||||
@@ -216,9 +225,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
}
|
||||
|
||||
// status code
|
||||
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
|
||||
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
|
||||
if (statusIndex >= 0) {
|
||||
const match = segments[statusIndex].match(/^(\d{3})\b/);
|
||||
const match = segments[statusIndex].match(/^(\d{3})$/);
|
||||
if (match) {
|
||||
const code = Number.parseInt(match[1], 10);
|
||||
if (code >= 100 && code <= 599) {
|
||||
@@ -346,11 +355,14 @@ const copyToClipboard = async (text: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
type TabType = 'logs' | 'errors';
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -626,261 +638,278 @@ 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={() => setActiveTab('errors')}
|
||||
>
|
||||
{t('logs.error_logs_modal_title')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<Card
|
||||
title={t('logs.log_content')}
|
||||
extra={
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => loadLogs(false)}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<IconRefreshCw size={16} />
|
||||
{t('logs.refresh_button')}
|
||||
</span>
|
||||
</Button>
|
||||
{activeTab === 'logs' && (
|
||||
<Card className={styles.logCard}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
<div className={styles.filters}>
|
||||
<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="Clear"
|
||||
aria-label="Clear"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<IconSearch size={16} className={styles.searchIcon} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
checked={autoRefresh}
|
||||
onChange={(value) => setAutoRefresh(value)}
|
||||
disabled={disableControls}
|
||||
checked={hideManagementLogs}
|
||||
onChange={setHideManagementLogs}
|
||||
label={
|
||||
<span className={styles.switchLabel}>
|
||||
<IconTimer size={16} />
|
||||
{t('logs.auto_refresh')}
|
||||
<IconEyeOff size={16} />
|
||||
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
|
||||
</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.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="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 })}
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => loadLogs(false)}
|
||||
disabled={disableControls || loading}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<span className={styles.buttonContent}>
|
||||
<IconRefreshCw size={16} />
|
||||
{t('logs.refresh_button')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.logList}>
|
||||
{parsedVisibleLines.map((line, index) => {
|
||||
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={`${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>
|
||||
);
|
||||
})}
|
||||
</Button>
|
||||
<ToggleSwitch
|
||||
checked={autoRefresh}
|
||||
onChange={(value) => setAutoRefresh(value)}
|
||||
disabled={disableControls}
|
||||
label={
|
||||
<span className={styles.switchLabel}>
|
||||
<IconTimer size={16} />
|
||||
{t('logs.auto_refresh')}
|
||||
</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>
|
||||
</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
|
||||
title={t('logs.error_logs_modal_title')}
|
||||
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) : ''}
|
||||
{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>
|
||||
<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.hidden_lines', { count: logState.visibleFrom })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadErrorLog(item.name)}
|
||||
>
|
||||
{t('logs.error_logs_download')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.logList}>
|
||||
{parsedVisibleLines.map((line, index) => {
|
||||
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={`${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>
|
||||
)}
|
||||
|
||||
{line.requestId && (
|
||||
<span
|
||||
className={[styles.badge, styles.requestIdBadge].join(' ')}
|
||||
title={line.requestId}
|
||||
>
|
||||
{line.requestId}
|
||||
</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.path && (
|
||||
<span className={styles.path} title={line.path}>
|
||||
{line.path}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{line.message && <span className={styles.message}>{line.message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -336,6 +336,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: $spacing-md;
|
||||
|
||||
Reference in New Issue
Block a user