release: bump version to 0.38.0
This commit is contained in:
515
frontend/src/pages/Logs/Logs.tsx
Normal file
515
frontend/src/pages/Logs/Logs.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Card, DatePicker, Empty, Input, InputNumber, Select, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
const LOG_FILTER_STORAGE_KEY = 'planet.logs.filters'
|
||||
const DATE_PRESET_OPTIONS = [
|
||||
{ key: 'today', label: 'Today', days: 0 },
|
||||
{ key: 'last3', label: '3 Days', days: 2 },
|
||||
{ key: 'last7', label: '7 Days', days: 6 },
|
||||
] as const
|
||||
|
||||
interface LogSourceSummary {
|
||||
source_id: string
|
||||
name: string
|
||||
kind: string
|
||||
location: string
|
||||
description: string
|
||||
category: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface LogSourcesResponse {
|
||||
items: LogSourceSummary[]
|
||||
}
|
||||
|
||||
interface LogSnapshot {
|
||||
source_id: string
|
||||
name: string
|
||||
kind: string
|
||||
location: string
|
||||
description: string
|
||||
category: string
|
||||
status: string
|
||||
level: string
|
||||
selected_levels: string[]
|
||||
search_query: string
|
||||
available_levels: string[]
|
||||
daily_markers: Array<{
|
||||
date_token: string
|
||||
total: number
|
||||
dominant_level: 'error' | 'warning' | 'info' | 'debug'
|
||||
}>
|
||||
line_limit: number
|
||||
line_count: number
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
interface DailyLogMarker {
|
||||
total: number
|
||||
dominantLevel: 'error' | 'warning' | 'info' | 'debug'
|
||||
}
|
||||
|
||||
const LOG_LIMIT_OPTIONS = [100, 200, 400, 800]
|
||||
const LOG_LEVEL_OPTIONS = [
|
||||
{ value: 'error', label: 'ERROR' },
|
||||
{ value: 'warning', label: 'WARNING' },
|
||||
{ value: 'info', label: 'INFO' },
|
||||
{ value: 'debug', label: 'DEBUG' },
|
||||
]
|
||||
|
||||
function isDayjsValue(value: unknown): value is Dayjs {
|
||||
return dayjs.isDayjs(value)
|
||||
}
|
||||
|
||||
function normalizeSelectedLevels(levels: string[] | null | undefined): string[] {
|
||||
const allowedLevels = new Set(LOG_LEVEL_OPTIONS.map((item) => item.value))
|
||||
return Array.from(new Set((levels || []).filter((level) => allowedLevels.has(level))))
|
||||
}
|
||||
|
||||
function getLogLevelTagColor(level: string): string {
|
||||
if (level === 'error') return 'error'
|
||||
if (level === 'warning') return 'warning'
|
||||
if (level === 'info') return 'success'
|
||||
if (level === 'debug') return 'default'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function readStoredFilters() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
|
||||
if (!rawValue) return null
|
||||
const parsed = JSON.parse(rawValue) as {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
selectedLevels?: string[]
|
||||
selectedDateRange?: [string, string] | null
|
||||
searchQuery?: string
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTagColor(status: string): string {
|
||||
if (status === 'ok') return 'success'
|
||||
if (status === 'missing') return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (status === 'ok') return '可用'
|
||||
if (status === 'missing') return '暂无日志'
|
||||
if (status === 'empty') return '暂无上报'
|
||||
if (status === 'docker_unavailable') return 'Docker 不可用'
|
||||
if (status === 'source_unavailable') return '日志源不可用'
|
||||
return status
|
||||
}
|
||||
|
||||
function getStatusHelp(status: string): string | null {
|
||||
if (status === 'missing') return '当前日志文件尚未生成,通常需要先启动对应服务。'
|
||||
if (status === 'empty') return '当前日志源还没有收到任何上报事件。'
|
||||
if (status === 'docker_unavailable') return '当前环境没有可用的 docker 命令,暂时无法读取容器日志。'
|
||||
if (status === 'source_unavailable') return '日志源当前不可读取,请检查服务是否已启动。'
|
||||
return null
|
||||
}
|
||||
|
||||
function resolvePresetRange(days: number): [Dayjs, Dayjs] {
|
||||
const end = dayjs().endOf('day')
|
||||
const start = dayjs().subtract(days, 'day').startOf('day')
|
||||
return [start, end]
|
||||
}
|
||||
|
||||
function normalizeDateRange(
|
||||
range: [Dayjs | null, Dayjs | null] | null,
|
||||
): [Dayjs | null, Dayjs | null] | null {
|
||||
if (!range?.[0] || !range?.[1]) return null
|
||||
return [range[0].startOf('day'), range[1].endOf('day')]
|
||||
}
|
||||
|
||||
function getActiveDatePreset(range: [Dayjs | null, Dayjs | null] | null): string | null {
|
||||
if (!range?.[0] || !range?.[1]) return null
|
||||
|
||||
for (const option of DATE_PRESET_OPTIONS) {
|
||||
const [presetStart, presetEnd] = resolvePresetRange(option.days)
|
||||
if (range[0].isSame(presetStart, 'day') && range[1].isSame(presetEnd, 'day')) {
|
||||
return option.key
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function Logs() {
|
||||
const storedFilters = readStoredFilters()
|
||||
const { user } = useAuthStore()
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const [sources, setSources] = useState<LogSourceSummary[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState<string>(storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState<number>(storedFilters?.lineLimit || 200)
|
||||
const [selectedLevels, setSelectedLevels] = useState<string[]>(
|
||||
normalizeSelectedLevels(storedFilters?.selectedLevels),
|
||||
)
|
||||
const [selectedDateRange, setSelectedDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(
|
||||
storedFilters?.selectedDateRange
|
||||
? normalizeDateRange([dayjs(storedFilters.selectedDateRange[0]), dayjs(storedFilters.selectedDateRange[1])])
|
||||
: null,
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState<string>(typeof storedFilters?.searchQuery === 'string' ? storedFilters.searchQuery : '')
|
||||
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const fetchSources = async () => {
|
||||
setSourcesLoading(true)
|
||||
try {
|
||||
const res = await axios.get<LogSourcesResponse>('/api/v1/system/logs/sources')
|
||||
setSources(res.data.items)
|
||||
setErrorMessage(null)
|
||||
if (res.data.items.length > 0 && !res.data.items.some((item) => item.source_id === selectedSource)) {
|
||||
setSelectedSource(res.data.items[0].source_id)
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setErrorMessage(typeof detail === 'string' ? detail : '加载日志源失败')
|
||||
} finally {
|
||||
setSourcesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSnapshot = async (
|
||||
sourceId: string,
|
||||
limit: number,
|
||||
levels: string[],
|
||||
dateRange: [Dayjs | null, Dayjs | null] | null,
|
||||
searchValue: string,
|
||||
) => {
|
||||
setLogLoading(true)
|
||||
try {
|
||||
const res = await axios.get<LogSnapshot>(`/api/v1/system/logs/${sourceId}`, {
|
||||
params: {
|
||||
limit,
|
||||
level: levels.length === 1 ? levels[0] : 'all',
|
||||
levels: levels.length > 0 ? levels.join(',') : undefined,
|
||||
start_date: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
end_date: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||
search: searchValue.trim() || undefined,
|
||||
},
|
||||
})
|
||||
setSnapshot(res.data)
|
||||
setErrorMessage(null)
|
||||
} catch (error) {
|
||||
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
|
||||
setSnapshot(null)
|
||||
setErrorMessage(typeof detail === 'string' ? detail : '加载日志内容失败')
|
||||
} finally {
|
||||
setLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return
|
||||
fetchSources()
|
||||
}, [isSuperAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin || !selectedSource) return
|
||||
fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
LOG_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
selectedSource,
|
||||
lineLimit,
|
||||
selectedLevels,
|
||||
selectedDateRange:
|
||||
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? [
|
||||
selectedDateRange[0].format('YYYY-MM-DD'),
|
||||
selectedDateRange[1].format('YYYY-MM-DD'),
|
||||
]
|
||||
: null,
|
||||
searchQuery,
|
||||
}),
|
||||
)
|
||||
}, [lineLimit, searchQuery, selectedLevels, selectedDateRange, selectedSource])
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<Alert type="warning" showIcon message="仅超级管理员可查看系统日志" />
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedMeta = sources.find((item) => item.source_id === selectedSource)
|
||||
const statusHelp = getStatusHelp(snapshot?.status || selectedMeta?.status || '')
|
||||
const activeDatePreset = getActiveDatePreset(selectedDateRange)
|
||||
const dailyLogMarkers = useMemo(
|
||||
() =>
|
||||
new Map<string, DailyLogMarker>(
|
||||
(snapshot?.daily_markers || []).map((marker) => [
|
||||
marker.date_token,
|
||||
{
|
||||
total: marker.total,
|
||||
dominantLevel: marker.dominant_level,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[snapshot?.daily_markers],
|
||||
)
|
||||
const currentResultLines = snapshot?.lines || []
|
||||
const lineCountLabel = currentResultLines.length
|
||||
const hasDateFilter = Boolean(selectedDateRange?.[0] && selectedDateRange?.[1])
|
||||
const effectiveLevelLabels = selectedLevels.length === 0
|
||||
? ['ALL']
|
||||
: normalizeSelectedLevels(selectedLevels).map(
|
||||
(level) => LOG_LEVEL_OPTIONS.find((item) => item.value === level)?.label || level.toUpperCase(),
|
||||
)
|
||||
|
||||
const applyDatePreset = (days: number) => {
|
||||
setSelectedDateRange(resolvePresetRange(days))
|
||||
}
|
||||
|
||||
const renderLevelTag = (props: CustomTagProps) => {
|
||||
const { label, value, closable, onClose } = props
|
||||
return (
|
||||
<Tag
|
||||
color={getLogLevelTagColor(String(value))}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
style={{ marginInlineEnd: 4 }}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
{contextHolder}
|
||||
<div className="page-shell logs-page">
|
||||
<div className="page-shell__header">
|
||||
<div className="logs-page__header-copy">
|
||||
<Title level={3} style={{ marginBottom: 4 }}>系统日志</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
统一查看 Planet 当前关键服务日志,并串联 Earth 浏览器端错误、后端异常与服务输出。
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-shell__body">
|
||||
<Card className="logs-page__card">
|
||||
<div className="logs-page__card-body">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="logs-page__toolbar">
|
||||
<div className="logs-page__toolbar-row logs-page__toolbar-row--primary">
|
||||
<Select
|
||||
value={selectedSource}
|
||||
onChange={(value) => setSelectedSource(value)}
|
||||
loading={sourcesLoading}
|
||||
className="logs-page__source-select"
|
||||
options={sources.map((item) => ({
|
||||
value: item.source_id,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedLevels}
|
||||
onChange={(value) => setSelectedLevels(normalizeSelectedLevels(value))}
|
||||
options={LOG_LEVEL_OPTIONS}
|
||||
className="logs-page__level-select"
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
tagRender={renderLevelTag}
|
||||
placeholder="全部级别"
|
||||
/>
|
||||
<Select
|
||||
value={lineLimit}
|
||||
onChange={(value) => setLineLimit(Number(value))}
|
||||
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value} 行` }))}
|
||||
className="logs-page__line-limit-select"
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<div className="logs-page__line-limit-customizer">
|
||||
<Text type="secondary">自定义行数</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={1000}
|
||||
value={lineLimit}
|
||||
onChange={(value) => setLineLimit(Number(value) || 200)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onSearch={(value) => setSearchQuery(value)}
|
||||
placeholder="搜索日志内容、模块名、错误关键字"
|
||||
className="logs-page__search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
|
||||
<RangePicker
|
||||
allowClear
|
||||
value={selectedDateRange}
|
||||
onChange={(value) => setSelectedDateRange(normalizeDateRange(value as [Dayjs | null, Dayjs | null] | null))}
|
||||
cellRender={(current, info) => {
|
||||
if (info.type !== 'date' || !isDayjsValue(current)) return info.originNode
|
||||
|
||||
const marker = dailyLogMarkers.get(current.format('YYYY-MM-DD'))
|
||||
if (!marker) return info.originNode
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`logs-page__calendar-cell logs-page__calendar-cell--${marker.dominantLevel}`}
|
||||
title={`${current.format('YYYY-MM-DD')} · ${marker.total} lines`}
|
||||
>
|
||||
{info.originNode}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
className="logs-page__date-range"
|
||||
/>
|
||||
<Space size={6} className="logs-page__preset-group">
|
||||
{DATE_PRESET_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
size="small"
|
||||
type={activeDatePreset === option.key ? 'primary' : 'default'}
|
||||
onClick={() => applyDatePreset(option.days)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelectedDateRange(null)}
|
||||
disabled={!hasDateFilter}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs-page__summary-card">
|
||||
<div className="logs-page__summary-header">
|
||||
<Space wrap size={8}>
|
||||
<Text strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</Text>
|
||||
<Tag color={getStatusTagColor(snapshot?.status || selectedMeta?.status || 'default')}>
|
||||
{getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}
|
||||
</Tag>
|
||||
<Tag>{(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</Tag>
|
||||
<Text type="secondary">当前显示 {lineCountLabel} 行</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">{snapshot?.description || selectedMeta?.description || '-'}</Text>
|
||||
<Text type="secondary">
|
||||
位置: {snapshot?.location || selectedMeta?.location || '-'}
|
||||
{selectedLevels.length > 0 ? ` · 级别: ${effectiveLevelLabels.join(' / ')}` : ''}
|
||||
{selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? ` · 日期: ${selectedDateRange[0].format('YYYY-MM-DD')} ~ ${selectedDateRange[1].format('YYYY-MM-DD')}`
|
||||
: ''}
|
||||
{searchQuery.trim() ? ` · 检索: ${searchQuery.trim()}` : ''}
|
||||
</Text>
|
||||
{statusHelp ? <Alert type="info" showIcon message={statusHelp} /> : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="system-log-console">
|
||||
<div className="system-log-console__actions">
|
||||
<Tooltip title="刷新日志">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<ReloadOutlined />}
|
||||
className="playground-message__actions-btn"
|
||||
onClick={() => {
|
||||
void fetchSources()
|
||||
if (selectedSource) {
|
||||
void fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制日志">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<CopyOutlined />}
|
||||
className="playground-message__actions-btn"
|
||||
onClick={async () => {
|
||||
const content = currentResultLines.join('\n')
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
messageApi.success('日志内容已复制')
|
||||
} catch {
|
||||
messageApi.error('复制失败,请检查浏览器剪贴板权限')
|
||||
}
|
||||
}}
|
||||
disabled={currentResultLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{logLoading ? (
|
||||
<div className="system-log-console__placeholder">
|
||||
<Spin />
|
||||
</div>
|
||||
) : currentResultLines.length > 0 ? (
|
||||
<Scrollbar className="system-log-console__scroll">
|
||||
<pre className="system-log-console__content">{currentResultLines.join('\n')}</pre>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
<div className="system-log-console__placeholder">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? '当前日期范围没有匹配的日志内容'
|
||||
: '当前没有可显示的日志内容'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logs
|
||||
Reference in New Issue
Block a user